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/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 строк.
|
||||
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user