猫咖
首页博客工具
搜索
语言
切换网站风格
选择主题颜色
点击特效
主题

猫咖 · 持续更新中,源码见 GitHub。

Index 19: MCP Plugin —— 多传输 / 通道路由 / 工具池组装

2026年8月13日
AI智能体Kotlin后端

系列

使用Kotlin从0开发一个ClaudeCode

系列

使用Kotlin从0开发一个ClaudeCode

进度 19 / 21

使用Kotlin从0开发一个ClaudeCode

上一篇

Index 18: Worktree Isolation —— 任务-目录绑定

下一篇

Index 20: Comprehensive Agent —— 全机制集成(收口)

1. 目标

从 ~/.cat-code/mcp.json 读取 MCP 服务器配置,通过 stdio(拉起子进程)或 Streamable HTTP(POST 端点)两种传输连接远端进程/服务,完成 JSON-RPC 握手后 拉取服务端的工具列表,适配为本地 Tool 接口注册进 s02 的 ToolRegistry—— LLM 从此可以调用外部工具,无需编译期嵌入。

完成后的效果(配置一个简单的 echo stdio 服务器):

TEXT
~/.cat-code/mcp.json:
{"mcpServers": {"echo": {"command": "./echo-server.sh"}}}

> /mcp
MCP servers:
  echo  CONNECTED  1 tool(s)  (stdio: ./echo-server.sh)

> /mcp tools
MCP tools:
  mcp__echo__echo  Echo back the input text

> 用 echo 工具向我说"你好"
⏺ 调用 mcp__echo__echo {"text":"你好"} → "你好"

在此之前,cat-code 的工具面完全编译期静态——所有工具都是硬编码的 Kotlin 类(BashTool、 ReadFileTool 等)。本 Index 把工具面从「自带 N 个」升级为「可插拔平台」:只需写一个 配置文件,重启 REPL,MCP 服务器提供的工具就无缝接入 AgentLoop——Built-in tools 和 MCP tools 共享同一个 ToolRegistry,AgentLoop 零感知。

2. 为什么需要

2.1 现实问题:工具面受限于编译期

s02 的工具注册表是 Map<String, Tool> + register(Tool) 显式注入——灵活但不动态: 要新增一个工具(如「查公司内部 API」或「直连数据库查询」),必须先写 Kotlin 代码、 实现 Tool 接口、编译、重跑。这对真实智能体场景不可接受——工具能力必须在运行期从 外部注入,且遵循通用协议(而非每个外部工具都维护一份 Kotlin 适配代码)。

MCP(Model Context Protocol)是这件事的事实工业标准:JSON-RPC 2.0 信封 + 标准化握手/工具发现/工具调用——一个协议打完所有外部工具。Claude Code 的 MCP 插件 走的正是这条路;本 Index 复刻的正是对应的核心子集。

2.2 设计原则(取舍依据)

  1. 协议最小子集,够用即止。 只实现 initialize/initialized/tools/list/ tools/call 四个方法 + JSON-RPC 2.0 信封。resources/prompts/sampling/roots/ SSE 流式推送——全部列为明确非目标(见 spec §7),不因为协议「有这个方法」就实现。 够用的边界是:远端工具能被 LLM 发现并调用——四个方法达成这一定义,再多就是 YAGNI。

  2. 传输层抽象,协议层无关。 McpTransport 接口(McpTransport.kt:33-50)只懂 「发请求等 response/发通知」,不懂 MCP 语义;McpClient(McpClient.kt:30-51) 只懂 initialize/tools/list/tools/call 的业务语义,不懂字节怎么传。两层可独立测试: client 用内存 fake transport 测握手/超时,传输层用真实子进程(bash fixture)/MockEngine 测报文收发——m×n 的组合爆炸被降为 m+n。

  3. 通道路由并入工具池。 原始设计草图(2026-07-24-cat-code-design.md)把 ChannelRouter 独立成一个类。现实是:路由就是「工具名前缀 mcp__server__tool → 持有该连接的 client」的一次 map 查询——独立成类只会沦为纯转发器。折叠进 McpToolPool,少一个文件,语义不丢(spec + 本博客正式记录此取舍)。

  4. 单服务器故障隔离。 一个 MCP 服务器握手/拉起失败不影响其余——warn + 标记 FAILED,其他服务器的工具照常可用。与 s11 的 fail-open 哲学一脉相承。

  5. 适配而非侵入。 远端工具经 McpToolAdapter(McpToolAdapter.kt:25-48) 实现现有的 Tool 接口注册——AgentLoop、ToolRegistry、所有既有工具一行代码不改。 这是 s02 接口设计的验证:「面向接口编程」的回报在这里兑现。

  6. 命名空间防撞。 远端工具一律以 mcp__<server>__<tool> 注册(Claude Code 同款约定)。服务器名与工具名各自消毒为 [A-Za-z0-9_-] 子集,与内置工具零冲突, 工具名本身就能追溯来源——这是 deploy/audit 的刚需。

