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:
@@ -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 = """
|
||||
|
||||
Reference in New Issue
Block a user