Skip to content

Part III · 实现篇 / Ch 11

实现篇第11章 给外部能力开一扇门——MCP ​

老Z章鱼在小A机器人旁检查来自远程日历服务的工具,并让只读日历卡通过适配与审批台。

本章导读: 实现篇扩展第 3 章。概念篇讲过 MCP 是“能力发现与消息交换的协议”。这一章推演 mini-agent 怎么接一个外部 MCP 服务,代码不进 src,要讲清的是 transport、工具适配、失败语义和信任边界。

小A的 Agent 只能读写自己的工作区。他想要一个“查日历”的能力,但日历在另一个服务里。他问老Z:“能不能让 Agent 直接调那个服务的 API?”

“能,但你别把 HTTP 请求直接写进工具。”老Z说,“你要接的是一个 MCP 服务端,它按协议暴露工具,你的 Agent 按协议调用。先把丑话说在前面:协议解决连通性,不解决信任。”

小A不服:“为什么不直接写个 calendar 工具,里面 fetch 那个 API?比接 MCP 简单多了。”

“现在简单,以后是债。”老Z说,“你直接写 fetch,API 地址、认证、错误处理全焊死在一个工具里,换个服务就得重写。MCP 把这些抽到协议层:服务端自己管认证和错误,你的 Agent 只管调一个叫 calendar_query 的工具。更关键的是,mini-agent 的 Tool.execute 返回 Promise<ToolResult>(见 types.ts),它假设本地同步语义,你把网络请求塞进去,超时、断连、重试都得在工具里处理。MCP 的价值就在这儿:它把‘远端能力’伪装成‘本地工具’,循环不需要知道背后是网络。这一章推演这个伪装层长什么样,不写进 src。”

先分清两个方向 ​

“MCP 有三种原语,先分清你接的是什么。”老Z说:

  • Tool:可调用的动作,这是 Agent 最需要的。
  • Resource:可读取的上下文(文件、文档)。
  • Prompt:可复用的模板。

“mini-agent 的循环只能消费自己的 Tool 接口(spec + execute)。所以 MCP 接入的核心问题是:怎么把一个 MCP 服务端的 Tool 变成 mini-agent 能调用的 Tool。这就是适配缝的位置,你不需要让循环懂 MCP,只需要一个把 MCP 工具翻译成 ToolSpec 的桥。”

把三种原语按“mini-agent 的循环有没有消费点”逐个过一遍:

MCP 原语是什么循环里有消费点吗要接的话怎么接
Tool可调用的动作有:options.tools适配成 Tool,直接可用
Resource可读取的上下文无包成 read_resource 工具
Prompt可复用的模板无改 system 注入逻辑

“这张表前两列是 MCP 的世界,第三、四列是 mini-agent 的世界。”老Z说,“只有 Tool 天然有消费点,Resource 和 Prompt 都要先改造循环才接得进去。改造本身不坏,坏的是没想清楚‘为什么要改’就动手,先把消费点画出来,原语接在哪一目了然。”

小A接着问:“那 Resource 和 Prompt 就不接了?”

“现在不接,循环没有消费它们的地方。”老Z说,“mini-agent 的 runAgent 只认 tools: Tool[](见 agent.ts),模型能调的只有工具。真要接,Resource 得包成一个 read_resource 工具,Prompt 得改 system 注入逻辑,而 mini-agent 现在的 system 是写死的字符串(见 main.ts)。规矩就一条:每接一种原语,先回答‘循环哪一处消费它’;消费点不存在,原语就先别接。”

设计一个最小 McpClient ​

版本与核验边界: 本章沿用原稿所引的 MCP 2026-07-28 外部规范专题,非 pi v1.0.0 实现;相关外部机制未经核验,也未用 pi v1.0.0 快照核验。本章没有补齐直接规范来源。下文及表格中的无状态、无握手/会话、_meta、server/discover、HTTP 重试与请求 ID、协议错误分类、resultType: "input_required" 和 inputResponses 均保留为【待核验】描述,不可当作已确认的规范要求或直接据此实现。先用本章讨论适配器边界;接入真实服务前须另行核对目标版本规范。

