Zero 架构 · 第十八课

把「别人进程里的工具」包成本地 Tool:一个适配器 + 一条不信任边界

第十七课的注册表只认一件事:满足 Tool 接口的对象。这一课问:一个跑在另一个进程里的 MCP 工具 ——它的代码你没有、它的 schema 是运行时才吐出来的、它随时可能连不上——怎么变成注册表里一个和内建工具一模一样的对象? 答案是一个适配器 registryTool registry.go:242, 外加一条「宿主说了算」的不信任边界

对齐 mission:一个现代 coding agent 的杀手锏,是能把任意外部能力(数据库、浏览器、公司内部 API)通过 MCP 挂进来。 但外部工具是不受信的第三方:它的 schema 格式你不能假设、它的进程可能挂、它更不该由它自己说「我很安全,自动放行我」。 这一课的功夫,是看 Zero 怎么用一个适配器抹平「远程 vs 本地」,又用一条边界确保外部工具永远越不过宿主的安全裁决—— 可扩展到无限,却一寸也不松安全。

适配器:5 个方法全部「转译 + 委托」

registryTool 实现了第十七课那 5 个方法,但它自己不干活,只是把每个方法翻译成对远程的调用或对元数据的读取:

func (t registryTool) Name() string        { return t.name }              // 合成的本地名
func (t registryTool) Parameters() Schema  { return t.parameters }         // 转译自远程 schema
func (t registryTool) Safety() Safety      { return t.safety }             // ← 宿主合成,非远程声明
func (t registryTool) Run(ctx, args) Result {
    result, err := t.client.CallTool(ctx, t.remote.Name, args)  // 委托给远程   registry.go:308
    ...
}                                                                // registry.go:272-329

关键在于:一旦包成 registryTool,上层再也分不出它是远程的。第十七课的注册表、第十三课的沙箱、第二课的闸门, 拿到的都是一个普通 Tool。远程的怪癖(JSON-RPC、进程、超时)全被关进适配器内部——这与第九课「一个 Provider 接口喂饱各家模型」是同一种防腐层手法,只是这次被适配的是「另一个进程的工具」。

三样元数据的三种来源

适配器在 newRegistryTool registry.go:251 里组装。注意三样东西各有出处,尤其 Safety:

字段来源为什么
NameregistryToolName 合成 :339mcp_<server>_<tool>,加命名空间防撞、剔非法字符
ParametersSchemaFromMCP 转译 schema.go:10远程松散的 map[string]any → Zero 的类型化 Schema
Safety宿主合成 :264远程无权声明自己的危险度(见下)
最关键的一处:Safety 由宿主强加,不容远程自评 每个 MCP 工具的 Safety硬编码SideEffect: Network + Permission: Prompt (除非用户此前持久授权过才升到 Allow,registry.go:255)。 远程工具吐回来的清单里根本没有安全字段可填——因为 Zero 不采信第三方的自我评估:一个外部工具若能自称「我无害,自动放行我」, 就等于把第十七课的安全裁决权交给了不受信的人。连「声明自己是什么」这个权利,外部工具也不给。 它只能被当作「触网、默认询问」,判定权牢牢留在第十三课的沙箱引擎手里。

这正是第十七课的呼应:那里内建工具用 Safety 声明式元数据「说自己是什么」,是因为内建工具是可信的自己人。 到了 MCP,同一个 Safety 字段改由宿主填——机制没变,信任边界变了。

延迟广告:MCP 工具默认「藏起来」

适配器还实现了第十七课的可选接口 Deferred() bool { return true } registry.go:295——每个 MCP 工具都是可延迟的,而内建工具不实现这个接口、保持 eager。 原因就是第五课的 prompt 预算:接十个 MCP server 可能来上百个工具,全把 schema 塞进 prompt 会爆。所以 MCP 工具平时不进 prompt,靠 tool_search 按需现身。 「远程能力无限扩展」与「prompt 预算有限」的矛盾,就用这一个布尔方法化解。

连接:一个 ToolClient 接口,抹平 stdio / http / sse

适配器持有的 client 是个 ToolClient 接口,只有三个方法 client.go:34:ListTools / CallTool / CloseConnect client.go:74 按传输类型分派——stdio 拉起子进程、http/sse 走网络——但产出的都是同一个 ToolClient。 又一次「一个接口喂饱多种后端」(第九课 Provider、第十四课沙箱后端的同款手法):适配器只跟接口打交道,传输怪癖关在各自实现里。

启动:best-effort + 「并发 I/O,串行提交」

外部进程随时可能连不上。RegisterTools registry.go:63 的整套设计都在防「一个坏 server 拖垮全局」:

为什么超时的 context 一定要 cancel stdio server 的子进程系在 context 上。连接超时被放弃时立刻 cancel() registry.go:126 撕掉半开的连接/子进程;而保留的 server 其 context 要一直活到 Close ——否则子进程会被提前杀掉。这是「外部进程生命周期系在 Go context 上」的一个必须小心的细节。

合起来看:一层适配器,两个方向的保证

向上,适配器把远程工具伪装成一个再普通不过的 Tool,让第十七课的注册表、第十三课的沙箱、第二课的闸门都无需知道 MCP 的存在——可扩展向内,它把 Safety 的填写权从远程夺回宿主,把连接失败隔离成「跳过一个」,把 prompt 膨胀交给延迟广告——安全与稳健。 一个外部进程的工具,就这样成了注册表里的一等公民,却始终越不过宿主画下的那条线。

动手回忆

外部 MCP 工具靠什么变得能被注册表/沙箱/闸门一视同仁地使用?

一个 MCP 工具的 Safety(危险度/权限)到底是谁决定的?

启动时某个 MCP 服务器连不上或超时,为什么不直接让整个启动失败?

接下来该读的一手源码

less +242 ../zero/internal/mcp/registry.go   # registryTool:适配器的 5 个方法(转译+委托)
less +251 ../zero/internal/mcp/registry.go   # newRegistryTool:Name 合成 / Schema 转译 / Safety 宿主强加
less +63  ../zero/internal/mcp/registry.go   # RegisterTools:best-effort + 并发连接 + 串行提交
less +34  ../zero/internal/mcp/client.go     # ToolClient 接口:ListTools/CallTool/Close 抹平传输
less +74  ../zero/internal/mcp/client.go     # Connect:按 stdio/http/sse 分派,产出同一 ToolClient
less +10  ../zero/internal/mcp/schema.go     # SchemaFromMCP:远程 map[string]any → 类型化 Schema

读的顺序:先看 registryTool 的 5 个方法怎么全部委托给 client——体会「适配器让远程工具对上层透明」。 再精读 newRegistryToolSafety 那段硬编码,想清楚「为什么外部工具连声明危险度的权利都没有」。 最后看 RegisterTools 的并发/串行两阶段和 best-effort 跳过——那是「和不可靠外部进程打交道」的稳健范式。

我是你的老师 —— 随时问我。 适合现在追问: 「MCP 权限的持久化——一次 /allow 之后 isPersistentlyApproved(permissions.go)靠什么把下次同类调用认成已授权,scope 怎么匹配?」、 「stdio 传输的 JSON-RPC 收发怎么做请求/响应配对(pending map[int]chan + 单 reader goroutine,client.go:52)?」, 或者换个大块:「swarm/多 agent 编排的骨架——多个 agent 怎么被拉起、怎么分工、结果怎么汇合?」