3. 核心设计与实现

3.1 架构四层

TEXT
┌──────────┐  ~/.cat-code/mcp.json  ── McpConfig.load(fail-open)
│ 配置层   │     McpServerConfig(stdio: command+args+env / http: url)
└────┬─────┘
     │ connectAll(并行,单点故障隔离)
┌────▼─────┐  Tools defined: McpToolDef(name, description, inputSchema, annotations)
│ 客户端层  │  McpClient —— 握手(initialize→initialized) + listTools(缓存) + callTool
└────┬─────┘  withTimeout(30s) 统一超时
     │
┌────▼─────┐  传输层
│ 传输层   │  McpTransport 接口 —— request(method, params) / notify / close
│          │  ├── StdioMcpTransport —— 子进程 NDJSON(先注册 deferred 再写,防竞态)
│          │  └── HttpMcpTransport —— ktor POST(mcp-session-id 会话亲和)
└────┬─────┘
     │ registerInto(toolRegistry)
┌────▼─────┐  适配层
│ 适配层   │  McpToolAdapter —— Tool 接口:mcp__server__tool,执行→callTool→flatten
└──────────┘

四层依赖严格向下:适配层知 client + Tool 接口;client 知 transport + 协议常量; transport 只知 JSON-RPC 信封。上不跨层调用下不感知上。

3.2 McpProtocol —— 常量与数据模型(McpProtocol.kt:13-89)

KOTLIN
object McpProtocol {
    const val JSONRPC_VERSION = "2.0"
    const val MCP_PROTOCOL_VERSION = "2025-06-18"   // 握手版本
    const val CLIENT_NAME = "cat-code"
    const val CLIENT_VERSION = "0.19.0"
    const val METHOD_INITIALIZE = "initialize"
    const val METHOD_INITIALIZED = "notifications/initialized"
    const val METHOD_TOOLS_LIST = "tools/list"
    const val METHOD_TOOLS_CALL = "tools/call"
}

四个数据模型支撑工具发现与调用闭环:

  • McpToolDef(McpProtocol.kt:47-56):服务端 tools/list 返回,@Serializable, 含 inputSchema 原样透传 LLM、annotations.readOnlyHint 映射本地 isReadOnly。
  • McpToolAnnotations:单字段 readOnlyHint: Boolean?——不存则默认 false(安全侧)。
  • McpContent:text 取 text,其他类型(image 等)保留 type 便于占位展示。
  • McpToolCallResult:content 列表 + isError。isError 缺省为 false (协议不严格要求,序列化兼容好于假设)。

3.3 McpTransport —— 传输抽象(McpTransport.kt:19-51)

两个实现 + 一个异常类:

|| 实现 | 协议 | 连接语义 | 关闭语义 | ||------|------|---------|---------| | StdioMcpTransport | NDJSON(行分隔 JSON) | ProcessBuilder.start(),读取 stdout 做读协程 | destroy()(2s grace → destroyForcibly()) | | HttpMcpTransport | 每请求一次独立 POST | 无长连接,每次 POST 都独立 | 空操作(HttpClient 生命归 pool) |

McpException(McpTransport.kt:10-17):统一异常类,携 code: Int?(JSON-RPC error code)与 message: String,所有层都用——client 捕获传输层常,适配器转为 ToolResult(isError=true)。

StdioMcpTransport(StdioMcpTransport.kt:40-162)

四个关键实现点,每个对应进程编程的一次踩坑经历:

  1. 先注册 deferred 再写 stdin(StdioMcpTransport.kt:75-78)。 子进程可能极快返回——响应写在父进程注册 CompletableDeferred 之前就到达了 readLoop 里,此时 dispatch 里 pending[id] 为空,响应被丢弃为 "unknown id"。 解法:pending[id] = deferred 先于 write(buildJsonObject{...})。

  2. 读协程单线程 + ConcurrentHashMap 分发(StdioMcpTransport.kt:116-150)。 stdin 的 readLine 阻塞在 IO 线程;读到一行后 dispatch 根据 id+result/error 完成对应 deferred。pending 用 ConcurrentHashMap——写请求在调用线程,完成在 读协程,无需额外同步。

  3. stderr 独立 drain 协程(StdioMcpTransport.kt:67-69)。 子进程 stderr 管道写满(OS 64KB)会阻塞写端,进而卡死 stdin 写入——这是 s18 runGit 的同款坑:进程读/写/错误三流必须并发消费,不能"先读 stdout"再 "读 stderr"。解法:另起协程,forEachLine 走进 debug 日志。

  4. 进程退出全员失败(StdioMcpTransport.kt:138-142)。 readLoop 的 finally 块给所有在途 pending 补 McpException("server process exited")—— 不等调用方 30s 超时,秒级感知并让用户/日志明白“工具已经死掉”。