老Z沿 ModelClient 的模式推演了一个接口:

ts
// 设计草图:McpClient(未进 src,先确认需求)
export interface McpClient {
  /** 可选 server/discover 确认版本与能力,再 tools/list 拿到工具清单 */
  connect(): Promise<{ tools: McpTool[] }>;
  /** 调用一个工具;signal 请求取消本地等待,不保证远端副作用撤销 */
  call(name: string, input: unknown, signal?: AbortSignal): Promise<{ content: unknown; isError: boolean }>;
  close(): Promise<void>;
}

// 把 MCP 工具翻译成 mini-agent 的 Tool
function mcpToolToTool(mcpTool: McpTool): Tool {
  return {
    spec: { name: mcpTool.name, description: mcpTool.description, inputSchema: mcpTool.inputSchema },
    execute: async (input, signal) => {
      const result = await mcp.call(mcpTool.name, input, signal);
      const content = JSON.stringify(result.content ?? []);
      if (content === undefined) {
        throw new TypeError("MCP tool content is not JSON-serializable");
      }
      return { isError: result.isError, content };
    },
  };
}

“先说 connect 这个名字。”老Z说,“原稿对 MCP 2026-07-28 版的描述是无状态、没有握手和会话,每个请求在 _meta 里带协议版本和客户端能力;这些机制尚待核验。若目标版本确认采用这种机制,这里的 connect 就只是宿主自己的启动步骤:拉起 stdio 子进程,可选地调一次 server/discover 确认对方支持的版本,再调 tools/list。在这个假设下,版本和能力字段由 McpClient 在每次请求时补上,循环和适配器都不用管。”

“signal 要由 McpClient.call 传给底层传输并响应中止;这里只声明接口,尚未实现取消。取消本地等待不证明远端工具没执行,已发生的副作用也不会因此回滚。JSON.stringify 若抛错或返回 undefined,适配器就向上失败,不能把非字符串结果交给循环。”老Z说。

“再看 mcpToolToTool,它把 MCP 工具的形状翻译成 mini-agent 的形状。”老Z说,“循环完全不知道背后是 MCP,它只看到一个普通 Tool。这就是适配器:McpClient 管协议,mcpToolToTool 管形状转换,循环只管调用。三层各管一段。”

三层之间的调用链,和错误各自停在哪一层,画出来是这样:

runAgent(循环)
   │ 只认识 Tool / ToolSpec
   ▼
mcpToolToTool(适配缝:把 McpTool 翻译成 Tool)
   │ 只做形状转换:spec 字段 + execute 里 JSON.stringify
   ▼
McpClient(协议层:connect / call / close)
   │ 只认识 MCP 协议,不认识循环
   ▼
McpTransport(stdin/stdout 或 HTTP)
   │ 只认识字节流,翻译成协议消息
   ▼
远端 MCP 服务端

“图里每一层只跟上下两层说话。”老Z说,“循环问 execute,适配器问 McpClient.call,协议层问 transport.send。哪一层出错,就在哪一层翻译成自己的语言,别一路穿透三层,后面讲的协议错误和工具错误,争的其实就是‘在哪一层定责’。”

小A越看越眼熟:“这个适配器跟 mini-agent 现有的 AnthropicModelClient 是一个套路?”

“一模一样的套路,只是方向相反。”老Z说,“AnthropicModelClient(见 model-client.ts)把 Anthropic 的流式协议翻译成 mini-agent 的 ModelEvent,处理 content_block_start、input_json_delta,吐出 text、tool_call、done。mcpToolToTool 反过来,把 MCP 工具描述翻译成 ToolSpec。两个适配器干的是同一类活,把外部协议接到内部接口上。mini-agent 的测试能用 FakeClient(见 agent.test.ts)跑完整回合,也是同一个原因:循环不关心背后是 Anthropic 还是 MCP。适配器因此成了这个架构里的复用单位。”

小A指着 execute 里那行问:“JSON.stringify(result.content) 是嫌模型读不了对象,先转成字符串?”

