Skip to content

MCP-lite JSON-RPC

本课交付结果

你将交付 McpClient.initialize/listTools/callTool/close:以 spawn(..., shell:false) 启动本地服务,用递增 JSON-RPC id 和 pending map 匹配乱序响应;错误、超时、取消、进程退出都会清理请求。

岗位问题

独立工具进程的响应不保证按请求顺序返回。若客户端只维护“当前请求”,快请求会被交给慢请求的 Promise。进程崩溃时若 pending 不全部 reject,Agent 会永久等待并遗留子进程资源。

前置检查

前置知识快照

先理解第 13 课进程边界:

bash
pnpm --filter @learn-traeai/coding-agent-bootcamp verify:lesson -- 13

本课是教学版 MCP 子集,只使用本地 stdio,不访问网络。

第 13 课已经建立 spawn(executable,args,{shell:false})、输出上限、timeout 与 Abort 的进程边界。本课在其上增加协议层:一个长期子进程承载许多并发 JSON-RPC 请求,响应可以乱序,每个 Promise 都必须有唯一 id、独立计时器和取消监听。进程生命周期成为所有 pending 请求的共同父级。

MCP-lite 只实现 initialize、tools/list、tools/call 与 close,目的是看清协议正确性,不声称覆盖完整标准。客户端只能启动配置中明确的本地 executable 和参数,不接受模型生成命令字符串;stdio 每行一个 JSON 对象,stdout 专用于协议,诊断写 stderr 且有字节上限。

开始前应能解释:request 有 id 并期待 response,notification 没有 id;JSON-RPC error 是远端业务/协议错误,不等于子进程崩溃;Promise.race 超时不会自动停止服务端工作;AbortSignal 是本地取消意图,是否发送远端 cancellation 取决于协议能力。教学实现先保证本地资源清理,再扩展取消通知。

原理拆解

mermaid
sequenceDiagram
  participant A as Agent Runtime
  participant C as McpClient
  participant S as stdio Server
  A->>C: callTool slow
  C->>S: request id=2
  A->>C: callTool fast
  C->>S: request id=3
  S-->>C: response id=3
  C-->>A: resolve fast
  S-->>C: response id=2
  C-->>A: resolve slow
  Note over C: id 精确匹配,非 FIFO

每个 request 分配 id,把 resolve/reject/timer/signal listener 存入 Map。stdout 按行解析 JSON,依据 response.id 找到对应项再清理。timeout 和 abort 主动删除 pending;child exit 把所有剩余请求统一拒绝。

json
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"echo","arguments":{"value":"ok"}}}

代码实验

打开本课 Lab

bash
pnpm --dir bootcamps/coding-agent/labs/33-mcp-lite/starter test
pnpm --dir bootcamps/coding-agent/labs/33-mcp-lite/solution test

Starter 应声明 实现 MCP pending request map;Solution 应通过 6 项本地 fixture server 测试。

失败实现

ts
class UnsafeClient {
  private currentResolve?: (value: unknown) => void;
  call(method: string, params: unknown) {
    stdin.write(JSON.stringify({ method, params }) + "\n");
    return new Promise((resolve) => { this.currentResolve = resolve; });
  }
  onLine(line: string) { this.currentResolve?.(JSON.parse(line).result); }
}

只有一个 currentResolve 意味着第二个调用覆盖第一个;快响应可能交给慢请求,另一个 Promise 永不结束。代码没有 id、超时、取消、错误映射、初始化或进程退出处理,也没有 framing 大小限制。功能测试只发一个请求时它会“成功”,并发与故障一来立即失真。

Pending 记录拥有本请求的全部清理资源:

ts
interface Pending {
  resolve(value: unknown): void;
  reject(error: Error): void;
  timer: ReturnType<typeof setTimeout>;
  signal?: AbortSignal;
  onAbort?: () => void;
}

private settle(id: number, outcome: { result?: unknown; error?: Error }) {
  const pending = this.pending.get(id);
  if (!pending) return;
  this.pending.delete(id);
  clearTimeout(pending.timer);
  if (pending.signal && pending.onAbort) {
    pending.signal.removeEventListener("abort", pending.onAbort);
  }
  outcome.error ? pending.reject(outcome.error) : pending.resolve(outcome.result);
}