HttpMcpTransport(HttpMcpTransport.kt:33-95)

  • 每请求一次 POST:Content-Type: application/json,Accept: application/json。
  • 会话亲和:initialize 响应头 mcp-session-id: <id> 被捕获(line 54),后续请求 自动回带(line 50)。companion object 常量 MCP_SESSION_HEADER 而非字面字符串。
  • SSE 明确拒绝(line 61-63):response.contentType()?.withoutParameters() == ContentType.Text.EventStream 时抛 McpException "SSE responses not supported"—— 这是一种高姿态的「不做」:比沉默返回空更清晰的错误,让用户/未来开发知道这是非目标 而非实现缺失。
  • 通知发 POST 后检查 200/202,不读 body。HttpClient 由 pool 的工厂注入 (测试用 MockEngine),mock 与 CIO 的真实调用共享同一接口。

3.4 McpClient —— 单服务器连接(McpClient.kt:30-116)

KOTLIN
enum class McpClientState { NEW, CONNECTED, FAILED, CLOSED }

class McpClient(serverName, transport, requestTimeoutMs = 30_000) {
    var state = NEW; private set
    suspend fun connect()        // initialize → 校验 protocolVersion → initialized 通知
    suspend fun listTools()      // 首次请求后缓存(tools/list_changed 通知非目标)
    suspend fun callTool(name, arguments): McpToolCallResult
    suspend fun close()
}

三个精心设计的点:

  1. 超时统一在 client(McpClient.kt:108-113)。 withTimeout(requestTimeoutMs) { transport.request(method, params) }—— 超时策略在 client 层统一,传输层不各自实现。TimeoutCancellationException 被 捕获为 McpException,异常语义不含糊。

  2. connect 失败置 FAILED(McpClient.kt:55-79)。 try 块成功则 CONNECTED;catch CancellationException(结构化并发的取消信号) 直接 rethrow 不走 FAILED;其余异常 state = FAILED 后再抛出——调用方(pool)的 catch 看到异常足以日志,但 state 已留下永久审计痕迹(用于 /mcp 状态展示)。

  3. listTools 缓存(McpClient.kt:84-97)。 toolsCache?.let { return it }——首次请求后即缓存。s20 综合集成时若需要刷新工具 列表,调用方主动重建 client(connect 重新握手)即可,无需 invalidateCache()。 缓存语义明确:连接期工具集视为静态。

3.5 McpToolAdapter —— 远端工具适配(McpToolAdapter.kt:25-67)

KOTLIN
class McpToolAdapter(serverName, def: McpToolDef, client: McpClient) : Tool {
    override val name = "mcp__${sanitize(serverName)}__${sanitize(def.name)}"
    override val isReadOnly = def.annotations?.readOnlyHint == true
    override suspend fun execute(input): ToolResult = try {
        val result = client.callTool(def.name, input)
        ToolResult("", flatten(result.content), result.isError)
    } catch (e: Exception) {
        ToolResult("", "Error: MCP tool '$name' failed: ${e.message}", isError = true)
    }
}
  • 命名空间:mcp__<server>__<tool>——Claude Code 同款。sanitize 把非法字符 替换为 -,保证 Anthropic 工具名 [A-Za-z0-9_-]{1,64} 约束。
  • 异常的边界:execute 不抛给 AgentLoop(BashTool 同款约定)——服务器死/超时/ 网络断全部转为 ToolResult("", "Error: ...", isError=true)。这与 s11 的 fail-open 一致:一个工具的失败不应阻断整个 agent 回合。
  • 内容展平:flatten 把 McpContent 列表 join 成单字符串——text 取文本, 非 text 如 image 占位 [unsupported content: <type>](McpToolAdapter.kt:62-66)。
  • toolCallId 留空:AgentLoop 在 r.copy(toolCallId = toolCall.id)(AgentLoop.kt:240) 回填,内置工具同款。

3.6 McpToolPool —— 工具池组装 + 故障隔离(McpToolPool.kt:45-132)