“对。mini-agent 的 ToolResult.content 是字符串(见 types.ts),但 MCP 工具返回的 content 可能是结构化对象。”老Z说,“你看 mini-agent 自己的工具,read 返回文件内容,bash 返回 stdout 拼接,内部接口统一用字符串,模型拿来就能读。MCP 返回结构化数据,得 stringify 才能塞进这个形状,有损就损在这儿,换来的是循环不用懂 MCP 的返回格式。”

小A追问:“那 stringify 丢了类型信息,模型解析不了结构化结果怎么办?”

“让模型按文本处理,或者你在适配器里做一层格式化。”老Z说,“这跟 read 的截断是同一个思路:内部接口是字符串,结构化信息要么靠模型自己读文本,要么在适配器里提前格式化成可读文本,选择权在你,不在循环。想让模型看到 { title: '开会', time: '10:00' } 而不是一串 JSON,就在 mcpToolToTool 的 execute 里做格式化,别给循环加‘理解 JSON’的新规则,两端格式都交给适配器一处去懂。”

transport:stdio 与 HTTP 的边界 ​

小A想到运行环境:“那 MCP 服务端跑在哪?本地进程还是远程服务?”

“两种 transport,两种边界。”老Z说:

  • stdio:服务端是本地子进程,通过标准输入输出通信。优点是进程隔离,缺点是每个连接一个进程。
  • Streamable HTTP:服务端是远程服务。优点是无需本地进程,缺点是要面对网络错误和认证。原稿称每次请求是一次 POST,响应流中途断开后须用新的请求 ID 重新发起;这组外部机制【待核验】,重试和请求 ID 规则须以目标版本规范为准。

“transport 决定失败语义。”老Z强调,“stdio 的失败是进程退出,你要处理子进程崩溃;HTTP 的失败是网络错误,你要处理超时和重试。设计 McpClient 时,把 transport 隔离成一个可替换的接口,测试就可以用 fake transport,不真的启动子进程、不真的发请求,就像 ModelClient 用 fake stream 一样。”

两种 transport 并排放进一张表:

维度stdioStreamable HTTP
服务端形态本地子进程远程服务
通信通道标准输入/输出HTTP 请求
失败模式进程退出、管道断超时、响应流断开、认证失败
生命周期每连接一个进程;意外退出应重启【待核验】原稿描述为每个请求一次 POST、协议层无会话
认证通常免认证(本地)需要处理凭据

“这张表给接口定的要求只有一条:按最坏情况设计。”老Z说,“McpTransport 的 send 只声明 Promise<unknown>,不承诺‘永远成功’,无论底下是哪个实现,失败都以 reject 表达。stdio 和 HTTP 的区别只发生在实现里,不在接口里。”

“这个 fake transport 的思路,跟 mini-agent 哪个测试对应?”小A问。

“model-client.test.ts 里的 fakeStream。”老Z说,“它把一组 RawEvent 数组当成 Anthropic 的流喂给 toModelEvents,不真的发网络请求,测试里构造 content_block_start、input_json_delta 这些事件,断言转换出来的 ModelEvent 对不对。fake transport 干一样的事:构造一组服务端会发的消息,断言 McpClient 解析出来的工具清单对不对。mini-agent 的协议转换逻辑全在 toModelEvents 这个纯函数里(见 model-client.ts),测试从不实例化 AnthropicModelClient,MCP 也该这么拆:协议解析是纯函数,transport 是注入的依赖,测试才不依赖真实进程或网络。”

小A顺着问:“那 stdio 的子进程崩溃,循环怎么知道?”

“transport 层把它翻译成协议错误向上抛。”老Z说,“子进程退出、管道断,McpTransport.send 就该 reject,按协议错误处理。循环不该看到‘子进程退出码 1’,只该看到工具调用失败。跟 mini-agent 的 bash 工具一个待遇:execFileAsync 失败时,failure(cause) 转成 { content, isError: true }(见 tools.ts)。”

“那子进程还活着,但发来的全是垃圾消息呢?”

