Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.jorisjonkers.personalstack.assistant.infrastructure.integration

import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.jorisjonkers.personalstack.assistant.config.RagProperties
import com.jorisjonkers.personalstack.assistant.domain.port.KnowledgeWritePort
import com.jorisjonkers.personalstack.assistant.domain.port.RetrievalPort
Expand All @@ -13,7 +14,7 @@ import java.util.concurrent.atomic.AtomicLong
/**
* JSON-RPC over HTTP against knowledge-api's /mcp endpoint. The
* server speaks the MCP 2024-11 dialect; we only need two tools
* (`knowledge_recall` and `knowledge_capture_lesson`) so the client
* (`knowledge.recall` and `knowledge.capture_lesson`) so the client
* is one-call-per-method rather than a full session manager.
*
* Auth: bearer-token allow-list (see knowledge-api/vault-secrets);
Expand All @@ -26,6 +27,7 @@ class KnowledgeMcpClient(
) : RetrievalPort,
KnowledgeWritePort {
private val log = LoggerFactory.getLogger(KnowledgeMcpClient::class.java)
private val mapper = jacksonObjectMapper()
private val seq = AtomicLong(0)

override fun retrieve(
Expand All @@ -36,7 +38,7 @@ class KnowledgeMcpClient(
return runCatching {
val resp =
callTool(
"knowledge_recall",
RECALL_TOOL,
mapOf("query" to query, "limit" to limit, "mode" to props.recallMode),
)
val result = resp?.get("result") ?: return@runCatching emptyList()
Expand Down Expand Up @@ -86,7 +88,7 @@ class KnowledgeMcpClient(
if (!props.enabled) return
runCatching {
callTool(
"knowledge_capture_lesson",
CAPTURE_LESSON_TOOL,
mapOf(
"title" to title,
"body" to body,
Expand Down Expand Up @@ -118,7 +120,8 @@ class KnowledgeMcpClient(
}
}.body(payload)
.retrieve()
.body(JsonNode::class.java)
.body(String::class.java)
?.let(mapper::readTree)
}

private fun parseRecallHits(text: String): List<RetrievalPort.Snippet> {
Expand All @@ -139,4 +142,9 @@ class KnowledgeMcpClient(
val m = Regex("score=([0-9.]+)").find(line) ?: return 0.0
return m.groupValues[1].toDoubleOrNull() ?: 0.0
}

private companion object {
const val RECALL_TOOL = "knowledge.recall"
const val CAPTURE_LESSON_TOOL = "knowledge.capture_lesson"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package com.jorisjonkers.personalstack.assistant.infrastructure.integration

import com.jorisjonkers.personalstack.assistant.config.RagProperties
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpMethod
import org.springframework.http.MediaType
import org.springframework.test.web.client.MockRestServiceServer
import org.springframework.test.web.client.match.MockRestRequestMatchers.header
import org.springframework.test.web.client.match.MockRestRequestMatchers.jsonPath
import org.springframework.test.web.client.match.MockRestRequestMatchers.method
import org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo
import org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess
import org.springframework.web.client.RestClient

class KnowledgeMcpClientTest {
private fun props(enabled: Boolean = true) =
RagProperties(
enabled = enabled,
knowledgeMcpUrl = "http://kb",
knowledgeMcpToken = "token-123",
lightragUrl = "http://lightrag",
recallMode = "hybrid",
)

@Test
fun `retrieve calls canonical dot-form recall tool and parses structured hits`() {
val builder = RestClient.builder()
val server = MockRestServiceServer.bindTo(builder).build()
val client = KnowledgeMcpClient(builder.build(), props())

server
.expect(requestTo("http://kb/mcp"))
.andExpect(method(HttpMethod.POST))
.andExpect(header(HttpHeaders.AUTHORIZATION, "Bearer token-123"))
.andExpect(jsonPath("$.method").value("tools/call"))
.andExpect(jsonPath("$.params.name").value("knowledge.recall"))
.andExpect(jsonPath("$.params.arguments.query").value("agent hooks"))
.andExpect(jsonPath("$.params.arguments.limit").value(2))
.andExpect(jsonPath("$.params.arguments.mode").value("hybrid"))
.andRespond(
withSuccess(
"""
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"structuredContent": {
"hits": [
{
"id": "01KTEST0000000000000000000",
"scope": "project:personal-stack",
"title": "Agent hooks use canonical MCP names",
"snippet": "Use knowledge.recall rather than legacy underscore aliases.",
"score": 0.91
}
]
}
}
}
""".trimIndent(),
MediaType.APPLICATION_JSON,
),
)

val snippets = client.retrieve("agent hooks", limit = 2)

assertThat(snippets).singleElement().satisfies({ snippet ->
assertThat(snippet.id).isEqualTo("01KTEST0000000000000000000")
assertThat(snippet.source).isEqualTo("kb:project:personal-stack:Agent hooks use canonical MCP names")
assertThat(snippet.text).isEqualTo("Use knowledge.recall rather than legacy underscore aliases.")
assertThat(snippet.score).isEqualTo(0.91)
})
server.verify()
}

@Test
fun `ingestNote calls canonical dot-form capture tool`() {
val builder = RestClient.builder()
val server = MockRestServiceServer.bindTo(builder).build()
val client = KnowledgeMcpClient(builder.build(), props())

server
.expect(requestTo("http://kb/mcp"))
.andExpect(method(HttpMethod.POST))
.andExpect(header(HttpHeaders.AUTHORIZATION, "Bearer token-123"))
.andExpect(jsonPath("$.method").value("tools/call"))
.andExpect(jsonPath("$.params.name").value("knowledge.capture_lesson"))
.andExpect(jsonPath("$.params.arguments.title").value("Canonical MCP names"))
.andExpect(jsonPath("$.params.arguments.body").value("Assistant API uses dot-form tool names."))
.andExpect(jsonPath("$.params.arguments.scope").value("project:personal-stack"))
.andExpect(jsonPath("$.params.arguments.tags[0]").value("mcp"))
.andRespond(
withSuccess(
"""{"jsonrpc":"2.0","id":1,"result":{"structuredContent":{"id":"01KCAPTURE"}}}""",
MediaType.APPLICATION_JSON,
),
)

client.ingestNote(
title = "Canonical MCP names",
body = "Assistant API uses dot-form tool names.",
scope = "project:personal-stack",
tags = listOf("mcp"),
)

server.verify()
}

@Test
fun `disabled client does not call the MCP endpoint`() {
val builder = RestClient.builder()
val server = MockRestServiceServer.bindTo(builder).build()
val client = KnowledgeMcpClient(builder.build(), props(enabled = false))

assertThat(client.retrieve("ignored", limit = 1)).isEmpty()
client.ingestNote("ignored", "ignored", "project:personal-stack")

server.verify()
}
}
Loading