KOTLIN
class McpToolPool(servers: Map<String, McpServerConfig>, scope, httpClientFactory) {
    suspend fun connectAll()              // 并行握手,单点失败 warn 不传播
    suspend fun registerInto(registry)    // CONNECTED 服务器 listTools → 适配注册
    fun status(): List<McpServerStatus>   // /mcp 展示
    suspend fun closeAll()                // 关闭全部子进程
}

connectAll 的并行+隔离(McpToolPool.kt:61-79):

KOTLIN
suspend fun connectAll() = coroutineScope {
    servers.map { (name, cfg) ->
        async {
            try {
                val client = createClient(name, cfg)
                clients[name] = client
                client.connect()
            } catch (e: CancellationException) { throw e }
            catch (e: Exception) { errors[name] = ...; logger.warn(e) { ... } }
        }
    }.awaitAll()
    Unit
}

coroutineScope { ... async { ... } }.awaitAll()——任何子协程(包括自己的)失败 不取消其他(async 内部的 try-catch 吞掉了)。配置错误(stdio 缺 command)在 createClient 抛异常,被 async 内 catch 吞下,不影响并行握手中的其他服务器。 这就是 s11 「不因局部失败而全局不可用」的 MCP 版本。

registerInto 的通道路由(McpToolPool.kt:84-103):

对每个 CONNECTED client,按 listTools() 逐个包 McpToolAdapter 注册进 ToolRegistry。路由在这里是一个命名嵌套:mcp__<server>__<tool>——AgentLoop 按工具名从 registry 取到 adapter,adapter 持有 client 引用直达正确服务器。 一次 map 查询,不是中央路由表——这也是为什么独立的 ChannelRouter 类被折叠进 池的理由(见 2.2 原则 3)。

status 与 registeredTools(McpToolPool.kt:108-117, 122):

  • status() 返回全部服务器状态(含配置错误未建 client 的——clients[name] ?: FAILED)。
  • registeredTools() 返回已注册的 adapter 列表——/mcp tools 展示用。
  • toolCounts 在 registerInto 时记录,供 status 在非 suspend 上下文(/mcp 命令) 查询用——listTools 本身是 suspend 的,不能从命令的 fun execute 直接调。

3.7 McpConfig —— fail-open 加载(McpConfig.kt:28-72)

KOTLIN
@Serializable data class McpServerConfig(
    @SerialName("stdio") val type = STDIO,
    val command: String? = null,   // stdio 必填
    val url: String? = null,       // http 必填
    val requestTimeoutMs: Long = McpClient.DEFAULT_REQUEST_TIMEOUT_MS
)

object McpConfig {
    fun load(path): McpServersFile = when {
        !Files.exists(path) -> McpServersFile()          // 无文件 → 空(不算错误)
        else -> runCatching { json.decodeFromString(...) }
                  .onFailure { logger.warn(...) }        // 损坏 → 空 + warn
                  .getOrDefault(McpServersFile())
    }
}

s12 TaskStore/s18 WorktreeStore 的 fail-open 传统在这延续到配置加载: 文件不存在不是错误(大部分用户没有 MCP 服务器),文件损坏也不是(不阻止 REPL 启动)。 只有服务器运行时的配置错误(stdio 缺 command)才报 FAILED——这是「不让静态配置 拖垮动态能力」的精确分界。

3.8 端到端:从配置到 LLM 调用

在临时仓库里手配一个 echo 服务器(bash 脚本,stdio),走一遍完整链路:

① 配置(~/.cat-code/mcp.json):

JSON
{"mcpServers": {"echo": {"command": "./echo-server.sh"}}}

echo-server.sh 是 NDJSON 行协议的最小 MCP 服务端(与测试 fixture 同款): while read line; case ... in *initialize* / *tools/list* ... esac; done。

② REPL 启动(ReplLoop.kt s19 段):

KOTLIN
mcpPool = McpToolPool(McpConfig.load(...).mcpServers, autoScope)
if (mcpPool.hasServers()) {
    autoScope.launch {
        mcpPool.connectAll()          // spawn 子进程 + 握手
        val count = mcpPool.registerInto(toolRegistry)   // 远端工具入驻注册表
        notificationQueue.push("MCP: 1 server(s), 1 tool(s) registered")
    }
}

关键设计:异步不阻塞启动。autoScope.launch 让 connect+register 跑在后台—— 用户启动后立刻能输入第一个提示词。如果此时远端工具还没就绪,LLM 会收到「无此工具」 的 tool_use 错误(如同任何未知工具),下一轮重试时后台已完成注册。这是「已知竞态, 可接受」的权衡。

③ 工作状态(/mcp):

