feat(tools): native MCP client — mount stdio MCP servers as Correx tools
Adds an MCP host layer so external MCP servers (AST/LSP code-intelligence, package resolvers, etc.) can be mounted at startup and surface their tools/list as first-class Correx tools named mcp__<server>__<tool>. Each MCP tool is a Tool+ToolExecutor, so it rides the normal ToolExecutor path and inherits tier gating, receipts, and event-recorded execution (#5) with zero special-casing; replay reads the recorded receipt rather than re-calling the server (#8/#9). - McpProtocol/McpStdioClient/McpTool/McpMounter in infrastructure:tools (stdio JSON-RPC 2.0, tools/* slice only). Capabilities empty; safety via default T2 tier since external side effects are opaque. - [[mcp]] config (id/command/env/tier) + array-of-tables parsing. - Main wires mounted servers into extraTools (main + per-workspace paths) with a shutdown hook; a server that fails to start is logged and skipped. - Tests: in-process fake transport (handshake/list/call/error + tool mapping), [[mcp]] parser. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+40
@@ -0,0 +1,40 @@
|
||||
package com.correx.infrastructure.tools.mcp
|
||||
|
||||
import com.correx.core.approvals.Tier
|
||||
import com.correx.core.tools.contract.Tool
|
||||
|
||||
/**
|
||||
* A running MCP server mounted into Correx: its advertised tools (ready to hand to the tool
|
||||
* registry as `extraTools`) plus the handle that shuts the child process down. The caller registers
|
||||
* [close] with the server's shutdown hook, exactly as the managed-model path unloads llama-server.
|
||||
*/
|
||||
class MountedMcpServer internal constructor(
|
||||
val serverId: String,
|
||||
private val client: McpClient,
|
||||
val tools: List<Tool>,
|
||||
) : AutoCloseable {
|
||||
override fun close() = client.close()
|
||||
}
|
||||
|
||||
/** Spawn an MCP server over stdio, initialize it, and surface its `tools/list` as Correx tools. */
|
||||
object McpMounter {
|
||||
suspend fun mount(
|
||||
serverId: String,
|
||||
command: List<String>,
|
||||
env: Map<String, String> = emptyMap(),
|
||||
tier: Tier = Tier.T2,
|
||||
): MountedMcpServer {
|
||||
val client = McpClient.spawn(serverId, command, env)
|
||||
val tools = client.listTools().map { descriptor ->
|
||||
McpTool(
|
||||
serverId = serverId,
|
||||
remoteName = descriptor.name,
|
||||
description = descriptor.description,
|
||||
parametersSchema = descriptor.inputSchema,
|
||||
tier = tier,
|
||||
client = client,
|
||||
)
|
||||
}
|
||||
return MountedMcpServer(serverId, client, tools)
|
||||
}
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
package com.correx.infrastructure.tools.mcp
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
|
||||
/**
|
||||
* Minimal JSON-RPC 2.0 + MCP wire types — just the tools slice Correx needs to mount an MCP
|
||||
* server's tools as Correx tools. Resources, prompts, and sampling are intentionally absent (nothing
|
||||
* consumes them yet). `ignoreUnknownKeys` on the codec lets servers send richer objects than these.
|
||||
*/
|
||||
@Serializable
|
||||
internal data class JsonRpcRequest(
|
||||
val method: String,
|
||||
val params: JsonElement? = null,
|
||||
val id: Long,
|
||||
val jsonrpc: String = "2.0",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
internal data class JsonRpcNotification(
|
||||
val method: String,
|
||||
val params: JsonElement? = null,
|
||||
val jsonrpc: String = "2.0",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
internal data class JsonRpcResponse(
|
||||
val id: Long? = null,
|
||||
val result: JsonElement? = null,
|
||||
val error: JsonRpcError? = null,
|
||||
val jsonrpc: String = "2.0",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
internal data class JsonRpcError(
|
||||
val code: Int,
|
||||
val message: String,
|
||||
val data: JsonElement? = null,
|
||||
)
|
||||
|
||||
/** One tool advertised by an MCP server (`tools/list` entry). */
|
||||
@Serializable
|
||||
internal data class McpToolDescriptor(
|
||||
val name: String,
|
||||
val description: String = "",
|
||||
val inputSchema: JsonObject = JsonObject(emptyMap()),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
internal data class McpListToolsResult(val tools: List<McpToolDescriptor> = emptyList())
|
||||
|
||||
/** One content block of a `tools/call` result. Only text blocks are surfaced to the model. */
|
||||
@Serializable
|
||||
internal data class McpContentBlock(
|
||||
val type: String,
|
||||
val text: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
internal data class McpCallToolResult(
|
||||
val content: List<McpContentBlock> = emptyList(),
|
||||
@SerialName("isError") val isError: Boolean = false,
|
||||
) {
|
||||
/** Flatten text blocks into the single string the Correx tool contract returns. */
|
||||
fun text(): String = content.mapNotNull { it.text }.joinToString("\n")
|
||||
}
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
package com.correx.infrastructure.tools.mcp
|
||||
|
||||
import com.correx.core.tools.process.ChildProcess
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
import kotlinx.serialization.json.put
|
||||
import kotlinx.serialization.json.putJsonObject
|
||||
import java.io.BufferedReader
|
||||
import java.io.Writer
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
/**
|
||||
* The transport an [McpClient] talks over. Extracted so tests can drive the client with an
|
||||
* in-process fake instead of spawning a real subprocess (keeps the client's protocol logic
|
||||
* deterministically testable — no process, no I/O).
|
||||
*/
|
||||
internal interface McpTransport {
|
||||
suspend fun request(request: JsonRpcRequest): JsonRpcResponse
|
||||
suspend fun notify(notification: JsonRpcNotification)
|
||||
fun close()
|
||||
}
|
||||
|
||||
private val MCP_JSON = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
/**
|
||||
* A live MCP session: performs the `initialize` handshake, lists tools, and calls them. Ecosystem-
|
||||
* agnostic — it neither knows nor cares whether the server behind [transport] resolves npm packages,
|
||||
* indexes a codebase, or anything else. The nondeterminism of a `tools/call` is captured by the
|
||||
* kernel's normal tool-execution recording (the call rides the ToolExecutor path), so nothing here
|
||||
* needs to emit events: replay never re-calls because replay never executes tools.
|
||||
*/
|
||||
internal class McpClient(
|
||||
val serverId: String,
|
||||
private val transport: McpTransport,
|
||||
) : McpClientHandle {
|
||||
suspend fun initialize() {
|
||||
val params = buildJsonObject {
|
||||
put("protocolVersion", MCP_PROTOCOL_VERSION)
|
||||
putJsonObject("capabilities") {}
|
||||
putJsonObject("clientInfo") {
|
||||
put("name", "correx")
|
||||
put("version", "0")
|
||||
}
|
||||
}
|
||||
transport.request(JsonRpcRequest(method = "initialize", params = params, id = 0)).orThrow(serverId)
|
||||
transport.notify(JsonRpcNotification(method = "notifications/initialized"))
|
||||
}
|
||||
|
||||
suspend fun listTools(): List<McpToolDescriptor> {
|
||||
val result = transport.request(JsonRpcRequest(method = "tools/list", id = nextId())).orThrow(serverId)
|
||||
return MCP_JSON.decodeFromJsonElement(McpListToolsResult.serializer(), result).tools
|
||||
}
|
||||
|
||||
override suspend fun callTool(remoteName: String, arguments: JsonObject): McpToolCallOutcome {
|
||||
val params = buildJsonObject {
|
||||
put("name", remoteName)
|
||||
put("arguments", arguments)
|
||||
}
|
||||
val result = transport.request(JsonRpcRequest(method = "tools/call", params = params, id = nextId()))
|
||||
.orThrow(serverId)
|
||||
val decoded = MCP_JSON.decodeFromJsonElement(McpCallToolResult.serializer(), result)
|
||||
return McpToolCallOutcome(text = decoded.text(), isError = decoded.isError)
|
||||
}
|
||||
|
||||
fun close() = transport.close()
|
||||
|
||||
private val ids = AtomicLong(1)
|
||||
private fun nextId() = ids.getAndIncrement()
|
||||
|
||||
private fun JsonRpcResponse.orThrow(serverId: String): JsonElement {
|
||||
error?.let { error("MCP server '$serverId' error ${it.code}: ${it.message}") }
|
||||
return result ?: error("MCP server '$serverId' returned no result")
|
||||
}
|
||||
|
||||
companion object {
|
||||
// The protocol revision Correx advertises. Servers negotiate down if they only speak older.
|
||||
const val MCP_PROTOCOL_VERSION = "2024-11-05"
|
||||
|
||||
/**
|
||||
* Spawn [command] as a child process and return an initialized client speaking MCP over its
|
||||
* stdio pipes. The caller owns [McpClient.close] (kills the process). Uses [ChildProcess] so
|
||||
* the server inherits the same unattended/non-interactive execution guarantees as every other
|
||||
* spawned child.
|
||||
*/
|
||||
suspend fun spawn(serverId: String, command: List<String>, env: Map<String, String>): McpClient {
|
||||
val process = withContext(Dispatchers.IO) {
|
||||
ChildProcess.builder(command).apply { environment().putAll(env) }.start()
|
||||
}
|
||||
val client = McpClient(serverId, StdioTransport(process))
|
||||
client.initialize()
|
||||
return client
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Newline-delimited JSON-RPC over a child process's stdio (the MCP stdio transport). Requests are
|
||||
* serialized through a [Mutex]: one line written, then response lines drained until the matching id
|
||||
* arrives — server-initiated notifications/logs on the same pipe are skipped, not mistaken for the
|
||||
* reply. Blocking pipe I/O is confined to [Dispatchers.IO].
|
||||
*/
|
||||
private class StdioTransport(private val process: Process) : McpTransport {
|
||||
private val lock = Mutex()
|
||||
private val writer: Writer = process.outputStream.bufferedWriter()
|
||||
private val reader: BufferedReader = process.inputStream.bufferedReader()
|
||||
|
||||
override suspend fun request(request: JsonRpcRequest): JsonRpcResponse = lock.withLock {
|
||||
withContext(Dispatchers.IO) {
|
||||
writeLine(MCP_JSON.encodeToString(JsonRpcRequest.serializer(), request))
|
||||
while (true) {
|
||||
val line = reader.readLine()
|
||||
?: error("MCP server '${request.method}' closed the connection")
|
||||
if (line.isBlank()) continue
|
||||
val response = runCatching {
|
||||
MCP_JSON.decodeFromString(JsonRpcResponse.serializer(), line)
|
||||
}.getOrNull() ?: continue // not a response we can parse (a notification/log line) — skip
|
||||
if (response.id == request.id) return@withContext response
|
||||
}
|
||||
@Suppress("UNREACHABLE_CODE")
|
||||
error("unreachable")
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun notify(notification: JsonRpcNotification) = lock.withLock {
|
||||
withContext(Dispatchers.IO) {
|
||||
writeLine(MCP_JSON.encodeToString(JsonRpcNotification.serializer(), notification))
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
runCatching { writer.close() }
|
||||
runCatching { reader.close() }
|
||||
process.destroy()
|
||||
}
|
||||
|
||||
private fun writeLine(json: String) {
|
||||
writer.write(json)
|
||||
writer.write("\n")
|
||||
writer.flush()
|
||||
}
|
||||
}
|
||||
|
||||
/** Encode a [ToolRequest]'s parameter map into the JsonObject `arguments` payload for a tools/call. */
|
||||
internal fun argumentsOf(parameters: Map<String, Any?>): JsonObject =
|
||||
JsonObject(parameters.mapValues { (_, value) -> anyToJsonElement(value) })
|
||||
|
||||
private fun anyToJsonElement(value: Any?): JsonElement = when (value) {
|
||||
null -> JsonNull
|
||||
is JsonElement -> value
|
||||
is Boolean -> JsonPrimitive(value)
|
||||
is Number -> JsonPrimitive(value)
|
||||
is String -> JsonPrimitive(value)
|
||||
is Map<*, *> -> JsonObject(value.entries.associate { (k, v) -> k.toString() to anyToJsonElement(v) })
|
||||
is Iterable<*> -> JsonArray(value.map { anyToJsonElement(it) })
|
||||
else -> JsonPrimitive(value.toString())
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.correx.infrastructure.tools.mcp
|
||||
|
||||
import com.correx.core.approvals.Tier
|
||||
import com.correx.core.events.events.ToolRequest
|
||||
import com.correx.core.tools.contract.Tool
|
||||
import com.correx.core.tools.contract.ToolCapability
|
||||
import com.correx.core.tools.contract.ToolExecutor
|
||||
import com.correx.core.tools.contract.ToolResult
|
||||
import com.correx.core.tools.contract.ValidationResult
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
|
||||
/**
|
||||
* One tool advertised by an MCP server, surfaced as a first-class Correx [Tool]/[ToolExecutor]. It
|
||||
* is exactly like any other tool from the kernel's point of view — so it inherits tier gating,
|
||||
* receipts, and event-recorded execution (#5) with zero special-casing, and replay reads the
|
||||
* recorded receipt instead of ever re-calling the server (#8/#9).
|
||||
*
|
||||
* Naming: `mcp__<server>__<remote>` — namespaced by server so two servers can advertise a tool of
|
||||
* the same name, and recognizable as MCP-backed at a glance (the same convention MCP hosts use).
|
||||
*
|
||||
* Capabilities are declared empty: an external MCP tool's side effects are opaque and cannot be
|
||||
* classified into Correx's FILE/NETWORK/SHELL taxonomy, so it does not claim any — safety comes from
|
||||
* [tier] (default T2 = approval-gated) rather than from a capability guess that would misfire the
|
||||
* plane-2 rules (e.g. NETWORK_ACCESS expects a NETWORK_TARGET param an MCP tool doesn't have).
|
||||
*/
|
||||
class McpTool(
|
||||
private val serverId: String,
|
||||
private val remoteName: String,
|
||||
override val description: String,
|
||||
override val parametersSchema: JsonObject,
|
||||
override val tier: Tier,
|
||||
private val client: McpClientHandle,
|
||||
) : Tool, ToolExecutor {
|
||||
|
||||
override val name: String = "mcp__${serverId}__$remoteName"
|
||||
override val requiredCapabilities: Set<ToolCapability> = emptySet()
|
||||
|
||||
override fun validateRequest(request: ToolRequest): ValidationResult = ValidationResult.Valid
|
||||
|
||||
override suspend fun execute(request: ToolRequest): ToolResult =
|
||||
runCatching { client.callTool(remoteName, argumentsOf(request.parameters)) }
|
||||
.fold(
|
||||
onSuccess = { result ->
|
||||
if (result.isError) {
|
||||
// A tool-reported failure (e.g. "package not found") — recoverable so it flows
|
||||
// back into the ReAct loop as evidence rather than aborting the stage.
|
||||
ToolResult.Failure(request.invocationId, result.text, recoverable = true)
|
||||
} else {
|
||||
ToolResult.Success(request.invocationId, result.text)
|
||||
}
|
||||
},
|
||||
// A transport/protocol failure (server crashed, bad frame) is not the model's fault
|
||||
// and not fixable by retrying the same call: non-recoverable.
|
||||
onFailure = { ToolResult.Failure(request.invocationId, "MCP call failed: ${it.message}", recoverable = false) },
|
||||
)
|
||||
}
|
||||
|
||||
/** The slice of [McpClient] an [McpTool] needs — narrowed to an interface so tools are unit-testable. */
|
||||
interface McpClientHandle {
|
||||
suspend fun callTool(remoteName: String, arguments: JsonObject): McpToolCallOutcome
|
||||
}
|
||||
|
||||
/** Transport-agnostic result an [McpClientHandle] hands back (decouples the tool from wire types). */
|
||||
data class McpToolCallOutcome(val text: String, val isError: Boolean)
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
package com.correx.infrastructure.tools.mcp
|
||||
|
||||
import com.correx.core.approvals.Tier
|
||||
import com.correx.core.events.events.ToolRequest
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.events.types.ToolInvocationId
|
||||
import com.correx.core.tools.contract.ToolResult
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* Drives [McpClient] through an in-process [McpTransport] — no subprocess, no I/O. Verifies the
|
||||
* protocol logic (handshake, tools/list decode, tools/call outcome, error surfacing) and the
|
||||
* [McpTool] adapter's mapping onto the Correx tool contract, all deterministically.
|
||||
*/
|
||||
class McpClientTest {
|
||||
|
||||
/** A scripted transport: answers each method from a canned map; records notifications. */
|
||||
private class FakeTransport(private val results: Map<String, JsonRpcResponse>) : McpTransport {
|
||||
val notified = mutableListOf<String>()
|
||||
override suspend fun request(request: JsonRpcRequest): JsonRpcResponse =
|
||||
(results[request.method] ?: error("no scripted result for ${request.method}"))
|
||||
.copy(id = request.id)
|
||||
override suspend fun notify(notification: JsonRpcNotification) { notified.add(notification.method) }
|
||||
override fun close() {}
|
||||
}
|
||||
|
||||
private fun ok(json: kotlinx.serialization.json.JsonElement) = JsonRpcResponse(result = json)
|
||||
|
||||
@Test
|
||||
fun `initialize handshakes then lists and calls tools`(): Unit = runBlocking {
|
||||
val listResult = buildJsonObject {
|
||||
put("tools", kotlinx.serialization.json.buildJsonArray {
|
||||
add(buildJsonObject {
|
||||
put("name", "search_code")
|
||||
put("description", "search the graph")
|
||||
})
|
||||
})
|
||||
}
|
||||
val callResult = buildJsonObject {
|
||||
put("content", kotlinx.serialization.json.buildJsonArray {
|
||||
add(buildJsonObject { put("type", "text"); put("text", "hit one") })
|
||||
add(buildJsonObject { put("type", "text"); put("text", "hit two") })
|
||||
})
|
||||
}
|
||||
val transport = FakeTransport(
|
||||
mapOf(
|
||||
"initialize" to ok(buildJsonObject {}),
|
||||
"tools/list" to ok(listResult),
|
||||
"tools/call" to ok(callResult),
|
||||
),
|
||||
)
|
||||
val client = McpClient("cm", transport)
|
||||
client.initialize()
|
||||
assertEquals(listOf("notifications/initialized"), transport.notified)
|
||||
|
||||
val tools = client.listTools()
|
||||
assertEquals(listOf("search_code"), tools.map { it.name })
|
||||
|
||||
val outcome = client.callTool("search_code", JsonObject(emptyMap()))
|
||||
assertEquals("hit one\nhit two", outcome.text)
|
||||
assertTrue(!outcome.isError)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `server error is surfaced as a thrown failure`(): Unit = runBlocking {
|
||||
val transport = FakeTransport(
|
||||
mapOf("tools/call" to JsonRpcResponse(error = JsonRpcError(code = -32000, message = "boom"))),
|
||||
)
|
||||
val client = McpClient("cm", transport)
|
||||
val ex = runCatching { client.callTool("x", JsonObject(emptyMap())) }.exceptionOrNull()
|
||||
assertTrue(ex?.message?.contains("boom") == true, "expected server error message, got: ${ex?.message}")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `McpTool maps an isError result to a recoverable failure`(): Unit = runBlocking {
|
||||
val handle = object : McpClientHandle {
|
||||
override suspend fun callTool(remoteName: String, arguments: JsonObject) =
|
||||
McpToolCallOutcome(text = "package not found", isError = true)
|
||||
}
|
||||
val tool = McpTool("cm", "resolve", "", JsonObject(emptyMap()), Tier.T2, handle)
|
||||
assertEquals("mcp__cm__resolve", tool.name)
|
||||
val result = tool.execute(req(tool.name)) as ToolResult.Failure
|
||||
assertEquals("package not found", result.reason)
|
||||
assertTrue(result.recoverable)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `McpTool maps a normal result to success`(): Unit = runBlocking {
|
||||
val handle = object : McpClientHandle {
|
||||
override suspend fun callTool(remoteName: String, arguments: JsonObject) =
|
||||
McpToolCallOutcome(text = "ok", isError = false)
|
||||
}
|
||||
val tool = McpTool("cm", "resolve", "", JsonObject(emptyMap()), Tier.T2, handle)
|
||||
val result = tool.execute(req(tool.name)) as ToolResult.Success
|
||||
assertEquals("ok", result.output)
|
||||
}
|
||||
|
||||
private fun req(name: String) = ToolRequest(
|
||||
invocationId = ToolInvocationId("inv-1"),
|
||||
sessionId = SessionId("s-1"),
|
||||
stageId = StageId("st-1"),
|
||||
toolName = name,
|
||||
parameters = mapOf("q" to "vite"),
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user