“那是协议错误,消息格式不对,McpTransport 解析不了就该 reject。”老Z说,“跟 model-client.ts 里 toModelEvents 的处理一个思路:遇到 input_json_delta 没有对应的 content_block_start,直接抛 Anthropic stream protocol error,不猜、不忽略。协议解析宁可停,不能猜,猜错一次,后面的事件流就全错位了。”

ts
// 设计草图:transport 可替换
export interface McpTransport {
  send(message: unknown): Promise<unknown>;
  close(): Promise<void>;
}
export class StdioTransport implements McpTransport { /* 子进程 stdio */ }
export class HttpTransport implements McpTransport { /* fetch + 认证 */ }

失败语义:工具错误不是协议错误 ​

“MCP 调用失败了怎么办?”小A把问题拉回最实际的一层。

“分两种。”老Z说:

  • 协议错误:连接失败、消息格式不对,在本章草图中按 McpClient 层的失败向上传播,让宿主决定是否重试或放弃。原稿还把版本不被支持(UnsupportedProtocolVersionError)、缺少客户端能力、调用不存在的工具列为服务端返回的 JSON-RPC 错误;这些外部分类与名称【待核验】。
  • 工具错误:MCP 工具自己返回了 isError: true(比如“日历查询失败”,或者参数里的日期格式不对)。这是业务结果,应该变成 ToolResult.isError 传回循环,让模型看到并调整。

“两条路混不得。把协议错误当工具错误,循环会傻傻地重试一个已经断掉的连接;把工具错误当协议错误,模型看不到失败原因,只能瞎猜。分开处理,是 McpClient 和 Tool 之间的契约。”

小A还想找个现成的例子:“这两条路径,在 mini-agent 现有代码里有对照吗?”

“有,就在 runAgent 里。”老Z说,“看 agent.ts:工具执行抛错时,它调 recordFailedCallAndSkippedCalls,把 call 记成 'tool execution failed',然后原样抛给调用方,整轮停,这对应协议错误。但如果工具返回 { isError: true }(比如 read 遇到 symlink 逃逸),它只把 result 记进 messages,循环继续,模型自己调整,这对应工具错误。MCP 接进来以后:服务端返回 isError: true 走第二条路,transport 抛错走第一条路。契约跟现有语义对齐,不用给循环加新规则。”

老Z把两条错误路径画成状态图,直接落到 mini-agent 的真实行为上:

MCP 工具调用出错
        │
   result.isError === true?         ← 服务端明确返回失败
        │
   ┌────┴─────┐
   │  是      │ 否
   ▼          ▼
业务错误    协议错误(transport reject / 格式错)
记进 messages   recordFailedCallAndSkippedCalls
循环继续       → "tool execution failed"
模型看到原因    → 原样抛给调用方 → main 设退出码 1

“注意右边那条路,模型看不到错误的原始内容。”老Z说,“它看到的是 'tool execution failed' 这个稳定标记,跟安全章讲的是同一件事:宿主侧的失败细节不进模型上下文。MCP 的协议错误也一样,transport 抛错的内容对循环是透明的,循环只负责把当前 call 闭环、把错误上抛。”

小A猜:“服务端说工具不存在,那八成是协议错误吧?”

“先别定类。”老Z说,“原稿称 MCP 2026-07-28 版的 Tools 规范把未知工具归为 JSON-RPC 协议错误,把参数值不合法、业务失败归为 isError: true 的执行错误;这项分类还待核验。若目标规范确认如此,适配器再决定是否在 call 里把‘工具已不可用’翻译成模型可见的工具结果,并让宿主重新 tools/list。是否翻译、是否刷新清单,都应按实际错误契约设计,不能凭这里的未核验描述定案。”

“原稿还描述了一种补充输入机制。”老Z补充,“服务端返回 resultType: "input_required",客户端带着 inputResponses 用新 ID 重发;这套字段和流程同样【待核验】。若目标版本确有这种机制,而最小的 McpClient 不支持,就应明确向宿主报告不支持,不能把它当成空结果交给模型。”

“那启动失败呢?server/discover 或第一次 tools/list 就失败,connect 直接 reject,循环是不是根本没机会跑?”