TEXT
MCP servers:
  echo  CONNECTED  1 tool(s)  (stdio: ./echo-server.sh)

④ LLM 调用:

AgentLoop 在 tool_use 块被解析后,ToolRegistry.get("mcp__echo__echo") → McpToolAdapter.execute(input) → McpClient.callTool("echo", input) → StdioMcpTransport.request("tools/call", {name,arguments}) → 子进程 stdin 写入 JSON → stdout 读出 result → adapter 展平 content → ToolResult。

⑤ REPL 退出(ReplLoop.kt finally 块 s19 段):

KOTLIN
runBlocking { mcpPool.closeAll() }   // 杀子进程
logger.info { "MCP pool closed" }

s17 autoScope.cancel() 在 s19 closeAll() 之前——确保 scope 取消不影响 close 的正常执行。close 顺序:先通知子进程终止(2s grace),后 destroyForcibly() 强杀。

3.9 传输层两实现的深度对比

把 stdio 和 HTTP 两种传输的差异放在一起看,设计取舍会更清楚:

维度StdioMcpTransportHttpMcpTransport
连接模型子进程持续存在每次请求独立 POST
协议NDJSON(行分隔,带 id 关联)JSON-RPC 信封同,HTTP 作为载体
进程生命周期start() → readLoop 常驻 → destroyForcibly()无长连接
请求关联pending: ConcurrentHashMap<id, CompletableDeferred>HTTP 天然的请求-响应
超时处理withTimeout 取消 deferred.await() → finally 清理 pendingwithTimeout 取消 ktor 调用
服务器主动通信通知(no id):debug 忽略;反向请求:warn 忽略—(无推送能力)
错误感知进程退出 → readLoop finally → 全员 pending 补异常HTTP 状态码 / contentType
会话亲和天然(同一个 pid)mcp-session-id 响应头 → 后续请求回带
测试替身bash fixture(真实子进程)MockEngine(请求拦截)
关闭destroy(),2s grace,destroyForcibly()空操作

两个传输的共性是 McpTransport 接口(McpTransport.kt:33-50):request/notify/ close 三个方法。接口签名的约束力在这里体现——换一个传输只换实现,上层 client 代码一个字不改。这是 s02 LLMProvider 接口(Anthropic 适配)的同款模式:接口隔离 换来的是测试可换性、未来扩展性(s20 加新的传输类型如 WebSocket 不用改 client)。

子进程管线的生存周期

StdioMcpTransport 的 init 块中做了三件事(StdioMcpTransport.kt:60-69):

  1. ProcessBuilder.start()——拉起子进程。IOException(命令不存在/无权限)转 McpException;这种“启动前的失败”不需要清理子进程。
  2. readerJob = scope.launch(Dispatchers.IO) { readLoop() }——读者协程,一直跑直到 流关闭或 scope 取消。
  3. stderrJob = scope.launch(Dispatchers.IO) { ... forEachLine { logger.debug } }—— 同款 drain 协程。

这两个协程的 scope 是调用方传入的 autoScope(ReplLoop 持有的 CoroutineScope)。 这意味着:

  • REPL 退出 → scope.cancel() → 两协程取消 → 子进程在被 destroy 前已经不再被读了。 s19 的 closeAll() 在 scope 取消之后才调(ReplLoop.kt finally 块顺序: autoScope.cancel() 先,pool.closeAll() 后)。先取消 scope 让读协程安静退出, 再 process.destroy() 杀进程——避免「正在 destroy 进程时读协程还在 forEachLine 上阻塞」的竞态。

HTTP 的 SSE 拒绝是一种防御性设计

HttpMcpTransport.kt:61-63 在检查 response contentType 时专门写了一行 ContentType.Text.EventStream 的比较。这不是「功能缺失」——这是「有意不做」:

TEXT
if (response.contentType()?.withoutParameters() == ContentType.Text.EventStream) {
    throw McpException("SSE responses not supported (non-goal, see spec)")
}

如果返回空结果/解析失败,AgentLoop 会收到一个空白 ToolResult 继续推理,LLM 可能 以为工具执行成功但没输出——这是比明确报错更差的体验。清晰的不支持消息让日志里 一眼可辨:这不是 bug,是非目标——未来有人要做 SSE 时直接搜这条错误消息就能找到 起手点。

3.10 与 s02 Tool 接口的适配契约

本 Index 对 project 最大的侵入性测试是「一行不该动,一行没动」:

  • tool/Tool.kt(s02):0 行改动
  • tool/ToolRegistry.kt(s02):0 行改动
  • agent/AgentLoop.kt(s01):0 行改动
  • tool/ToolResult.kt(s02):0 行改动

