mcp: сервер memo_search/memo_status/memo_reindex

This commit is contained in:
2026-10-02 01:44:45 +03:00
parent f1d497d336
commit f2956e3cef
6 changed files with 586 additions and 75 deletions
+36 -42
View File
@@ -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<String>) — читает 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":<bool>}
Ошибка (нет обязательного аргумента, неизвестный инструмент, исключение) → result с "isError":true
и текстом ошибки, а НЕ JSON-RPC error.
fun main(args: Array<String>) — читает 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":<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-хуком (переиндексация перед поиском).
Текст ответа — человекочитаемый список: `<path>:<line> <heading>\n<text>\n` для каждого хита.
Пустой результат → строка "ничего не найдено".
- toolStatus: по коллекции — `файлов <N>, чанков <M>, индекс <ISO-дата>`.
- toolReindex: полная переиндексация: удалить каталог `<коллекция>/.memo` и создать заново, затем
indexTree. Вернуть `переиндексировано файлов: <N>`.
- memo_reindex через tools/call должен РАБОТАТЬ (не isError).
- Неизвестный инструмент → isError true с текстом "unknown tool: <name>".
Поведение инструментов:
- Коллекции определять ТАК ЖЕ, как 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<java.io.File>`,
и переключи CLI и MCP на них; CLI при этом должен остаться с зелёными тестами).
- toolSearch: Searcher с refresh-хуком (indexTree перед поиском), текст ответа — по строке на хит:
`<path>:<line> <heading>` и следом текст чанка; пусто → "ничего не найдено".
- toolStatus: `<коллекция>: файлов <N>, чанков <M>, индекс <ISO>`.
- toolReindex: удалить `<коллекция>/.memo` целиком и переиндексировать; вернуть `переиндексировано файлов: <N>`.
## 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 строк.
+9 -32
View File
@@ -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<String, String> {
}
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<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) {
val targets = resolveSearchTargets(File(cmd.path))
if (targets.isEmpty()) {
@@ -250,11 +227,11 @@ private fun resolveSearchTargets(path: File): List<File> {
}
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()
}
}
@@ -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" }
}
}
+14 -1
View File
@@ -1,3 +1,16 @@
plugins {
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")
}
}