Zero 架构 · 第十八课
第十七课的注册表只认一件事:满足 Tool 接口的对象。这一课问:一个跑在另一个进程里的 MCP 工具
——它的代码你没有、它的 schema 是运行时才吐出来的、它随时可能连不上——怎么变成注册表里一个和内建工具一模一样的对象?
答案是一个适配器 registryTool registry.go:242,
外加一条「宿主说了算」的不信任边界。
对齐 mission:一个现代 coding agent 的杀手锏,是能把任意外部能力(数据库、浏览器、公司内部 API)通过 MCP 挂进来。 但外部工具是不受信的第三方:它的 schema 格式你不能假设、它的进程可能挂、它更不该由它自己说「我很安全,自动放行我」。 这一课的功夫,是看 Zero 怎么用一个适配器抹平「远程 vs 本地」,又用一条边界确保外部工具永远越不过宿主的安全裁决—— 可扩展到无限,却一寸也不松安全。
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:
| 字段 | 来源 | 为什么 |
|---|---|---|
Name | registryToolName 合成 :339 | mcp_<server>_<tool>,加命名空间防撞、剔非法字符 |
Parameters | SchemaFromMCP 转译 schema.go:10 | 远程松散的 map[string]any → Zero 的类型化 Schema |
Safety | 宿主合成 :264 | 远程无权声明自己的危险度(见下) |
Safety 被硬编码成 SideEffect: Network + Permission: Prompt
(除非用户此前持久授权过才升到 Allow,registry.go:255)。
远程工具吐回来的清单里根本没有安全字段可填——因为 Zero 不采信第三方的自我评估:一个外部工具若能自称「我无害,自动放行我」,
就等于把第十七课的安全裁决权交给了不受信的人。连「声明自己是什么」这个权利,外部工具也不给。
它只能被当作「触网、默认询问」,判定权牢牢留在第十三课的沙箱引擎手里。
这正是第十七课的呼应:那里内建工具用 Safety 声明式元数据「说自己是什么」,是因为内建工具是可信的自己人。
到了 MCP,同一个 Safety 字段改由宿主填——机制没变,信任边界变了。
适配器还实现了第十七课的可选接口 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 / Close。
Connect client.go:74 按传输类型分派——stdio 拉起子进程、http/sse 走网络——但产出的都是同一个 ToolClient。
又一次「一个接口喂饱多种后端」(第九课 Provider、第十四课沙箱后端的同款手法):适配器只跟接口打交道,传输怪癖关在各自实现里。
外部进程随时可能连不上。RegisterTools registry.go:63
的整套设计都在防「一个坏 server 拖垮全局」:
SkippedServer :35),而不是让启动失败或禁掉其余 server。慢 server 也拖不慢首个模型响应。buildServerTools :199)——不留半套。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——体会「适配器让远程工具对上层透明」。
再精读 newRegistryTool 里 Safety 那段硬编码,想清楚「为什么外部工具连声明危险度的权利都没有」。
最后看 RegisterTools 的并发/串行两阶段和 best-effort 跳过——那是「和不可靠外部进程打交道」的稳健范式。
/allow 之后 isPersistentlyApproved(permissions.go)靠什么把下次同类调用认成已授权,scope 怎么匹配?」、
「stdio 传输的 JSON-RPC 收发怎么做请求/响应配对(pending map[int]chan + 单 reader goroutine,client.go:52)?」,
或者换个大块:「swarm/多 agent 编排的骨架——多个 agent 怎么被拉起、怎么分工、结果怎么汇合?」