McpToolAdapter 的 execute 返回值遵从的是 s02 的隐式契约(由 BashTool、ReadFileTool 等内置工具验证过的惯例):

契约项内置工具做法McpToolAdapter 做法
ToolResult.toolCallId传空串 "",AgentLoop 回填同左
执行失败返回 ToolResult("", "Error: ...", isError=true),不抛异常同左
jsonSchema 结构{"name":..., "description":..., "input_schema":...}def.inputSchema 原样透传
isReadOnly硬编码(ReadFileTool=true, BashTool=false)由 annotations.readOnlyHint 映射,缺省 false
description手工编写def.description ?: "MCP tool <name> (server: <server>)"——总是有兜底

这是一个设计原则的验收:s02 的接口隔离是有效的。如果当时把 Tool.execute 设计成 有 context 参数 / 依赖注入 / 特定异常类型——现在 MCP adapter 就得侵入式修改所有 既有工具。现在的纯度是迭代纪律的结果,不是巧合。

3.11 开发时序的镜像——测试替身如何影射生产结构

本 Index 的测试文件结构本身就是设计文档的另一种形式:

TEXT
mcp/
├── McpProtocolTest       ← 纯数据,无替身
├── McpClientTest         ← fake transport(Map<method, handler>)
├── StdioMcpTransportTest ← bash fixture(真实子进程)
├── HttpMcpTransportTest  ← MockEngine(ktor)
├── McpConfigTest         ← 临时文件 JSON
├── McpToolAdapterTest    ← fake transport(复用 client test 同款)
└── McpToolPoolTest       ← routingEngine(MockEngine,URL host 路由)

每层测试的替身选择都不是随意的——它受该层的真实依赖方向约束:

  • Client 层依赖 transport 接口 → fake 实现这个接口,等价于「我能查到调用序列, 还能注入超时/错误/正常响应」——transport 接口的签名先验证了接口足够简约(3 个方法即可表达完整交互)。
  • Transport 层依赖真实进程/网络 → fixture 或 MockEngine。fixture 验证的是 「子进程拉起→写入→读回→退出」的真实进程管线,不是 mock。MockEngine 验证的是 HTTP 信封(session-id 回带、content-type 检查、状态码映射)。
  • Pool 层依赖「多服务器并行」→ routingEngine 在一处按 URL host 分发响应, 避免了多 engine 实例与 async 调度之间的分配竞态。

这种「替身层次映射依赖方向」不是刻意设计,而是跟着 TDD 自然浮现的——但浮现之后 回头看,它就是架构自文档化。

3.12 通道路由合并的完整论证

原始草图(docs/superpowers/specs/2026-07-24-cat-code-design.md)的包结构里列了 三个类:

TEXT
mcp/
├── McpTransport.kt
├── ChannelRouter.kt     ← 本 Index 未实现
└── McpToolPool.kt

ChannelRouter 的设想是把「多个 MCP 服务器的请求路由」集中成一个独立组件—— 类似 web 框架的 URL router。但 MCP 工具调用的路由问题比 HTTP 路由简单得多:

  1. 路由键是工具名前缀:mcp__<server>__<tool> 天然编码了目标服务器;
  2. 路由表就是 adapter 持有的 client 引用:每个 adapter 创建时就知道自己的 client 是谁——无需中央查表;
  3. 没有动态路由变更:MCP 服务器在 connectAll 后集合稳定(tools/list_changed 是非目标),不存在「半途加服务器」需更新路由的场景。

因此一个独立的 ChannelRouter 类会是什么样?差不多就是:

KOTLIN
class ChannelRouter(private val clients: Map<String, McpClient>) {
    fun resolve(toolName: String): McpClient? {
        val server = toolName.removePrefix("mcp__").split("__").first()
        return clients[server]
    }
}

三行逻辑。建一个类 + 一个测试文件 + 一个注入点只为了包装这三行——这就是本 Index 做出合并决策的原因。spec 在 §2.2 明确了「通道路由并入工具池」,本博客在实现后提供 论证复盘:不是觉得路由不重要,而是路由已经内化在 adapter 持有 client 引用这一设计 里——路由是你持有谁,不是你查谁。

TEXT
s02 ToolRegistry  ─┐
s11 Error Recovery ┤──→ s19 McpToolPool.registerInto(toolRegistry)
s13 NotificationQ  ┘      适配器实现 Tool 接口,零侵入
                            |
                            ▼
                     s20 Comprehensive Agent
                     最后一个工具面组装:内置工具 + MCP 工具 → 完整注册表
                     /mcp status 合并进统一面板