关键实现讲解

initialize 成功前禁止 list/call。慢请求和快请求同时发出,服务先回快响应,pending map 仍能各自匹配。close 先拒绝 pending,再 SIGTERM 并等待 exit,保证测试后没有孤儿进程。

客户端构造函数先验证 executable 非空、args 是受控字符串数组、默认 timeout 为正整数、最大行与 stderr 上限合理。不要接受拼好的 command 字符串并用 shell 启动;shell 会重新解释空格、引号、重定向和替换。spawn(command,args,{shell:false,stdio:["pipe","pipe","pipe"]}) 保持参数边界。

环境变量采用 allowlist,而不是展开整个 process.env。至少 PATH 可用于找到运行时,HOME、云密钥、代理和调试变量按服务器需求显式注入。cwd 固定在受控目录,不能由模型提供。若 MCP Server 需要 workspace 访问,仍通过沙箱与权限配置限制,不因为进程独立就自动可信。

start 是幂等的:已有健康 child 不重复 spawn;关闭后如果策略允许可重新启动新实例,但 initialized 重置为 false。监听 error、exit、stdout line 与受限 stderr。spawn error 可能发生在 exit 前或没有 exit,清理函数必须可重复,不能让 pending 被 reject 两次或 child 引用悬挂。

每个 request 使用单调递增整数 id。JavaScript 安全整数耗尽在现实很远,但长期进程可在达到上限前重启;不能在 pending 尚存时回绕复用。id 由客户端生成,模型和调用者无法指定。Map 以 number 为键,收到字符串 "3" 不应与整数三混用。

写请求前建立 pending 记录,避免服务器极快响应时 Map 尚未注册。顺序是检查预取消、分配 id、创建 timer/listener、set Map、写单行 JSON。stdin.write callback 报错时同样通过 settle 删除记录与监听。若先写后 set,fixture server 可能在事件循环调度中回包造成 unknown id。

每行请求包含 jsonrpc 版本、id、method 与 params。method 由客户端固定 wrapper 提供,工具 name 和 arguments 是数据,不拼进 method 或命令。JSON.stringify 失败例如循环参数,要在写前捕获并清理 pending;更好是在建记录前验证输入为受限 JSON value 和字节上限。

stdout framing 使用 readline 适合教学,但 production 必须在积累完整行之前限制 maxLineBytes。服务器若永不换行并持续输出,readline 会积累内存;可用自定义 Buffer decoder,查找换行并在超过上限时终止协议。UTF-8 跨 chunk 解码使用 StringDecoder,不能逐 chunk toString 破坏多字节字符。

stdout 只允许协议。服务器 console.log("ready") 会成为非法 JSON;客户端应把协议视为损坏并拒绝所有 pending,通常终止进程。若只忽略坏行,后续 response 与请求完整性无法保证。诊断必须写 stderr,且客户端只保留尾部或前部受限字节并标记 truncated。

解析 response 后验证对象、jsonrpc、整数 id,以及 result/error 恰有一个。无 id 可视为 notification,只有实现明确支持的 method 才处理;未知 notification 受控忽略或记录。unknown id 可能是已 timeout 的迟到响应,不能匹配给新请求;记录低频指标即可,避免把完整 data 写日志。

乱序正确性完全来自 id→pending。fixture 同时发 slow 与 fast,服务先回 fast,客户端先 resolve fast,同时 slow 的 Map 项仍保留。FIFO queue 会交换结果,按 method 匹配也无法处理同一工具并发调用。id 是协议关联的唯一权威。

ts
it("matches out-of-order responses by id", async () => {
  await client.initialize();
  const slow = client.callTool("echo", { value: "slow", delay: 30 });
  const fast = client.callTool("echo", { value: "fast", delay: 0 });
  await expect(fast).resolves.toEqual({ content: "fast" });
  await expect(slow).resolves.toEqual({ content: "slow" });
});

timeout 回调先确认 id 仍在 Map,再 delete、移除 abort listener、reject 稳定 MCP_TIMEOUT。若服务器支持取消通知,可在本地清理后发送 notifications/cancelled,但发送失败不应重新悬挂原 Promise。迟到 response 找不到 pending 被忽略,绝不能复活已超时调用。

