From f2956e3cefc7781df42087332abf3fcb8655a131 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Fri, 2 Oct 2026 01:44:45 +0300 Subject: [PATCH] =?UTF-8?q?mcp:=20=D1=81=D0=B5=D1=80=D0=B2=D0=B5=D1=80=20m?= =?UTF-8?q?emo=5Fsearch/memo=5Fstatus/memo=5Freindex?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/orders/07-mcp.md | 78 ++-- memo-cli/src/main/kotlin/memo/cli/Main.kt | 41 +- .../src/main/kotlin/memo/core/Collections.kt | 31 ++ memo-mcp/build.gradle.kts | 15 +- .../src/main/kotlin/memo/mcp/McpServer.kt | 440 ++++++++++++++++++ .../test/kotlin/memo/mcp/McpProtocolTest.kt | 56 +++ 6 files changed, 586 insertions(+), 75 deletions(-) create mode 100644 memo-core/src/main/kotlin/memo/core/Collections.kt create mode 100644 memo-mcp/src/main/kotlin/memo/mcp/McpServer.kt create mode 100644 memo-mcp/src/test/kotlin/memo/mcp/McpProtocolTest.kt diff --git a/docs/orders/07-mcp.md b/docs/orders/07-mcp.md index 73eb6a6..51fe0b0 100644 --- a/docs/orders/07-mcp.md +++ b/docs/orders/07-mcp.md @@ -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/test/kotlin/memo/mcp/McpProtocolTest.kt ## Зависимости модуля 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") } dependencies { implementation(project(":memo-core")) 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) — читает stdin построчно, отвечает в stdout ОДНОЙ строкой JSON-RPC 2.0 на запрос. -НИЧЕГО кроме JSON-RPC в stdout не печатать (диагностика — только в stderr). -Поддерживаемые методы: -- `initialize` → result: {"protocolVersion":"2024-11-05","capabilities":{"tools":{}},"serverInfo":{"name":"memo","version":"0.1.0"}} -- `notifications/initialized` → уведомление, ответ не отправлять -- `tools/list` → result: {"tools":[ ... ]} с тремя инструментами: - * memo_search: {"path":string,"query":string,"k":integer(по умолч. 8),"mode":string("hybrid"|"lex"|"vec")} - обязательные: path, query - * memo_status: {"path":string} — обязательный: path - * memo_reindex: {"path":string} — обязательный: path -- `tools/call` с params {"name":..., "arguments":{...}} → - result: {"content":[{"type":"text","text":"<строка>"}],"isError":} - Ошибка (нет обязательного аргумента, неизвестный инструмент, исключение) → result с "isError":true - и текстом ошибки, а НЕ JSON-RPC error. +fun main(args: Array) — читает stdin построчно, на каждый запрос печатает в stdout РОВНО одну строку +JSON-RPC 2.0. В stdout НИЧЕГО кроме JSON (включая баннеры/логи) — диагностика только в stderr. + +Методы: +- initialize → {"protocolVersion":"2024-11-05","capabilities":{"tools":{}},"serverInfo":{"name":"memo","version":"0.1.0"}} +- notifications/initialized → ответ не отправлять (вернуть null) +- tools/list → 3 инструмента: memo_search {path, query, k=8, mode="hybrid"}, memo_status {path}, memo_reindex {path} +- tools/call {"name":..., "arguments":{...}} → {"content":[{"type":"text","text":"..."}],"isError":false} + Ошибки (нет обязательного аргумента, неизвестный инструмент, исключение) → isError:true + текст, НЕ JSON-RPC error. - неизвестный метод → {"jsonrpc":"2.0","id":,"error":{"code":-32601,"message":"Method not found"}} +- уведомление (нет "id" в запросе) → ответ не отправлять -Реализацию инструментов вынести в функции, тестируемые без процесса: - fun toolSearch(path: String, query: String, k: Int, mode: SearchMode): String - fun toolStatus(path: String): String - fun toolReindex(path: String): String - fun handleRequest(line: String): String? // null для уведомлений +Тестируемые без процесса функции: handleRequest(line: String): String?, toolSearch(path, query, k, mode): String, +toolStatus(path): String, toolReindex(path): String. -Поведение: -- Модель: MEMO_MODEL_DIR, иначе /root/WORK/memo/models/siglip2. Пути к БД — как в CLI - (<коллекция>/.memo/index.db); определение коллекций: каталог с *.md на глубине до 2. -- toolSearch: поиск через Searcher с refresh-хуком (переиндексация перед поиском). - Текст ответа — человекочитаемый список: `: \n\n` для каждого хита. - Пустой результат → строка "ничего не найдено". -- toolStatus: по коллекции — `файлов , чанков , индекс `. -- toolReindex: полная переиндексация: удалить каталог `<коллекция>/.memo` и создать заново, затем - indexTree. Вернуть `переиндексировано файлов: `. -- memo_reindex через tools/call должен РАБОТАТЬ (не isError). -- Неизвестный инструмент → isError true с текстом "unknown tool: ". +Поведение инструментов: +- Коллекции определять ТАК ЖЕ, как CLI: см. memo-cli/src/main/kotlin/memo/cli/Main.kt — переиспользуй ту + логику определения корня и коллекций (если это private — сделай в memo-core отдельный файл + memo-core/src/main/kotlin/memo/core/Collections.kt с функциями + `fun resolveCollection(path: java.io.File): java.io.File` и `fun findCollections(root: java.io.File): List`, + и переключи CLI и MCP на них; CLI при этом должен остаться с зелёными тестами). +- toolSearch: Searcher с refresh-хуком (indexTree перед поиском), текст ответа — по строке на хит: + `: ` и следом текст чанка; пусто → "ничего не найдено". +- toolStatus: `<коллекция>: файлов , чанков , индекс `. +- toolReindex: удалить `<коллекция>/.memo` целиком и переиндексировать; вернуть `переиндексировано файлов: `. ## McpProtocolTest.kt — ровно 5 тестов, имена ровно такие -1. `initializeHandshake` — handleRequest(строка JSON с initialize) → ответ содержит "2024-11-05" и "memo". -2. `toolsListHasThreeTools` — ответ на tools/list содержит имена memo_search, memo_status, memo_reindex. +1. `initializeHandshake` — ответ содержит "2024-11-05" и "memo". +2. `toolsListHasThreeTools` — ответ содержит memo_search, memo_status, memo_reindex. 3. `unknownMethodReturns32601` — ответ содержит "-32601". -4. `searchWithoutPathIsError` — tools/call memo_search с пустыми arguments → в ответе "isError":true. -5. `notificationProducesNoResponse` — handleRequest("{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}") == null. +4. `searchWithoutPathIsError` — tools/call memo_search с arguments {} → содержит "isError":true. +5. `notificationProducesNoResponse` — handleRequest(уведомление notifications/initialized) == null. -После: ./gradlew :memo-mcp:test --rerun-tasks — зелёные; ./gradlew :memo-mcp:installDist — собирается; -бинарь: memo-mcp/build/install/memo-mcp/bin/memo-mcp +После: ./gradlew :memo-mcp:test --rerun-tasks и ./gradlew :memo-mcp:installDist — зелёные. Коммит: git add -A && git commit -m "mcp: сервер memo_search/memo_status/memo_reindex" СТРОГИЕ ЗАПРЕТЫ: - Не выводить план текстом; сразу создавай файлы. -- Не трогать memo-core, memo-cli, memo-watch. -- Ничего не печатать в stdout, кроме строк JSON-RPC. +- Не трогать логику memo-core, кроме добавления Collections.kt и переключения CLI на него. +- Ничего в stdout, кроме JSON-RPC строк. diff --git a/memo-cli/src/main/kotlin/memo/cli/Main.kt b/memo-cli/src/main/kotlin/memo/cli/Main.kt index 3dc28bc..81ad43c 100644 --- a/memo-cli/src/main/kotlin/memo/cli/Main.kt +++ b/memo-cli/src/main/kotlin/memo/cli/Main.kt @@ -7,6 +7,8 @@ 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 sealed interface Cmd @@ -125,9 +127,8 @@ private fun modelPaths(): Pair { } private fun runIndex(cmd: IndexCmd) { - val raw = File(cmd.path) - val base = if (raw.name == ".memo") raw.parentFile ?: raw else raw - val collections = discoverCollections(base) + val base = resolveCollection(File(cmd.path)) + val collections = findCollections(base) if (collections.isEmpty()) { System.err.println("коллекции не найдены в ${base.absolutePath}") return @@ -159,30 +160,6 @@ private fun runIndex(cmd: IndexCmd) { println("итого: $totalUpdated обновлено в ${collections.size} коллекциях") } -private fun discoverCollections(base: File): List { - if (!base.isDirectory) return emptyList() - val candidates = LinkedHashSet() - candidates.add(base) - val q = ArrayDeque>() - 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) { val targets = resolveSearchTargets(File(cmd.path)) if (targets.isEmpty()) { @@ -250,11 +227,11 @@ private fun resolveSearchTargets(path: File): List { } 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()) { listOf(base) } else { - discoverCollections(base) + findCollections(base) } if (collections.isEmpty()) { println("коллекции не найдены") @@ -383,11 +360,11 @@ private fun performSearch(path: String, query: String, k: Int, mode: SearchMode) } private fun formatStatusInner(path: String): String { - val base = File(path) + val base = resolveCollection(File(path)) val collections = if (File(base, ".memo/index.db").exists()) { listOf(base) } else { - discoverCollections(base) + findCollections(base) } if (collections.isEmpty()) return "коллекции не найдены" val sb = StringBuilder() @@ -572,4 +549,4 @@ private fun unescapeJson(s: String): String { } } return sb.toString() -} \ No newline at end of file +} diff --git a/memo-core/src/main/kotlin/memo/core/Collections.kt b/memo-core/src/main/kotlin/memo/core/Collections.kt new file mode 100644 index 0000000..48dfd5e --- /dev/null +++ b/memo-core/src/main/kotlin/memo/core/Collections.kt @@ -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 { + if (!root.isDirectory) return emptyList() + val candidates = LinkedHashSet() + candidates.add(root) + val q = ArrayDeque>() + 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" } + } +} diff --git a/memo-mcp/build.gradle.kts b/memo-mcp/build.gradle.kts index 9bc4506..1a6888c 100644 --- a/memo-mcp/build.gradle.kts +++ b/memo-mcp/build.gradle.kts @@ -1,3 +1,16 @@ plugins { kotlin("jvm") -} \ No newline at end of file + 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")) +} diff --git a/memo-mcp/src/main/kotlin/memo/mcp/McpServer.kt b/memo-mcp/src/main/kotlin/memo/mcp/McpServer.kt new file mode 100644 index 0000000..dbbddae --- /dev/null +++ b/memo-mcp/src/main/kotlin/memo/mcp/McpServer.kt @@ -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) { + 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 ?: 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 ?: throw IllegalArgumentException("params required") + val name = p["name"] as? String ?: throw IllegalArgumentException("name required") + val args = p["arguments"] as? Map ?: 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() + 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 { + 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 { + 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 { + expect('{') + val map = LinkedHashMap() + 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 { + expect('[') + val list = ArrayList() + 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++ + } +} diff --git a/memo-mcp/src/test/kotlin/memo/mcp/McpProtocolTest.kt b/memo-mcp/src/test/kotlin/memo/mcp/McpProtocolTest.kt new file mode 100644 index 0000000..3280f1a --- /dev/null +++ b/memo-mcp/src/test/kotlin/memo/mcp/McpProtocolTest.kt @@ -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") + } +}