MCP 服务器在 s20 不用特殊处理——它已经是 ToolRegistry 里的普通工具了。

4. 错误处理总表

场景行为代码位置
mcp.json 不存在空配置,pool 不建,REPL 无感知McpConfig.kt:63-64
mcp.json 损坏warn + 空配置(fail-open)McpConfig.kt:65-69
stdio 缺 command / http 缺 urlcreateClient 抛异常 → async 内被吞 → FAILEDMcpToolPool.kt:128-141
子进程拉起失败(命令不存在)McpException → FAILED + warnStdioMcpTransport.kt:61-64
握手响应缺 protocolVersionMcpException → FAILEDMcpClient.kt:73-75
请求超时(30s 默认,可配)withTimeout 抛 TimeoutCancellationException → 转 McpExceptionMcpClient.kt:108-113
服务器返回 JSON-RPC errorMcpException(code, message)StdioMcpTransport.kt:84-88 / HttpMcpTransport.kt:65-69
HTTP 非 200 状态McpException("MCP HTTP <code>: <body>")HttpMcpTransport.kt:57-59
HTTP 服务器返回 SSEMcpException("SSE responses not supported")——明确非目标HttpMcpTransport.kt:61-63
服务器反向请求(sampling 等)warn 日志忽略StdioMcpTransport.kt:150-151
callTool 时服务器已死适配器 try-catch → ToolResult(isError=true),AgentLoop 正常续跑McpToolAdapter.kt:46-50
单服务器 connect/listTools 失败隔离:其余服务器照常注册McpToolPool.kt:64-77 / McpToolPool.kt:90-95

5. 测试策略

5.1 测试类 × 覆盖点

测试类用例数覆盖点测试替身
McpProtocolTest4序列化 roundtrip / 可选字段兼容 / 缺省值 / 常量固定—(纯数据类测试)
McpClientTest7握手序列 / 缺 protocolVersion 失败 / listTools 缓存 / callTool 参数封装 / 超时 / error 传播 / close内存 fake transport
StdioMcpTransportTest4端到端握手→list→call / id 关联 / 拉起失败 / 进程退出 → pending 失败bash fixture(真实子进程)
HttpMcpTransportTest6信封正确 / session-id 捕获与回带 / 202 通知 / 非 200 抛异常 / SSE 拒绝 / error body 解析MockEngine
McpConfigTest4stdio+http 解析 / 文件缺失 / 损坏 fail-open / 默认类型—(临时文件 JSON)
McpToolAdapterTest7命名空间 / isReadOnly 映射 / schema 透传 / execute 成功与 isError / 异常→Error 结果 / 非文本占位内存 fake transport
McpToolPoolTest4多服务器注册 / 单点失败隔离 / 端到端路由 / hasServers路由 MockEngine
McpCommandTest4空池 / 失败服务器列表 / tools 空态 / 未知子命令 usage空池 + McpServerConfig

5.2 三种测试替身的取舍

bash fixture(StdioMcpTransportTest):真实进程,真实管道竞态,真实退出后清理。 代价是依赖 bash;但 macOS/Linux 上 bash 是事实标准,GitHub CI 也支持。这不是 一个 mock——这是一个最小但真实的 MCP 服务器。23 行 bash 覆盖 initialize/ tools/list/tools/call 三种状态,notifications/initialized 忽略不应答。

MockEngine(HttpMcpTransportTest + McpToolPoolTest):s02 AnthropicProvider 测试 的同款手法——ktor 的 MockEngine 拦截请求,返回预置 JSON,验证 HTTP 信封。池测试 的 routingEngine() 在一处处理了「URL host 路由工具名」的逻辑——不用多个 MockEngine 实例,规避并行 connectAll(async 开协程)下的 engine 分配竞态。

内存 fake transport(McpClientTest + McpToolAdapterTest):最轻量的替身—— 一张 Map<method, handler> 路由。适合测 client 层的协议序列、超时、异常传播; 适配器层的 execute→ToolResult 映射、isError 透传、异常边界。

三者分工:fixture 验证进程管线真的活、MockEngine 验证 HTTP 信封真的对、 fake transport 验证业务语义真的通。