预取消 signal 在任何 spawn/write 前返回 MCP_ABORTED,证明零副作用。运行中 abort listener 做与 timeout 相同的 settle,并尽可能通知远端。listener 使用 once 仍要在正常 response 时 remove,因为 signal 长寿命且请求多时会积累。错误 reason 需安全字符串化,不回显任意对象。

timeout 与 abort 可能同时触发,Map 删除充当一次性提交点:只有先找到 pending 的路径负责 reject,后到者看不到记录并退出。不要分别维护 done 布尔与 Map,两个状态可能不一致。所有完成路径集中 settle,资源所有权更容易审计。

JSON-RPC error 保留 remoteCode、message 和受限 data,映射为结构化 MCP_ERROR;业务失败不是进程崩溃,其他 pending 继续。message 不应拼接整个 data 或 stack。非法 response schema 则是协议故障,可拒绝全部 pending 并关闭 server,防止继续基于不可信流运行。

initialize 是显式状态转换。启动进程不等于协议 ready;客户端发送协议版本、capabilities 和 clientInfo,服务器回应协商版本与能力,验证成功才设 initialized。失败保持 false。并发调用 initialize 要么共享同一 in-flight Promise,要么拒绝重复,不能发送两次导致服务器状态混乱。

listTools 与 callTool 在 initialized 前同步或异步拒绝。listTools 验证 tools 数组、每项 name/description/inputSchema 与唯一性,再映射成 Tool Adapter;Server 声明的工具权限不能自动提升,MCP 配置为整个 server 指定最大 permission,适配器取安全值并进入正常审批。

callTool 验证 name 来自已发现 allowlist,而不是把模型任意字符串交给远端。arguments 先过 inputSchema、大小与 JSON 安全验证;响应 content 同样验证类型和上限。教学实现只检查非空 name,生产扩展需要完整 adapter contract。

child exit 是父级故障。exit handler 清 reader、child 引用和 initialized,构造包含 code/signal 与截断 stderr 的 MCP_PROCESS_EXIT,对 pending Map 做快照逐个清 timer/listener/reject。关闭流程主动发 SIGTERM 时设置 closing,避免把预期 exit 再报成异常,但 close 已先以 CLIENT_CLOSED 拒绝 pending。

close 必须幂等。第一次设置 closing、rejectAll、停止接收新请求、SIGTERM 并等待 exit;若宽限期内不退出则 SIGKILL,再等待资源回收。第二次 close 直接等待相同 closePromise 或返回。只调用 child.kill 不等 exit,会在测试与 CLI 退出后留下句柄或 zombie。

Server 可能忽略 SIGTERM、生成孙进程或保持 pipe。Unix 可用进程组配合沙箱回收,Windows 使用 job object 或容器;仅杀父 PID 不总能保证无孤儿。Runner 对整个 MCP server 生命周期负责,父 Agent 取消时级联 close。进程所有权是 structured concurrency 的核心。

stderr 捕获用字节上限并标记 truncated。无限 stderr 不能拖垮客户端,退出错误仍需要少量诊断。截断按 Buffer 字节而不是 JS 字符,避免中文边界破坏;可保留最后 N 字节,因为崩溃原因常在尾部,但实现要用安全 decoder。任何秘密扫描在进入 Trace 前执行。

背压也需处理。stdin.write 返回 false 表示缓冲已满,应等待 drain 或限制并发请求;无界写会把内存当队列。设置 maxPending,请求超过上限立即 MCP_BUSY,不启动更多计时器。服务端处理能力通过 capability 或配置决定,模型不应无限并发调用同一工具。

心跳与健康检查不能和业务 request 共用一个“当前 Promise”。可以用 JSON-RPC request id 或 notification,并设独立 deadline。心跳失败将 client 标 unhealthy、拒绝新调用并关闭;但短暂空闲不需要持续 ping 本地进程。恢复通常启动全新 server 并重新 initialize,不复用旧 pending。

协议版本协商要保守。服务器回应不支持版本时停止,不能假设大致兼容;capabilities 决定是否允许 tools、notifications 或 cancellation。将来升级完整 MCP 时,method 与 schema 由 contract 包维护,教学 client 的 pending 生命周期仍可复用。