“对,就该这样。”老Z说,“启动阶段就失败,意味着服务端不在或版本对不上,任何工具调用都会失败,不如在循环开始前就暴露。mini-agent 的 runAgent 连 sessionPath 的 load 失败都会直接抛(见 agent.ts 的 await loadSession),启动期依赖失败,就该让启动失败,别拖到循环里反复重试。MCP 的 connect 也一样:它在 runAgent 之前,失败直接抛给调用方,由宿主决定是重连还是放弃。”

信任边界:协议通了不等于安全 ​

小A松了口气:“接上之后,我是不是就能放心让 Agent 用日历工具了?”

“协议解决连通性,安全解决可控性,两件事缺一不可。”老Z打开概念篇 第12章 给外部工具留一扇门——MCP 的结论,“MCP 不规定谁有权调用哪个工具、哪个资源可以读。你接了一个外部服务,等于开了一扇没有门卫的后门,三件事得自己管:

  1. 工具清单要审查:服务端声明的每个工具,你要确认它做的事是你允许 Agent 做的。
  2. 结果要过审批:高风险工具(删除、外发)要挂 BeforeToolCall 审批,就像本地工具一样。
  3. 注入要防:MCP 返回的内容可能带提示注入,通过工具结果诱导模型越权。它只是文本,不是可信指令。”

小A:“那 MCP 工具的审批,跟本地工具一样吗?”

“完全一样,而且必须一样。”老Z说,“mcpToolToTool 把 MCP 工具翻译成普通 Tool,它进的是 runAgent 的 options.tools。循环执行任何工具前都过 beforeToolCall?.(call)(见 agent.ts),只看 call.name,不区分本地还是 MCP,所以 denyBashByDefault 的 if (call.name === 'bash')(见 approval.ts)对 MCP 工具同样生效。但反过来看:不挂审批就默认放行,?? { approved: true }(见 agent.ts)。适配器让 MCP 工具看起来像本地工具,也就继承了本地工具的默认放行策略,风险跟收益是一体的。”

拿你的日历服务练一遍,信任清单长这样:

日历 MCP 信任清单(推演)

  1. calendar_query:只读,返回日程文本。放行,但要审查它会不会把其他用户的日程也查回来。
  2. calendar_create:创建日程,副作用是写。挂审批,按名称 deny,除非有明确策略。
  3. calendar_delete:删除日程,不可逆。默认拒绝,逐次人工确认。
  4. 所有工具的返回内容都当提示注入处理,日程描述是外部文本,可能写着“忽略之前的指令”。

“注意第 4 条最容易漏。”老Z说,“日历条目出自别人之手,带着陌生人的意图进你的上下文。安全章那个恶意 README 能骗模型,一条恶意的日程描述一样能骗。”

小A又冒出一个问题:“那连接都断了,审计数据又没发出去,怎么补?”

“这件事得放进信任边界一起设计。”老Z说,“如果你要求‘每次调用都可审计’,而 transport 是 fire-and-forget 的上报,那你得自己保证上报到达,要么 transport 失败时调用方知道,要么审计走独立通道。信任模型得想清楚审计丢了怎么办,否则你以为自己在审计,实际什么都没留下。”

与 pi-mono 的对照 ​

“pi-mono 的协议层是更完整的参考。”老Z说,“它的 pi-protocol 用 schema → CBOR → frame 四层把消息变成可靠字节,pi-server 处理握手、请求路由和会话附着,那是完整的远程协议栈。你推演的最小 McpClient 只需要它的一小片:能连上、能列出工具、能调用。别为了接一个日历工具,先把整套协议栈写一遍。”

小A惦记着以后:“pi-mono 那套分层思想,mini-agent 将来会用上吗?”

“看 transport。”老Z说,“stdio 本地子进程用 JSON 就够;只有接 Streamable HTTP、面对真实网络不稳定时,帧协议和消息校验才有意义。pi-mono 的协议栈是它自己的远程通信基础设施,跟 MCP 是两套东西,参考它的分层思想就行,transport 隔离、失败分级,字节格式别搬。”

