MCP 与 Extensions:外部工具接入的两条路
桥接生态标准与原生扩展怎么分工。核心源码:packages/mcp/mcp-client/ 与 packages/extensions/。
先玩再讲。同一个外部能力「查天气」,左边走 MCP 桥,右边走原生 Extension,两条路同时接入。看三件事:工具名怎么生成、服务器断线时模型视角发生什么、两条路的能力面差多少。点「播放」自动走完,或用「单步」逐帧看。
先解释名词。MCP(Model Context Protocol)是一个开放协议:任何人写一个工具服务器,任何支持 MCP 的客户端都能连上去用它的工具。DSH 的 dsh-mcp-client 插件就是这个协议的客户端,一个插件实例连一个服务器,stdio 子进程和 streamable-http 两种传输都支持。连接成功后它做的事很直白:listTools() 拉一遍工具清单,把每个工具用公开名注册进 ctx.tools,模型从此把它们当原生工具用。
命名是第一个设计点。每个 MCP 工具有两个名字:原始名只在网线上出现(tools/call 用它),模型看到的公开名是 mcp__服务器名__原始名。这个格式与 Claude Code 和 Codex 一致,mcp-client 的 README 自己点了这一句。名字必须满足 DeepSeek 函数名约定:最长 64 字符、只允许字母数字下划线连字符。要是替换字符或截断改动了名字,就在尾部追加一个 12 位十六进制的 SHA-256 hash,保证两个不同的工具身份绝不会折叠成同一个名字。整个函数是 (serverName, rawName) 的纯函数:连接顺序、重新同步、别的服务器,都改不了一个工具的名字。
export function publicToolName(serverName: string, rawName: string): string {
const joined = `mcp__${serverName}__${rawName}`
const normalized = joined.replace(INVALID_NAME_CHARS, '_')
if (normalized === joined && normalized.length <= MAX_PUBLIC_NAME_LENGTH) return normalized
const hash = createHash('sha256').update(`${serverName}\0${rawName}`).digest('hex').slice(0, HASH_LENGTH)
return `${normalized.slice(0, MAX_PUBLIC_NAME_LENGTH - HASH_LENGTH - 1)}_${hash}`
}
packages/mcp/mcp-client/src/tools.ts,核对日期 2026-08-13。代码块保留源码原文。第二个设计点是世代(generation)。服务器的工具清单会变,变了就要重新同步。同步分两阶段:先把下一世代的全部工具定义拉完建好,任何一步失败都不碰注册表,上一世代原样活着;拉完了才做交换,先注销旧世代、再注册新世代。
交换阶段的写法值得讲一下。注册循环里,每注册成功一个工具就把它的注销函数存进一张表。任何一次注册抛了冲突(意味着有外来注册霸占了这台服务器的命名空间),catch 分支就把这张表里已注册的全部注销,一个工具都不留,然后记一条 error 日志。注释把意图写得很直白:回滚是为了让模型看到的要么是完整的一个世代,要么什么都没有,绝不能是半套。
出处:packages/mcp/mcp-client/src/tools.ts 第 159 至 172 行的注册与回滚分支,核对日期 2026-08-13。
断线重连也建在世代上。stdio 子进程崩了,supervisor 用指数退避重启它:首次延迟默认 500 毫秒,逐次翻倍,上限 30 秒,一次中断最多试 10 次(README.zh.md 配置表)。中断期间最后一个正常世代保持注册,模型这时调用会失败,但工具名不会凭空消失;重连成功后重新发现,恢复的世代整体替换旧世代,工具既不重复也不泄漏。serverName 没变的话,新世代的名字逐字相同,KV cache 前缀都保得住。预算也有讲究:连接存活超过 30 秒就重置尝试预算,所以偶尔崩一次的服务器可以无限恢复,反复崩溃循环的服务器最终会耗尽预算被注销,不会永远重启下去。
最后一个设计点最容易被忽略:这座桥刻意只桥了 MCP 的 tools 能力。MCP 协议里还有 resources(资源)和 prompts(提示词模板)两类能力,DSH 一概没接。README 的「已知限制与暂缓事项」一节写得很坦白:
packages/mcp/mcp-client/README.zh.md 第 111 行,核对日期 2026-08-13
逻辑不难还原:harness 内部没有谁会消费一个外部 resource 或外部 prompt,先造桥墩没有意义。工具有明确的消费方(agent loop 的工具调用),所以先桥工具。这属于按需造桥。图片、音频这类非文本结果也做了有损投影,在模型上下文里变成占位符,二进制载荷不进上下文。
第二条路完全不同。Cordis 是 DSH 的插件框架,整个 harness 就是一棵 Cordis 插件树。Extensions 子系统让模型在会话里现写一个 Cordis 插件、当场跑起来:写代码前先用 cordis_inspect 查询当前运行时里有哪些服务和接口可用,然后 cordis_define 提交源码,cordis_run 启动,不要了就 cordis_stop 或 cordis_undefine。这五个工具由 packages/extensions/tool-cordis 注册。
一个动态插件分两半。Host 半在 Node 侧的 node:vm 沙箱里跑逻辑,Browser 半在页面里渲染 UI,两半的生命周期由 ctx.dynamicCordisRunner(packages/extensions/cordis-host-runner/src/index.ts 第 124 行起)统一管。带 Browser 半的启动要走审批:cordis/request-run 事件把请求送到页面,用户点了允许才继续,还可以勾选一并放行这个插件的后续版本(runHostHalf 的 approveFutureVersions 参数)。每个 Package 版本不可变,改代码就是追加新版本。
把两条路放一起看,它们是正交的,各管一头。MCP 桥面对的是进程外的现成能力,信任模型是隔离:服务器崩了、返回垃圾、断线,都被世代和错误路径挡在桥外,但它能给模型的只有工具这一种东西。Extension 面对的是模型现场生成的代码,跑在自己进程里,能力面大得多:能加工具、能发事件、能注册服务、能画界面,代价是每次运行都在审批和沙箱的看管之下。一个是接外面的电,一个是自己发电。
名字是纯函数公开名只由 (serverName, rawName) 决定。两个服务器都叫 search 的工具在各自命名空间下共存;连接顺序和重新同步永远不会重命名工具。
世代要么全有要么全无拉取失败不碰注册表,注册冲突整代回滚。模型看到的永远是完整的一套工具,绝不会是半套。断线期间旧世代保持注册,调用会失败但名字还在。
桥只桥 toolsresources 和 prompts 被有意搁置,理由是 harness 里没有它们的消费接口。能力面差距要靠 Extensions 补:工具、事件、服务、界面四样都能加。
Claude Code:MCP 客户端的满配实现
还原源码里的 MCP 实现比 DSH 厚得多:六种传输方式(stdio、sse、sse-ide、http、ws、sdk,见 restored-src/src/services/mcp/types.ts 第 23 至 26 行)、七个配置来源层级(local、user、project、dynamic、enterprise、claudeai、managed)、OAuth 认证加 15 分钟缓存。工具命名和 DSH 同形,mcp__server__tool,权限规则能精确到工具级或服务器级。还有一个 DSH 没有的防御:工具描述截断到 2048 字符,因为观测到 OpenAPI 自动生成的服务器往描述里塞 15 到 60KB 的文档(services/mcp/client.ts 第 217 至 219 行注释)。资料来源:claude-code-sourcemap-main/study/chapters/08-mcp.md。
差异在取向。Claude Code 把 MCP 当唯一的官方扩展点做深做全;DSH 把 MCP 桥做薄(只桥 tools),把重能力留给原生 Extensions。前者的扩展跑在进程外,后者多给了一条跑在进程内的路。
Grok Build:插件市场路线
Grok Build 仓库里 MCP 客户端(crates/codegen/xai-grok-mcp/)与插件市场(crates/codegen/xai-grok-plugin-marketplace/)并存:MCP 负责协议兼容,市场负责分发与信任,走的是集中审核的生态路线。它的 MCP 连接、发现与恢复机制,站内 Grok 专题已经逐行核对过,见 MCP 连接、发现与恢复;市场的发现与信任模型见 Plugin Marketplace 的发现与信任,这里不重复展开。
三家放一起,光谱就出来了:Grok 靠市场集中管信任,Claude Code 靠七层配置和权限规则分散管信任,DSH 把两条路拆开,各配各的信任模型:桥外隔离,桥内审批。
推演一次断线重连的完整时间线
weather 服务器在模型刚拿到工具清单后崩溃,8 秒后被 supervisor 拉起来,这次它的工具清单多了一个 get_alerts。请按时间顺序推演:崩溃瞬间注册表里有什么?模型在中断期间调用 mcp__weather__get_forecast 会得到什么?重连成功后注册表经历了什么操作,get_forecast 的公开名变了吗?再回答:如果两个不同的服务器 weather 和 weather2 都暴露 get_forecast,它们会冲突吗,为什么?(提示:世代替换、名字是 (serverName, rawName) 的纯函数。)
(serverName, rawName) 的纯函数,世代替换保证模型手里的工具集要么完整要么为空,永远没有中间态。