测试 afterEach close 所有 client,避免一个失败测试遗留进程影响后续。六条 Lab 证据覆盖 happy path、乱序、远端 error、timeout、abort 和 exit;项目测试再覆盖 oversized line、stderr cap 与 adapter。压力测试发一百个随机 delay 请求,结束后 pending size、listener 与活动 timer 全为零。

可观测指标包含 active server、pending 数、请求 method、延迟、timeout、abort、unknown response、协议错误、stderr 截断与退出原因。工具参数和 result 不进入普通指标;Trace 记录 request id、tool name、状态、耗时与安全摘要。id 只在单 client 内唯一,跨运行关联还需 runId/serverInstanceId。

安全上,MCP server 是独立执行主体而非自动可信服务。可执行路径来自管理员配置和签名包,启动环境最小化,沙箱限制文件与网络,工具清单经过权限映射,每次调用仍过 Policy。stdio 隔离减少主进程内任意代码,却没有消除 server 自身越权能力。

完整验收启动 fixture server,initialize 后 list echo 并调用;并发 slow/fast 证明 id 匹配;fail 映射 remote error 且 client 仍健康;never 分别 timeout 与 abort,pending 清空;exit 让全部请求拒绝;最后 close 等待 PID 消失。每条路径检查 timer 与 listener 回收,才算生命周期闭合。

启动失败要区分 executable 不存在、权限不足和握手失败。spawn 的 error 事件可能在 request 已进入 Map 后到达,rejectAll 必须覆盖 initialize;握手 response schema 错误则关闭已经启动的 child。不要把两类都包装成“工具调用失败”,否则用户会反复重试一个根本无法启动的配置。

自动重启仅适用于未来新请求,不能透明重放旧 pending。工具调用可能产生写入或外部副作用,进程退出时客户端不知道服务端是否已经执行、只是尚未回包;自动重发会重复动作。所有 pending 以 indeterminate 或 process-exit 失败,上层根据工具幂等性和审批决定是否重试。

工具适配器应给每个 MCP tool 生成 ToolDefinition,但 server 提供的 description 与 inputSchema 仍是不可信数据。限制 name slug、描述字节、schema 深度和工具总数;重复 name 拒绝。配置为 server 指定 read/execute 等最大 permission,不能让 server 在 description 里自称 write 后自动获得能力。

initialize 返回的 serverInfo、protocolVersion 与 capabilities 进入安全摘要和 Trace。clientInfo 不包含用户名、HOME 或仓库绝对路径,只给产品名与版本。协议协商失败时列出双方受控版本,不打印完整握手 payload。版本锁有助于复现某次工具行为。

JSON-RPC request 对象还应限制 params 序列化字节,防止模型生成巨大数组把 stdin 缓冲和 Server 内存占满。验证在创建 pending 前完成,超限零副作用;response 逐行也受 maxLineBytes。输入和输出预算与 Agent 总上下文预算联动,不能因为跨进程就绕开成本治理。

如果 stdin.write 返回 false,后续调用进入有界发送队列,等待 drain;队列项仍有 deadline,尚未实际发送也可取消。无界等待会让 timeout 已发生的请求之后仍被写入 Server。发送前再次确认 pending 仍存在,已 settle 项直接丢弃,避免“幽灵请求”。

Server notification 未来可报告进度或日志。Notification 无 id,不能 resolve 任意 pending;只接受协商 capability 中允许的 method,payload 同样验证和限流。进度通过 request id 关联到 Trace,未知 id 忽略。通知洪水有每秒与字节上限,超限可终止不守协议的 Server。

取消通知存在竞态:本地发送 cancellation 后,正常 result 可能同时到达。Map 的第一次 settle 决定调用者看到结果或取消;协议层可记录 late completion,但不二次 resolve。对于有副作用工具,即使调用者看到 aborted,也不能断言 Server 未执行,结果状态需要注明执行不确定。

close 与 request 也有竞态。closing 标志在 rejectAll 前设置,新 request 入口看到后立即 CLIENT_CLOSED,不触发 start;已排队 write callback 若晚到,只能清理自己的 id。closePromise 让多调用者等待同一个退出过程,防止一个 close 把 child 清空后另一个错误地启动新进程。