把 pi-mono 的完整协议栈和推演的最小切片摊开对比:

pi-mono 协议层干什么最小 McpClient 要吗
schema消息类型定义部分:工具清单、调用、结果
CBOR 序列化二进制编码不需要:stdio 用 JSON
frame分帧、校验不需要:本地连接够稳
transport字节流读写要:隔离成 McpTransport
握手 / 订阅快照会话状态同步【待核验】若目标 MCP 版本确为无握手、无会话且逐请求携带版本与能力,则不需沿用 Pi 这一层

“前三行,pi-mono 要,因为它的服务端可能跑在远端、消息要过不可靠的网络;你的日历服务如果只在本地 stdio 上跑,JSON 加换行就够。”老Z说,“判断要不要一层,标准是‘没有它,哪条失败路径说不清’。CBOR 和 frame 解决的是网络校验,你的 stdio 用不上,就砍掉,别背这笔用不上的债。”

“那万一将来真的接 HTTP 呢?”

“到那时你加 frame、加重试、加心跳,这些都落在实现里,接口原样不动。”老Z说,“McpTransport 的签名不变,变的是 HttpTransport 内部的失败处理。当初把 transport 隔离成接口,为的就是让今天用不上的复杂度只待在实现里。”

小结 ​

MCP 接入的钥匙是适配缝:McpClient 管协议,mcpToolToTool 管形状转换,循环只管调用普通 Tool。transport 决定失败语义,隔离成可替换的接口,协议解析收在纯函数里、连接细节交给注入的 transport,测试拿 fake 就够,真实进程和网络都不必碰。工具错误是业务结果,要传回循环(isError: true 走 ToolResult);协议错误是连接层故障,要向上传播(transport reject 走 tool.execute 抛错)。两条路径分开,跟 mini-agent 现有的工具失败语义对齐。协议通了不等于安全:MCP 工具经适配后继承本地工具的默认放行策略,高风险的必须按名称挂 BeforeToolCall 审批,返回内容当提示注入处理。mini-agent 没有实现 MCP,这一章是推演:适配器的形状、transport 的边界、两条错误路径的契约,应该长成什么样。

这套桥是架构里能复用的单位,方向有两种。AnthropicModelClient 把 provider 的流式协议翻译成 ModelEvent,mcpToolToTool 把 MCP 工具翻译成 ToolSpec,一个向外,一个向内,都是把外部协议接到内部接口的桥。因为循环只认识内部接口,测试才能用 FakeClient 跑完整回合;将来接第二个 MCP 服务,也只是再写一个 transport,循环一行不改。判断一个适配器写得好不好,就看换掉它的实现时,循环是否需要知道。

原语和层次都按“有没有消费点”来决定接不接。Tool 有消费点,直接适配;Resource 和 Prompt 没有,要么先包工具,要么先改 system,别为接而接。协议层也一样:pi-mono 的 schema、CBOR、frame 是为远端通信设计的,stdio 本地连接用 JSON 就够。把 transport 隔离成接口,将来接 HTTP 再往里加。该学的是分层,字节格式留给 pi-mono。

最后是信任这一课。连通性协议已经替你解决,可控性还留在你的清单上:MCP 不规定谁有权调哪个工具,也不规定返回内容可不可信。工具清单要审查、高风险工具要挂审批、返回内容当提示注入,三条缺一条,适配器就让风险变得和收益一样隐形。记得把日历服务的信任清单写下来:哪些工具放行、哪些默认拒绝、为什么。写完它,这一章的交付物也就齐了:一份说得出理由的边界。

阶段验收 ​

不修改 src。在 examples/mini-agent 写推演笔记:

  1. 画出 McpClient → mcpToolToTool → runAgent 的调用链,标出协议错误和工具错误各自的传播路径。
  2. 用 fake transport 写一个 McpClient 的测试草稿:连接失败、工具返回 isError、transport 中途断开,三种情况断言什么。
  3. 为你的 MCP 日历工具列一个信任清单:哪些操作要挂 BeforeToolCall 审批,为什么。