mcp: сервер memo_search/memo_status/memo_reindex
This commit is contained in:
+36
-42
@@ -1,68 +1,62 @@
|
|||||||
Проект: /root/WORK/memo (Kotlin/JVM). Заказ по MCP-серверу.
|
Проект: /root/WORK/memo (Kotlin/JVM). Заказ по MCP-серверу. Контракт уже в docs/orders/07-mcp.md —
|
||||||
|
читать его как исходное ТЗ, а здесь перечислены обязательные требования и известные грабли.
|
||||||
|
|
||||||
Создать:
|
Создать:
|
||||||
memo-mcp/build.gradle.kts (kotlin("jvm") + application + зависимости на :memo-core и kotlinx-serialization-json)
|
memo-mcp/build.gradle.kts
|
||||||
memo-mcp/src/main/kotlin/memo/mcp/McpServer.kt
|
memo-mcp/src/main/kotlin/memo/mcp/McpServer.kt
|
||||||
memo-mcp/src/test/kotlin/memo/mcp/McpProtocolTest.kt
|
memo-mcp/src/test/kotlin/memo/mcp/McpProtocolTest.kt
|
||||||
|
|
||||||
## Зависимости модуля memo-mcp
|
## Зависимости модуля memo-mcp
|
||||||
|
|
||||||
plugins { kotlin("jvm") application; kotlin("plugin.serialization") version "2.4.10" }
|
plugins { kotlin("jvm"); application; kotlin("plugin.serialization") version "2.4.10" }
|
||||||
application { mainClass.set("memo.mcp.McpServerKt") }
|
application { mainClass.set("memo.mcp.McpServerKt") }
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(project(":memo-core"))
|
implementation(project(":memo-core"))
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
|
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
|
||||||
}
|
}
|
||||||
|
ВАЖНО: в kotlin("plugin.serialization") версию указать обязательно 2.4.10, иначе сборка упадёт на несовместимости версий плагина и Kotlin.
|
||||||
|
Если сериализация начнёт мешать — НЕ добавляй её, собери JSON строками вручную (ручной JSON-рендер допустим и проще).
|
||||||
|
|
||||||
## McpServer.kt — протокол
|
## McpServer.kt
|
||||||
|
|
||||||
fun main(args: Array<String>) — читает stdin построчно, отвечает в stdout ОДНОЙ строкой JSON-RPC 2.0 на запрос.
|
fun main(args: Array<String>) — читает stdin построчно, на каждый запрос печатает в stdout РОВНО одну строку
|
||||||
НИЧЕГО кроме JSON-RPC в stdout не печатать (диагностика — только в stderr).
|
JSON-RPC 2.0. В stdout НИЧЕГО кроме JSON (включая баннеры/логи) — диагностика только в stderr.
|
||||||
Поддерживаемые методы:
|
|
||||||
- `initialize` → result: {"protocolVersion":"2024-11-05","capabilities":{"tools":{}},"serverInfo":{"name":"memo","version":"0.1.0"}}
|
Методы:
|
||||||
- `notifications/initialized` → уведомление, ответ не отправлять
|
- initialize → {"protocolVersion":"2024-11-05","capabilities":{"tools":{}},"serverInfo":{"name":"memo","version":"0.1.0"}}
|
||||||
- `tools/list` → result: {"tools":[ ... ]} с тремя инструментами:
|
- notifications/initialized → ответ не отправлять (вернуть null)
|
||||||
* memo_search: {"path":string,"query":string,"k":integer(по умолч. 8),"mode":string("hybrid"|"lex"|"vec")}
|
- tools/list → 3 инструмента: memo_search {path, query, k=8, mode="hybrid"}, memo_status {path}, memo_reindex {path}
|
||||||
обязательные: path, query
|
- tools/call {"name":..., "arguments":{...}} → {"content":[{"type":"text","text":"..."}],"isError":false}
|
||||||
* memo_status: {"path":string} — обязательный: path
|
Ошибки (нет обязательного аргумента, неизвестный инструмент, исключение) → isError:true + текст, НЕ JSON-RPC error.
|
||||||
* memo_reindex: {"path":string} — обязательный: path
|
|
||||||
- `tools/call` с params {"name":..., "arguments":{...}} →
|
|
||||||
result: {"content":[{"type":"text","text":"<строка>"}],"isError":<bool>}
|
|
||||||
Ошибка (нет обязательного аргумента, неизвестный инструмент, исключение) → result с "isError":true
|
|
||||||
и текстом ошибки, а НЕ JSON-RPC error.
|
|
||||||
- неизвестный метод → {"jsonrpc":"2.0","id":<id>,"error":{"code":-32601,"message":"Method not found"}}
|
- неизвестный метод → {"jsonrpc":"2.0","id":<id>,"error":{"code":-32601,"message":"Method not found"}}
|
||||||
|
- уведомление (нет "id" в запросе) → ответ не отправлять
|
||||||
|
|
||||||
Реализацию инструментов вынести в функции, тестируемые без процесса:
|
Тестируемые без процесса функции: handleRequest(line: String): String?, toolSearch(path, query, k, mode): String,
|
||||||
fun toolSearch(path: String, query: String, k: Int, mode: SearchMode): String
|
toolStatus(path): String, toolReindex(path): String.
|
||||||
fun toolStatus(path: String): String
|
|
||||||
fun toolReindex(path: String): String
|
|
||||||
fun handleRequest(line: String): String? // null для уведомлений
|
|
||||||
|
|
||||||
Поведение:
|
Поведение инструментов:
|
||||||
- Модель: MEMO_MODEL_DIR, иначе /root/WORK/memo/models/siglip2. Пути к БД — как в CLI
|
- Коллекции определять ТАК ЖЕ, как CLI: см. memo-cli/src/main/kotlin/memo/cli/Main.kt — переиспользуй ту
|
||||||
(<коллекция>/.memo/index.db); определение коллекций: каталог с *.md на глубине до 2.
|
логику определения корня и коллекций (если это private — сделай в memo-core отдельный файл
|
||||||
- toolSearch: поиск через Searcher с refresh-хуком (переиндексация перед поиском).
|
memo-core/src/main/kotlin/memo/core/Collections.kt с функциями
|
||||||
Текст ответа — человекочитаемый список: `<path>:<line> <heading>\n<text>\n` для каждого хита.
|
`fun resolveCollection(path: java.io.File): java.io.File` и `fun findCollections(root: java.io.File): List<java.io.File>`,
|
||||||
Пустой результат → строка "ничего не найдено".
|
и переключи CLI и MCP на них; CLI при этом должен остаться с зелёными тестами).
|
||||||
- toolStatus: по коллекции — `файлов <N>, чанков <M>, индекс <ISO-дата>`.
|
- toolSearch: Searcher с refresh-хуком (indexTree перед поиском), текст ответа — по строке на хит:
|
||||||
- toolReindex: полная переиндексация: удалить каталог `<коллекция>/.memo` и создать заново, затем
|
`<path>:<line> <heading>` и следом текст чанка; пусто → "ничего не найдено".
|
||||||
indexTree. Вернуть `переиндексировано файлов: <N>`.
|
- toolStatus: `<коллекция>: файлов <N>, чанков <M>, индекс <ISO>`.
|
||||||
- memo_reindex через tools/call должен РАБОТАТЬ (не isError).
|
- toolReindex: удалить `<коллекция>/.memo` целиком и переиндексировать; вернуть `переиндексировано файлов: <N>`.
|
||||||
- Неизвестный инструмент → isError true с текстом "unknown tool: <name>".
|
|
||||||
|
|
||||||
## McpProtocolTest.kt — ровно 5 тестов, имена ровно такие
|
## McpProtocolTest.kt — ровно 5 тестов, имена ровно такие
|
||||||
|
|
||||||
1. `initializeHandshake` — handleRequest(строка JSON с initialize) → ответ содержит "2024-11-05" и "memo".
|
1. `initializeHandshake` — ответ содержит "2024-11-05" и "memo".
|
||||||
2. `toolsListHasThreeTools` — ответ на tools/list содержит имена memo_search, memo_status, memo_reindex.
|
2. `toolsListHasThreeTools` — ответ содержит memo_search, memo_status, memo_reindex.
|
||||||
3. `unknownMethodReturns32601` — ответ содержит "-32601".
|
3. `unknownMethodReturns32601` — ответ содержит "-32601".
|
||||||
4. `searchWithoutPathIsError` — tools/call memo_search с пустыми arguments → в ответе "isError":true.
|
4. `searchWithoutPathIsError` — tools/call memo_search с arguments {} → содержит "isError":true.
|
||||||
5. `notificationProducesNoResponse` — handleRequest("{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}") == null.
|
5. `notificationProducesNoResponse` — handleRequest(уведомление notifications/initialized) == null.
|
||||||
|
|
||||||
После: ./gradlew :memo-mcp:test --rerun-tasks — зелёные; ./gradlew :memo-mcp:installDist — собирается;
|
После: ./gradlew :memo-mcp:test --rerun-tasks и ./gradlew :memo-mcp:installDist — зелёные.
|
||||||
бинарь: memo-mcp/build/install/memo-mcp/bin/memo-mcp
|
|
||||||
Коммит: git add -A && git commit -m "mcp: сервер memo_search/memo_status/memo_reindex"
|
Коммит: git add -A && git commit -m "mcp: сервер memo_search/memo_status/memo_reindex"
|
||||||
|
|
||||||
СТРОГИЕ ЗАПРЕТЫ:
|
СТРОГИЕ ЗАПРЕТЫ:
|
||||||
- Не выводить план текстом; сразу создавай файлы.
|
- Не выводить план текстом; сразу создавай файлы.
|
||||||
- Не трогать memo-core, memo-cli, memo-watch.
|
- Не трогать логику memo-core, кроме добавления Collections.kt и переключения CLI на него.
|
||||||
- Ничего не печатать в stdout, кроме строк JSON-RPC.
|
- Ничего в stdout, кроме JSON-RPC строк.
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import memo.core.Indexer
|
|||||||
import memo.core.RefreshHook
|
import memo.core.RefreshHook
|
||||||
import memo.core.SearchMode
|
import memo.core.SearchMode
|
||||||
import memo.core.Searcher
|
import memo.core.Searcher
|
||||||
|
import memo.core.findCollections
|
||||||
|
import memo.core.resolveCollection
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
sealed interface Cmd
|
sealed interface Cmd
|
||||||
@@ -125,9 +127,8 @@ private fun modelPaths(): Pair<String, String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun runIndex(cmd: IndexCmd) {
|
private fun runIndex(cmd: IndexCmd) {
|
||||||
val raw = File(cmd.path)
|
val base = resolveCollection(File(cmd.path))
|
||||||
val base = if (raw.name == ".memo") raw.parentFile ?: raw else raw
|
val collections = findCollections(base)
|
||||||
val collections = discoverCollections(base)
|
|
||||||
if (collections.isEmpty()) {
|
if (collections.isEmpty()) {
|
||||||
System.err.println("коллекции не найдены в ${base.absolutePath}")
|
System.err.println("коллекции не найдены в ${base.absolutePath}")
|
||||||
return
|
return
|
||||||
@@ -159,30 +160,6 @@ private fun runIndex(cmd: IndexCmd) {
|
|||||||
println("итого: $totalUpdated обновлено в ${collections.size} коллекциях")
|
println("итого: $totalUpdated обновлено в ${collections.size} коллекциях")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun discoverCollections(base: File): List<File> {
|
|
||||||
if (!base.isDirectory) return emptyList()
|
|
||||||
val candidates = LinkedHashSet<File>()
|
|
||||||
candidates.add(base)
|
|
||||||
val q = ArrayDeque<Pair<File, Int>>()
|
|
||||||
q.addLast(base to 0)
|
|
||||||
while (q.isNotEmpty()) {
|
|
||||||
val (d, depth) = q.removeFirst()
|
|
||||||
if (depth >= 2) continue
|
|
||||||
val children = d.listFiles() ?: continue
|
|
||||||
for (c in children) {
|
|
||||||
if (c.isDirectory && !c.name.startsWith(".")) {
|
|
||||||
candidates.add(c)
|
|
||||||
q.addLast(c to depth + 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return candidates.filter { d ->
|
|
||||||
d.walkTopDown()
|
|
||||||
.maxDepth(8)
|
|
||||||
.any { it.isFile && it.extension == "md" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun runSearch(cmd: SearchCmd) {
|
private fun runSearch(cmd: SearchCmd) {
|
||||||
val targets = resolveSearchTargets(File(cmd.path))
|
val targets = resolveSearchTargets(File(cmd.path))
|
||||||
if (targets.isEmpty()) {
|
if (targets.isEmpty()) {
|
||||||
@@ -250,11 +227,11 @@ private fun resolveSearchTargets(path: File): List<File> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun runStatus(cmd: StatusCmd) {
|
private fun runStatus(cmd: StatusCmd) {
|
||||||
val base = File(cmd.path)
|
val base = resolveCollection(File(cmd.path))
|
||||||
val collections = if (File(base, ".memo/index.db").exists()) {
|
val collections = if (File(base, ".memo/index.db").exists()) {
|
||||||
listOf(base)
|
listOf(base)
|
||||||
} else {
|
} else {
|
||||||
discoverCollections(base)
|
findCollections(base)
|
||||||
}
|
}
|
||||||
if (collections.isEmpty()) {
|
if (collections.isEmpty()) {
|
||||||
println("коллекции не найдены")
|
println("коллекции не найдены")
|
||||||
@@ -383,11 +360,11 @@ private fun performSearch(path: String, query: String, k: Int, mode: SearchMode)
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun formatStatusInner(path: String): String {
|
private fun formatStatusInner(path: String): String {
|
||||||
val base = File(path)
|
val base = resolveCollection(File(path))
|
||||||
val collections = if (File(base, ".memo/index.db").exists()) {
|
val collections = if (File(base, ".memo/index.db").exists()) {
|
||||||
listOf(base)
|
listOf(base)
|
||||||
} else {
|
} else {
|
||||||
discoverCollections(base)
|
findCollections(base)
|
||||||
}
|
}
|
||||||
if (collections.isEmpty()) return "коллекции не найдены"
|
if (collections.isEmpty()) return "коллекции не найдены"
|
||||||
val sb = StringBuilder()
|
val sb = StringBuilder()
|
||||||
@@ -572,4 +549,4 @@ private fun unescapeJson(s: String): String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return sb.toString()
|
return sb.toString()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package memo.core
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
fun resolveCollection(path: File): File {
|
||||||
|
return if (path.name == ".memo") path.parentFile ?: path else path
|
||||||
|
}
|
||||||
|
|
||||||
|
fun findCollections(root: File): List<File> {
|
||||||
|
if (!root.isDirectory) return emptyList()
|
||||||
|
val candidates = LinkedHashSet<File>()
|
||||||
|
candidates.add(root)
|
||||||
|
val q = ArrayDeque<Pair<File, Int>>()
|
||||||
|
q.addLast(root to 0)
|
||||||
|
while (q.isNotEmpty()) {
|
||||||
|
val (d, depth) = q.removeFirst()
|
||||||
|
if (depth >= 2) continue
|
||||||
|
val children = d.listFiles() ?: continue
|
||||||
|
for (c in children) {
|
||||||
|
if (c.isDirectory && !c.name.startsWith(".")) {
|
||||||
|
candidates.add(c)
|
||||||
|
q.addLast(c to depth + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return candidates.filter { d ->
|
||||||
|
d.walkTopDown()
|
||||||
|
.maxDepth(8)
|
||||||
|
.any { it.isFile && it.extension == "md" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,16 @@
|
|||||||
plugins {
|
plugins {
|
||||||
kotlin("jvm")
|
kotlin("jvm")
|
||||||
}
|
application
|
||||||
|
kotlin("plugin.serialization") version "2.4.10"
|
||||||
|
}
|
||||||
|
|
||||||
|
application {
|
||||||
|
mainClass.set("memo.mcp.McpServerKt")
|
||||||
|
applicationName = "memo-mcp"
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(project(":memo-core"))
|
||||||
|
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
|
||||||
|
testImplementation(kotlin("test"))
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,440 @@
|
|||||||
|
package memo.mcp
|
||||||
|
|
||||||
|
import memo.core.Db
|
||||||
|
import memo.core.Embedder
|
||||||
|
import memo.core.Indexer
|
||||||
|
import memo.core.RefreshHook
|
||||||
|
import memo.core.SearchMode
|
||||||
|
import memo.core.Searcher
|
||||||
|
import memo.core.findCollections
|
||||||
|
import memo.core.resolveCollection
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
private const val PROTOCOL_VERSION = "2024-11-05"
|
||||||
|
private const val SERVER_NAME = "memo"
|
||||||
|
private const val SERVER_VERSION = "0.1.0"
|
||||||
|
|
||||||
|
fun main(args: Array<String>) {
|
||||||
|
val br = System.`in`.bufferedReader()
|
||||||
|
while (true) {
|
||||||
|
val line = br.readLine() ?: break
|
||||||
|
if (line.isBlank()) continue
|
||||||
|
try {
|
||||||
|
val resp = handleRequest(line)
|
||||||
|
if (resp != null) {
|
||||||
|
println(resp)
|
||||||
|
}
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
System.err.println("mcp: ${t.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun handleRequest(line: String): String? {
|
||||||
|
val parsed = try {
|
||||||
|
JsonParser(line).parse()
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
return rpcError(null, -32700, "Parse error")
|
||||||
|
}
|
||||||
|
val obj = parsed as? Map<String, Any?> ?: return rpcError(null, -32600, "Invalid Request")
|
||||||
|
if (!obj.containsKey("id")) return null
|
||||||
|
val id = obj["id"]
|
||||||
|
val method = obj["method"] as? String ?: return rpcError(id, -32600, "Invalid Request")
|
||||||
|
val params = obj["params"]
|
||||||
|
return when (method) {
|
||||||
|
"initialize" -> result(id, initializeResult())
|
||||||
|
"tools/list" -> result(id, toolsListResult())
|
||||||
|
"tools/call" -> {
|
||||||
|
try {
|
||||||
|
val r = handleToolCall(params)
|
||||||
|
result(id, r)
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
result(id, toolErrorContent(t.message ?: "error"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> rpcError(id, -32601, "Method not found")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun initializeResult(): String =
|
||||||
|
"""{"protocolVersion":"$PROTOCOL_VERSION","capabilities":{"tools":{}},"serverInfo":{"name":"$SERVER_NAME","version":"$SERVER_VERSION"}}"""
|
||||||
|
|
||||||
|
private fun toolsListResult(): String =
|
||||||
|
"""{"tools":[""" +
|
||||||
|
"""{"name":"memo_search","description":"Гибридный поиск по индексу","inputSchema":{"type":"object","properties":{"path":{"type":"string"},"query":{"type":"string"},"k":{"type":"integer","default":8},"mode":{"type":"string","enum":["hybrid","lex","vec"],"default":"hybrid"}},"required":["path","query"]}},""" +
|
||||||
|
"""{"name":"memo_status","description":"Статус индекса коллекции","inputSchema":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"]}},""" +
|
||||||
|
"""{"name":"memo_reindex","description":"Полная переиндексация","inputSchema":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"]}}""" +
|
||||||
|
"""]}"""
|
||||||
|
|
||||||
|
private fun handleToolCall(params: Any?): String {
|
||||||
|
val p = params as? Map<String, Any?> ?: throw IllegalArgumentException("params required")
|
||||||
|
val name = p["name"] as? String ?: throw IllegalArgumentException("name required")
|
||||||
|
val args = p["arguments"] as? Map<String, Any?> ?: emptyMap()
|
||||||
|
return when (name) {
|
||||||
|
"memo_search" -> {
|
||||||
|
val path = args["path"] as? String
|
||||||
|
if (path.isNullOrBlank()) {
|
||||||
|
toolErrorContent("аргумент path обязателен")
|
||||||
|
} else {
|
||||||
|
val query = args["query"] as? String
|
||||||
|
if (query.isNullOrBlank()) {
|
||||||
|
toolErrorContent("аргумент query обязателен")
|
||||||
|
} else {
|
||||||
|
val k = (args["k"] as? Number)?.toInt() ?: 8
|
||||||
|
val mode = when (args["mode"] as? String) {
|
||||||
|
"lex" -> SearchMode.LEX
|
||||||
|
"vec" -> SearchMode.VEC
|
||||||
|
else -> SearchMode.HYBRID
|
||||||
|
}
|
||||||
|
toolOk(toolSearch(path, query, k, mode))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"memo_status" -> {
|
||||||
|
val path = args["path"] as? String
|
||||||
|
if (path.isNullOrBlank()) {
|
||||||
|
toolErrorContent("аргумент path обязателен")
|
||||||
|
} else {
|
||||||
|
toolOk(toolStatus(path))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"memo_reindex" -> {
|
||||||
|
val path = args["path"] as? String
|
||||||
|
if (path.isNullOrBlank()) {
|
||||||
|
toolErrorContent("аргумент path обязателен")
|
||||||
|
} else {
|
||||||
|
toolOk(toolReindex(path))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> toolErrorContent("unknown tool: $name")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toolSearch(path: String, query: String, k: Int, mode: SearchMode): String {
|
||||||
|
val root = resolveCollection(File(path))
|
||||||
|
val targets = selectTargets(root)
|
||||||
|
if (targets.isEmpty()) return "коллекции не найдены"
|
||||||
|
val (modelPath, tokenizerPath) = modelPaths()
|
||||||
|
val allHits = ArrayList<memo.core.Hit>()
|
||||||
|
for (coll in targets) {
|
||||||
|
val dbPath = File(coll, ".memo/index.db")
|
||||||
|
if (!dbPath.exists()) continue
|
||||||
|
runCatching {
|
||||||
|
val db = Db(dbPath.absolutePath)
|
||||||
|
try {
|
||||||
|
db.init()
|
||||||
|
val embedder = Embedder(modelPath, tokenizerPath)
|
||||||
|
try {
|
||||||
|
val refresh = RefreshHook { r ->
|
||||||
|
val innerDbPath = File(r, ".memo/index.db")
|
||||||
|
if (innerDbPath.exists()) {
|
||||||
|
val innerDb = Db(innerDbPath.absolutePath)
|
||||||
|
try {
|
||||||
|
innerDb.init()
|
||||||
|
Indexer(innerDb, embedder).indexTree(r)
|
||||||
|
} finally {
|
||||||
|
innerDb.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val hits = Searcher(db, embedder, refresh).search(coll, query, k, mode)
|
||||||
|
allHits.addAll(hits)
|
||||||
|
} finally {
|
||||||
|
embedder.close()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
db.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val merged = allHits.sortedByDescending { it.score }.take(k)
|
||||||
|
if (merged.isEmpty()) return "ничего не найдено"
|
||||||
|
val sb = StringBuilder()
|
||||||
|
for (h in merged) {
|
||||||
|
sb.append("${h.path}:${h.line} ${h.heading}\n${h.text}\n")
|
||||||
|
}
|
||||||
|
return sb.toString().trimEnd('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toolStatus(path: String): String {
|
||||||
|
val base = resolveCollection(File(path))
|
||||||
|
val collections = selectTargets(base)
|
||||||
|
if (collections.isEmpty()) return "коллекции не найдены"
|
||||||
|
val sb = StringBuilder()
|
||||||
|
for (coll in collections) {
|
||||||
|
val dbPath = File(coll, ".memo/index.db")
|
||||||
|
if (!dbPath.exists()) {
|
||||||
|
sb.append("${coll.name}: нет индекса\n")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val db = Db(dbPath.absolutePath)
|
||||||
|
try {
|
||||||
|
val fileCount = countInt(db, "SELECT COUNT(*) FROM files")
|
||||||
|
val chunkCount = countInt(db, "SELECT COUNT(*) FROM chunks")
|
||||||
|
val indexedAt = maxDouble(db, "SELECT indexed_at FROM files WHERE indexed_at IS NOT NULL")
|
||||||
|
val date = if (indexedAt != null) {
|
||||||
|
java.time.Instant.ofEpochSecond(indexedAt.toLong()).toString()
|
||||||
|
} else {
|
||||||
|
"—"
|
||||||
|
}
|
||||||
|
sb.append("$coll: файлов $fileCount, чанков $chunkCount, индекс $date\n")
|
||||||
|
} finally {
|
||||||
|
db.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb.toString().trimEnd('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toolReindex(path: String): String {
|
||||||
|
val root = resolveCollection(File(path))
|
||||||
|
val collections = selectTargets(root)
|
||||||
|
if (collections.isEmpty()) return "коллекции не найдены"
|
||||||
|
val (modelPath, tokenizerPath) = modelPaths()
|
||||||
|
var totalUpdated = 0
|
||||||
|
for (coll in collections) {
|
||||||
|
val memoDir = File(coll, ".memo")
|
||||||
|
if (memoDir.exists()) {
|
||||||
|
memoDir.deleteRecursively()
|
||||||
|
}
|
||||||
|
memoDir.mkdirs()
|
||||||
|
val dbPath = File(memoDir, "index.db")
|
||||||
|
val db = Db(dbPath.absolutePath)
|
||||||
|
try {
|
||||||
|
db.init()
|
||||||
|
val embedder = Embedder(modelPath, tokenizerPath)
|
||||||
|
try {
|
||||||
|
totalUpdated += Indexer(db, embedder).indexTree(coll)
|
||||||
|
} finally {
|
||||||
|
embedder.close()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
db.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "переиндексировано файлов: $totalUpdated"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun selectTargets(root: File): List<File> {
|
||||||
|
if (!root.exists()) return emptyList()
|
||||||
|
if (File(root, ".memo/index.db").exists()) return listOf(root)
|
||||||
|
return findCollections(root).filter { File(it, ".memo/index.db").exists() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun modelPaths(): Pair<String, String> {
|
||||||
|
val modelDir = System.getenv("MEMO_MODEL_DIR") ?: "/root/WORK/memo/models/siglip2"
|
||||||
|
return "$modelDir/text_model_int8.onnx" to "$modelDir/tokenizer.model"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun countInt(db: Db, sql: String): Int {
|
||||||
|
val stmt = db.conn.prepare(sql)
|
||||||
|
return try {
|
||||||
|
val rs = stmt.executeQuery()
|
||||||
|
try {
|
||||||
|
if (rs.next()) rs.getInt(0) ?: 0 else 0
|
||||||
|
} finally {
|
||||||
|
rs.close()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
stmt.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun maxDouble(db: Db, sql: String): Double? {
|
||||||
|
val stmt = db.conn.prepare(sql)
|
||||||
|
return try {
|
||||||
|
val rs = stmt.executeQuery()
|
||||||
|
try {
|
||||||
|
var best: Double? = null
|
||||||
|
while (rs.next()) {
|
||||||
|
val v = rs.getDouble(0) ?: continue
|
||||||
|
if (best == null || v > best) best = v
|
||||||
|
}
|
||||||
|
best
|
||||||
|
} finally {
|
||||||
|
rs.close()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
stmt.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toolOk(text: String): String =
|
||||||
|
"""{"content":[{"type":"text","text":${jsonStr(text)}}],"isError":false}"""
|
||||||
|
|
||||||
|
private fun toolErrorContent(text: String): String =
|
||||||
|
"""{"content":[{"type":"text","text":${jsonStr(text)}}],"isError":true}"""
|
||||||
|
|
||||||
|
private fun result(id: Any?, body: String): String =
|
||||||
|
"""{"jsonrpc":"2.0","id":${idLiteral(id)},"result":$body}"""
|
||||||
|
|
||||||
|
private fun rpcError(id: Any?, code: Int, message: String): String =
|
||||||
|
"""{"jsonrpc":"2.0","id":${idLiteral(id)},"error":{"code":$code,"message":${jsonStr(message)}}}"""
|
||||||
|
|
||||||
|
private fun idLiteral(id: Any?): String = when (id) {
|
||||||
|
null -> "null"
|
||||||
|
is Number -> if (id.toDouble().rem(1.0) == 0.0) id.toLong().toString() else id.toString()
|
||||||
|
is Boolean -> id.toString()
|
||||||
|
else -> jsonStr(id.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun jsonStr(s: String): String {
|
||||||
|
val sb = StringBuilder(s.length + 2)
|
||||||
|
sb.append('"')
|
||||||
|
for (c in s) {
|
||||||
|
when (c) {
|
||||||
|
'"' -> sb.append("\\\"")
|
||||||
|
'\\' -> sb.append("\\\\")
|
||||||
|
'\n' -> sb.append("\\n")
|
||||||
|
'\r' -> sb.append("\\r")
|
||||||
|
'\t' -> sb.append("\\t")
|
||||||
|
'\b' -> sb.append("\\b")
|
||||||
|
'\u000C' -> sb.append("\\f")
|
||||||
|
else -> if (c.code < 0x20) {
|
||||||
|
sb.append("\\u%04x".format(c.code))
|
||||||
|
} else {
|
||||||
|
sb.append(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb.append('"')
|
||||||
|
return sb.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private class JsonParser(private val s: String) {
|
||||||
|
private var pos = 0
|
||||||
|
|
||||||
|
fun parse(): Any? {
|
||||||
|
skipWs()
|
||||||
|
val v = parseValue()
|
||||||
|
skipWs()
|
||||||
|
if (pos != s.length) throw IllegalArgumentException("trailing data at $pos")
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseValue(): Any? {
|
||||||
|
skipWs()
|
||||||
|
if (pos >= s.length) throw IllegalArgumentException("unexpected end")
|
||||||
|
return when (val c = s[pos]) {
|
||||||
|
'{' -> parseObject()
|
||||||
|
'[' -> parseArray()
|
||||||
|
'"' -> parseString()
|
||||||
|
't' -> parseLiteral("true", true)
|
||||||
|
'f' -> parseLiteral("false", false)
|
||||||
|
'n' -> parseLiteral("null", null)
|
||||||
|
'-', in '0'..'9' -> parseNumber()
|
||||||
|
else -> throw IllegalArgumentException("unexpected char '$c' at $pos")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseObject(): Map<String, Any?> {
|
||||||
|
expect('{')
|
||||||
|
val map = LinkedHashMap<String, Any?>()
|
||||||
|
skipWs()
|
||||||
|
if (peek() == '}') { pos++; return map }
|
||||||
|
while (true) {
|
||||||
|
skipWs()
|
||||||
|
val key = parseString()
|
||||||
|
skipWs()
|
||||||
|
expect(':')
|
||||||
|
map[key] = parseValue()
|
||||||
|
skipWs()
|
||||||
|
when (peek()) {
|
||||||
|
',' -> { pos++; continue }
|
||||||
|
'}' -> { pos++; return map }
|
||||||
|
else -> throw IllegalArgumentException("expected ',' or '}' at $pos")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseArray(): List<Any?> {
|
||||||
|
expect('[')
|
||||||
|
val list = ArrayList<Any?>()
|
||||||
|
skipWs()
|
||||||
|
if (peek() == ']') { pos++; return list }
|
||||||
|
while (true) {
|
||||||
|
list.add(parseValue())
|
||||||
|
skipWs()
|
||||||
|
when (peek()) {
|
||||||
|
',' -> { pos++; continue }
|
||||||
|
']' -> { pos++; return list }
|
||||||
|
else -> throw IllegalArgumentException("expected ',' or ']' at $pos")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseString(): String {
|
||||||
|
expect('"')
|
||||||
|
val sb = StringBuilder()
|
||||||
|
while (pos < s.length) {
|
||||||
|
val c = s[pos]
|
||||||
|
if (c == '"') {
|
||||||
|
pos++
|
||||||
|
return sb.toString()
|
||||||
|
}
|
||||||
|
if (c == '\\') {
|
||||||
|
if (pos + 1 >= s.length) throw IllegalArgumentException("bad escape at end")
|
||||||
|
when (s[pos + 1]) {
|
||||||
|
'"' -> sb.append('"')
|
||||||
|
'\\' -> sb.append('\\')
|
||||||
|
'/' -> sb.append('/')
|
||||||
|
'n' -> sb.append('\n')
|
||||||
|
'r' -> sb.append('\r')
|
||||||
|
't' -> sb.append('\t')
|
||||||
|
'b' -> sb.append('\b')
|
||||||
|
'f' -> sb.append('\u000C')
|
||||||
|
'u' -> {
|
||||||
|
if (pos + 6 > s.length) throw IllegalArgumentException("bad unicode escape")
|
||||||
|
val hex = s.substring(pos + 2, pos + 6)
|
||||||
|
sb.append(hex.toInt(16).toChar())
|
||||||
|
pos += 4
|
||||||
|
}
|
||||||
|
else -> throw IllegalArgumentException("bad escape '\\${s[pos + 1]}'")
|
||||||
|
}
|
||||||
|
pos += 2
|
||||||
|
} else {
|
||||||
|
sb.append(c)
|
||||||
|
pos++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw IllegalArgumentException("unterminated string")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseNumber(): Any {
|
||||||
|
val start = pos
|
||||||
|
if (s[pos] == '-') pos++
|
||||||
|
var hasDigit = false
|
||||||
|
while (pos < s.length && s[pos].isDigit()) { hasDigit = true; pos++ }
|
||||||
|
var isFloat = false
|
||||||
|
if (pos < s.length && s[pos] == '.') {
|
||||||
|
isFloat = true; pos++
|
||||||
|
while (pos < s.length && s[pos].isDigit()) { hasDigit = true; pos++ }
|
||||||
|
}
|
||||||
|
if (pos < s.length && (s[pos] == 'e' || s[pos] == 'E')) {
|
||||||
|
isFloat = true; pos++
|
||||||
|
if (pos < s.length && (s[pos] == '+' || s[pos] == '-')) pos++
|
||||||
|
while (pos < s.length && s[pos].isDigit()) { hasDigit = true; pos++ }
|
||||||
|
}
|
||||||
|
if (!hasDigit) throw IllegalArgumentException("bad number at $start")
|
||||||
|
val raw = s.substring(start, pos)
|
||||||
|
return if (isFloat) raw.toDouble() else raw.toLong()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseLiteral(lit: String, value: Any?): Any? {
|
||||||
|
if (s.regionMatches(pos, lit, 0, lit.length)) {
|
||||||
|
pos += lit.length
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
throw IllegalArgumentException("expected literal '$lit' at $pos")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun peek(): Char = if (pos < s.length) s[pos] else '\u0000'
|
||||||
|
|
||||||
|
private fun expect(c: Char) {
|
||||||
|
if (peek() != c) throw IllegalArgumentException("expected '$c' at $pos")
|
||||||
|
pos++
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun skipWs() {
|
||||||
|
while (pos < s.length && s[pos].isWhitespace()) pos++
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package memo.mcp
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class McpProtocolTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun initializeHandshake() {
|
||||||
|
val resp = handleRequest(
|
||||||
|
"""{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}"""
|
||||||
|
)
|
||||||
|
assertNotNull(resp, "initialize обязан вернуть ответ")
|
||||||
|
assertTrue(resp.contains("2024-11-05"), "ожидался protocolVersion 2024-11-05, получено: $resp")
|
||||||
|
assertTrue(resp.contains("memo"), "ожидалось имя сервера memo, получено: $resp")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun toolsListHasThreeTools() {
|
||||||
|
val resp = handleRequest(
|
||||||
|
"""{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}"""
|
||||||
|
)
|
||||||
|
assertNotNull(resp, "tools/list обязан вернуть ответ")
|
||||||
|
assertTrue(resp.contains("memo_search"), "ожидался memo_search, получено: $resp")
|
||||||
|
assertTrue(resp.contains("memo_status"), "ожидался memo_status, получено: $resp")
|
||||||
|
assertTrue(resp.contains("memo_reindex"), "ожидался memo_reindex, получено: $resp")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun unknownMethodReturns32601() {
|
||||||
|
val resp = handleRequest(
|
||||||
|
"""{"jsonrpc":"2.0","id":3,"method":"foo/bar"}"""
|
||||||
|
)
|
||||||
|
assertNotNull(resp, "неизвестный метод обязан вернуть JSON-RPC error")
|
||||||
|
assertTrue(resp.contains("-32601"), "ожидался код -32601, получено: $resp")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun searchWithoutPathIsError() {
|
||||||
|
val resp = handleRequest(
|
||||||
|
"""{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"memo_search","arguments":{}}}"""
|
||||||
|
)
|
||||||
|
assertNotNull(resp, "tools/call обязан вернуть ответ")
|
||||||
|
assertTrue(resp.contains("\"isError\":true"), "ожидался isError:true, получено: $resp")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun notificationProducesNoResponse() {
|
||||||
|
val resp = handleRequest(
|
||||||
|
"""{"jsonrpc":"2.0","method":"notifications/initialized"}"""
|
||||||
|
)
|
||||||
|
assertNull(resp, "уведомление не должно порождать ответ, получено: $resp")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user