重启策略应由上层 McpManager 管理,而不是 request 隐式 start 任意次数。Manager 对同一 server hash 做指数退避、最大失败数和 quarantine,健康后才重置计数。配置或二进制 hash 改变可解除隔离,但需要重新审批。单个 client 聚焦一段清晰生命周期。

多个 MCP Server 的工具汇总要处理命名冲突,常用 server/tool 命名空间。路由目录只展示精简 metadata,选中后才初始化对应 Server,沿用 Skill 渐进加载;但用户明确常用 server 可预热。未选服务不启动进程,减少资源、攻击面和启动延迟。

Server 工作目录可以是只读包目录,workspace 访问通过显式参数或沙箱挂载。不要把 cwd 设为用户仓库再假定工具只读;恶意 Server 可以直接访问所有相对文件。操作系统账号、mount、网络 namespace 和资源 limits 落实配置声明,MCP 协议本身不提供沙箱。

秘密注入遵循按调用最小化。若某 server 需要 API token,通过专门 secret broker 在启动或请求时注入,stderr/stdout 脱敏且不进入 args;所有工具不应共享整个 Agent 环境。Server 退出后撤销临时凭证,长寿命进程的凭证轮换需要安全重启。

性能测试区分握手冷启动、单调用延迟、并发吞吐与大响应内存。先确保一百请求结果一一对应、pending 回零,再优化。平均延迟会掩盖队头阻塞,查看 P95/P99;Server 串行执行不是客户端错误,但工具路由可据 capability 控制并发。

故障注入还应包含半行 JSON 后退出、合法 JSON 非对象、重复 response id、result 与 error 同时出现、超大无换行输出、stdin 提前关闭和 stderr 洪水。每种只允许稳定错误与有界资源,不得崩溃主 Runtime。协议 parser 的输入完全由外部进程控制,要像网络 parser 一样对待。

持续集成检测孤儿进程可以让 fixture 写 PID 文件,afterEach close 后用无信号探测确认不存在;同时检查临时目录和活动 handle。若测试只因主进程退出自动带走子进程,在桌面长期 Agent 中仍会泄漏,因此必须显式证明 close 行为。

报告一次 MCP 故障时,收集 server identity/hash、协议版本、request method/id、阶段、稳定 code、耗时、stderr 截断摘要与退出状态。不要收集 arguments 或完整 content,除非进入受权限控制的脱敏 artifact。证据足够定位生命周期,不扩大业务数据暴露。

最终判断标准不是“echo 返回 ok”,而是任意响应顺序和终止路径都能把每个请求恰好完成一次,并归还 timer、listener、Map、pipe 与进程。只要还有一个 Promise 会悬挂或一个迟到响应会误配,客户端就不能进入长期运行的 Coding Agent。

部署健康检查不应真的调用有副作用工具,可只完成 initialize 与 tools/list,再用专门只读 ping;检查完成后 close 并确认退出。启动时对所有可选 Server 全量健康检查会拖慢 CLI,按需服务可在首次选择时检查,同时把失败缓存一个短退避窗口。

当父 Agent 被取消,McpManager 先 abort 所有调用,让每个 pending 走统一 settle,再 close Server;不能先切断 pipe 后让调用者只看到模糊 exit。取消原因沿结构化错误传回 Orchestrator,区分用户取消、预算停止和进程故障,报告才不会把主动终止算工具失败。

代码评审可画出资源所有权表:Client 拥有 child、reader、stderr buffer 与 pending Map;每个 Pending 拥有 timer 和 signal listener;close 拥有退出等待。任何资源只能有一个明确释放者,释放操作幂等。这个表比散落的 try/catch 更能发现泄漏与双重完成。

安全演练让 fixture 输出一条超大无换行响应并持续写 stderr,同时保留一个 never 请求。客户端应在固定字节与时间内拒绝、截断 stderr、清空 pending、终止 child,主进程内存保持有界。只有 happy path 加 timeout 还不足以证明面对恶意 Server 的韧性。

每个请求都应有唯一归属、唯一完成点与完整回收证据,任何异常都不能破坏这个基本承诺。

运行与验证