6. 开发过程记录

  1. 「ChannelRouter 独立成类」被折叠:原始草图(2026-07-24-cat-code-design.md) 列了三个类:McpTransport / ChannelRouter / McpToolPool。实现在分析后发现: 路由就是「名字前缀→客户端」的一次 map,独立类只是转发层。合并进 McpToolPool, spec 第 2.2.3 节 + 本博客正式记录此决策。

  2. SSE 列为非目标而非「实现但部分工作」:服务器若返回 SSE(text/event-stream), 与其返回空结果让 LLM 困惑,不如抛 McpException("SSE responses not supported")。 这是一种有态度的非目标——错误消息本身就是设计文档。

  3. MockEngine 通知(无 id)导致的 NPE 是 plan 审查中发现的唯一竞态: routingEngine() 原用 Regex(""""id":(\d+)""").find(body)!! 强解 id,但 transport.notify("notifications/initialized") 的 POST 没有 id 字段—— NPE 会炸掉整个 client.connect()。修法:无 id 时直接返回空 200。

  4. suspend 本地函数是 Kotlin 的隐性便利:McpToolAdapterTest 的 clientReturning 定义为 suspend fun,在 StringSpec 的 suspend 闭包内调用——零额外依赖, connect 的 suspend 调用与 fake transport 的 await 在同一协程上下文里丝滑衔接。

  5. TDD 节奏:6 个实现 commit(protocol → transport+client → stdio → http → config+adapter+pool → command+接线),全部先红后绿;spec/plan 2 个 docs commit。 全量测试通过时(包括 8 个为 ReplContext 新字段修补的既有命令测试),没有回归。

7. 下一站

  • s20 Comprehensive Agent:本 Index 的直接组装对象。McpToolPool.registerInto 把 MCP 工具注入 ToolRegistry,s20 只需确保完整的工具面(内置 + worktree + MCP) 在 AgentLoop 启动前入驻完毕。McpServerStatus 兼容 s20 的统一状态面板。 到那时,四个阶段的 20 个 Index 全部到位,一个完整的智能体平台将首次闭环。

目录

当前章节:1. 目标

  • 1. 1. 目标
  • 2. 2. 为什么需要
  • 3. 2.1 现实问题:工具面受限于编译期
  • 4. 2.2 设计原则(取舍依据)
  • 5. 3. 核心设计与实现
  • 6. 3.1 架构四层
  • 7. 3.2 McpProtocol —— 常量与数据模型(McpProtocol.kt:13-89)
  • 8. 3.3 McpTransport —— 传输抽象(McpTransport.kt:19-51)
  • 9. 3.4 McpClient —— 单服务器连接(McpClient.kt:30-116)
  • 10. 3.5 McpToolAdapter —— 远端工具适配(McpToolAdapter.kt:25-67)
  • 11. 3.6 McpToolPool —— 工具池组装 + 故障隔离(McpToolPool.kt:45-132)
  • 12. 3.7 McpConfig —— fail-open 加载(McpConfig.kt:28-72)
  • 13. 3.8 端到端:从配置到 LLM 调用
  • 14. 3.9 传输层两实现的深度对比
  • 15. 3.10 与 s02 Tool 接口的适配契约
  • 16. 3.11 开发时序的镜像——测试替身如何影射生产结构
  • 17. 3.12 通道路由合并的完整论证
  • 18. 4. 错误处理总表
  • 19. 5. 测试策略
  • 20. 5.1 测试类 × 覆盖点
  • 21. 5.2 三种测试替身的取舍
  • 22. 6. 开发过程记录
  • 23. 7. 下一站
回到顶部

相关推荐

查看全部文章
Index 21: Terminal UX —— Claude Code 风格终端交互

Index 21: Terminal UX —— Claude Code 风格终端交互

2026年8月21日

Index 21 将 cat-code 终端交互升级为 Claude Code 风格,解决旧 REPL 黑箱、无中断及输入体验差的问题。通过 JLine3 与 Mordant 实现历史补全、实时工具可见性、Spinner 状态及 Esc 中断。核心采用 UI 与 AgentLoop 解耦的事件流架构,支持内联权限菜单与优雅降级。同时配置 logback 收敛控制台日志,确保 TUI 清爽且功能无损,显著提升可用性。

Index 20: Comprehensive Agent —— 全机制集成(收口)

Index 20: Comprehensive Agent —— 全机制集成(收口)

2026年8月13日

作为收官之作,把 s01-s19 的二十个核心机制整合为一个全面智能体:统一的状态查询与运行周期、自治认领与工作区隔离的贯通,以及 /status 命令的全局可视化。全机制协同运转,标志着 Cat-Code 从零完整复刻 Claude Code 核心能力的收官。

Index 18: Worktree Isolation —— 任务-目录绑定

Index 18: Worktree Isolation —— 任务-目录绑定

2026年8月13日

引入工作区隔离机制,把每个任务绑定到独立的 git worktree 目录,让并行执行的智能体互不干扰。通过任务-目录绑定与隔离环境的自动创建回收,多智能体可以在各自的工作副本中安全并行开发。