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:
2026-07-16 19:26:01 +04:00
parent 633da3d2df
commit 63e8b4f5d6
9 changed files with 563 additions and 11 deletions
@@ -265,9 +265,11 @@ object ConfigLoader {
val providers = mutableListOf<MutableMap<String, Any>>()
val models = mutableListOf<MutableMap<String, Any>>()
val artifacts = mutableListOf<MutableMap<String, Any>>()
val mcpServers = mutableListOf<MutableMap<String, Any>>()
var currentProvider: MutableMap<String, Any>? = null
var currentModel: MutableMap<String, Any>? = null
var currentArtifact: MutableMap<String, Any>? = null
var currentMcp: MutableMap<String, Any>? = null
// Flush any open array-of-table entry into its list. Called whenever a new
// table header (array or section) starts, so the previous entry is committed.
@@ -275,9 +277,11 @@ object ConfigLoader {
currentProvider?.let { providers.add(it) }
currentModel?.let { models.add(it) }
currentArtifact?.let { artifacts.add(it) }
currentMcp?.let { mcpServers.add(it) }
currentProvider = null
currentModel = null
currentArtifact = null
currentMcp = null
}
for ((lineNum, line) in lines.withIndex()) {
@@ -302,6 +306,11 @@ object ConfigLoader {
currentArtifact = mutableMapOf()
currentSection = ""
}
trimmed == "[[mcp]]" -> {
flushTables()
currentMcp = mutableMapOf()
currentSection = ""
}
trimmed.startsWith("[") && !trimmed.startsWith("[[") && trimmed.endsWith("]") -> {
// Parse section headers like [server] or [tools.shell]
flushTables()
@@ -320,10 +329,12 @@ object ConfigLoader {
val provider = currentProvider
val model = currentModel
val artifact = currentArtifact
val mcp = currentMcp
when {
provider != null -> provider[key] = parsedValue
model != null -> model[key] = parsedValue
artifact != null -> artifact[key] = parsedValue
mcp != null -> mcp[key] = parsedValue
currentSection.isNotEmpty() -> sections[currentSection]?.put(key, parsedValue)
}
}
@@ -334,7 +345,7 @@ object ConfigLoader {
// Don't forget the last array-of-table entry if file ends with one
flushTables()
return buildConfig(sections, providers, models, artifacts)
return buildConfig(sections, providers, models, artifacts, mcpServers)
}
private fun parseValue(valueStr: String, lineNum: Int): Any {
@@ -452,6 +463,7 @@ object ConfigLoader {
providersList: List<Map<String, Any>> = emptyList(),
modelsList: List<Map<String, Any>> = emptyList(),
artifactsList: List<Map<String, Any>> = emptyList(),
mcpList: List<Map<String, Any>> = emptyList(),
): CorrexConfig {
val serverSection = sections["server"] ?: emptyMap()
val tuiSection = sections["tui"] ?: emptyMap()
@@ -735,6 +747,20 @@ object ConfigLoader {
repeatPenalty = samplingSection["repeat_penalty"]?.let { asDouble(it) },
)
val mcp = mcpList.mapNotNull { mcpMap ->
val id = asStringOrNull(mcpMap["id"]) ?: return@mapNotNull null
val command = asStringList(mcpMap["command"])
if (command.isEmpty()) return@mapNotNull null
@Suppress("UNCHECKED_CAST")
val env = (mcpMap["env"] as? Map<String, Any>)?.mapValues { it.value.toString() } ?: emptyMap()
McpServerConfig(
id = id,
command = command,
env = env,
tier = asString(mcpMap["tier"], "T2"),
)
}
val gitSection = sections["git"] ?: emptyMap()
val git = GitConfig(
enabled = asBoolean(gitSection["enabled"], false),
@@ -758,6 +784,7 @@ object ConfigLoader {
orchestration = orchestration,
sampling = sampling,
git = git,
mcp = mcp,
)
}
@@ -19,6 +19,19 @@ data class CorrexConfig(
val sampling: SamplingConfig = SamplingConfig(),
val health: HealthConfig = HealthConfig(),
val git: GitConfig = GitConfig(),
val mcp: List<McpServerConfig> = emptyList(),
)
/**
* An MCP server to mount at startup. Its `tools/list` becomes Correx tools (`mcp__<id>__<name>`),
* gated at [tier] (default T2 = approval) since an external tool's side effects are opaque.
*/
@Serializable
data class McpServerConfig(
val id: String,
val command: List<String>,
val env: Map<String, String> = emptyMap(),
val tier: String = "T2",
)
/**
@@ -390,6 +390,31 @@ class ConfigLoaderTest {
assertEquals(10001, result.modelsSettings.port)
}
@Test
fun `parseToml parses mcp array-of-tables with command list and env`() {
val toml = """
[[mcp]]
id = "codebase-memory"
command = ["codebase-memory-mcp", "serve"]
env = { RUST_LOG = "info" }
[[mcp]]
id = "other"
command = ["other-server"]
""".trimIndent()
val parseTomlMethod = ConfigLoader::class.java.getDeclaredMethod("parseToml", String::class.java)
parseTomlMethod.isAccessible = true
val result = parseTomlMethod.invoke(ConfigLoader, toml) as CorrexConfig
assertEquals(2, result.mcp.size)
assertEquals("codebase-memory", result.mcp[0].id)
assertEquals(listOf("codebase-memory-mcp", "serve"), result.mcp[0].command)
assertEquals("info", result.mcp[0].env["RUST_LOG"])
assertEquals("T2", result.mcp[0].tier)
assertEquals(listOf("other-server"), result.mcp[1].command)
}
@Test
fun `parseToml returns empty models list and default modelsSettings when sections absent`() {
val toml = """