bash
pnpm --filter @learn-traeai/coding-agent-bootcamp verify:lesson -- 33
pnpm --filter @coding-agent/extensions test

六项证据分别覆盖 happy path、乱序、JSON-RPC error、timeout、AbortSignal 和 server exit。

真实运行输出

text
✓ tests/lab.test.ts (6 tests)
Test Files  1 passed (1)
Tests       6 passed (6)

项目 extensions 套件还证明超大行被拒绝、stderr 截断不超过预算、工具适配器执行成功。测试结束后的 close 是证据的一部分,不能只看六个断言而忽略子进程回收。

常见失败与排查

故障案例 1

症状:并发 slow/fast 返回内容互换,或其中一个 Promise 永久 pending。

根因:用 FIFO/currentResolve 匹配响应,没有整数 id 到 Pending 的唯一映射。

定位:让服务端故意反序响应,记录请求 id、响应 id 与 Map key,不记录敏感 params。

修复:单调 id 和 pending Map;收到 response 只 settle 同 id,未知迟到 id 不复用。

故障案例 2

症状:大量 timeout/abort 后内存、listener 或 timer 持续增长,迟到响应触发错误结果。

根因:只 reject Promise,未 delete Map、clear timer、remove listener;多个完成路径各写清理逻辑。

定位:循环一百次 never 请求,检查 pending size、signal listener 警告与活动句柄。

修复:集中 settle/rejectAll,以 Map 删除作为一次性提交;所有成功、错误、超时、取消和写失败共用清理。

故障案例 3

症状:Server 崩溃后调用一直等待,测试退出挂住,系统还有孤儿 PID 或无限 stderr。

根因:exit 只清 child 引用,没有拒绝 pending;close kill 后不等待,stderr 无上限。

定位:fixture 在 pending 中 exit,观察 Promise、PID、pipe 与 stderr 字节;afterEach 枚举活动句柄。

修复:进程退出 rejectAll 并重置 initialized;close 先停请求再 TERM/等待/必要时 KILL,stderr 有界且脱敏。

课后作业

加入 notifications、取消通知、stderr 有界采集和协议版本协商;用 100 个随机延迟请求做压力测试,结束后 pending size 必须为 0。

验收 Rubric

维度通过标准常见扣分
关联id → pending 精确匹配假设 FIFO
生命周期timeout/abort/exit 全清理悬挂 Promise
进程shell:false 且 close 等待退出孤儿进程
协议初始化后才发现/调用跳过握手

总项目增量

总项目包:@coding-agent/extensions

总项目路径:packages/extensions/src/mcp-client.ts

总项目验证命令:pnpm --filter @coding-agent/extensions test

MCP 是进程间工具协议;下一课的 Subagent 是拥有受限上下文和预算的执行角色。

延伸阅读

  • JSON-RPC 2.0 request/response/error。
  • MCP lifecycle 与 capability negotiation。
  • Child process ownership 和 structured concurrency。

方案对比与工程取舍

每次工具调用启动短进程最易回收,却重复握手且难共享 server 状态;长期 stdio server 性能好并支持发现,但必须维护 pending 与健康生命周期。MCP 工具通常多次调用,选择长期进程合理,代价由本课的结构化所有权显式承担。

逐行 JSON 简单可观察,适合本地教学;长度前缀 framing 更容易在读取前限制大小,却与标准 stdio 约定不同。沿用每行 JSON 时必须禁止 stdout 诊断并实现 maxLineBytes。协议兼容优先于自行发明 framing,安全上限由 decoder 补足。

本地 stdio 减少网络认证与中间人问题,但 Server 仍能访问宿主资源;远程 transport 增加 TLS、身份、重试和多租户授权。不要通过给 command 传 ssh/curl 字符串伪装远程 MCP,应使用专门 transport 与信任模型,保留相同 request 生命周期接口。

下一课衔接

MCP 把工具能力隔离到受控进程,下一课把推理执行隔离成 Subagent。父任务不会复制整个会话,而是通过 Task Contract 选择 contextKeys、权限子集、allowedPaths/tools、严格更小预算和取消信号;进程 pending 的结构化所有权会演进为父子任务的结构化并发。

从零实现 Mini Code Agent Runtime