深入浅出 DeepSeek Harness 第六部:能力接入——Skills、MCP和插件 第 40 章

外部进程的工具,怎样进全局工具表又不撞名

mcp-client 把一个 MCP server 连进来(stdio 子进程或 streamable-http),按 server 名建立受监督的连接,两阶段把它的工具同步进全局工具表;publicToolName 给外部工具加前缀防撞车,重连与指令注入都在连接监督器里。MCP 是"外部进程即工具源"。

本章以中文撰写

源码版本
dsh-v0.1.7-rc.2
验证日期
Commit
477b4f420553e8a52c2fbccc464d7561b239c443

模型决定调一个 search_web 工具。这个工具不是 DSH 自带的——它是某个外部 MCP server 提供的。这一章讲从”插件声明了一个 MCP server”到”这个 server 的工具出现在全局工具表、模型能调它”之间发生了什么:连接怎么建、断线怎么重连、外部工具的名字怎样在几十个工具里不撞车,以及为什么同步要拆成两阶段而不是边拉边挂。

mcp-client:一个 server = 一条受监督连接

mcp-client 本身是一个 Cordis 插件,inject=['tools']——它的全部本事就是把外部工具塞进 tools 服务。每个被声明的 MCP server 是一条独立的连接,server 名是这条连接的句柄。

本节源码依据(4 处)

为什么 server 名是受管资源。 activeServerNames 是一个 WeakMap<ctx, Set<string>>:每个 Cordis 上下文维护自己已占用的 server 名集合。同一上下文里第二次声明同名 server,在 reserve 阶段就被拒绝——这是有意的:两个同名 server 的工具会互相覆盖,与其运行时静默串,不如注册时就响亮失败。WeakMap 又保证上下文销毁后这些占用自动回收,不泄漏。

两种传输与子进程环境擦除

stdio 类型要起一个子进程;streamable-http 只是连一个 URL。关键细节在子进程环境:父进程环境里混着各种凭据形状的变量,不能原样塞给一个来路不一定可靠的 MCP server。

本节源码依据(2 处)

scrubbedParentEnv() 把父进程环境里”长得像凭据”的变量和过时的 DSH_* 名字先剥掉,再叠 config 显式声明的 env。也就是说:给外部子进程的环境是一张”擦过的白纸 + 显式配料”,不是父环境的原样继承。 这和第 50 章讲的”引用而非值”是同一套克制——外部进程不该顺手拿到宿主的凭据。

维度stdiostreamable-http
连接方式spawn 子进程连一个 URL
环境处理scrubbedParentEnv + 显式 env不涉及进程环境
认证随进程 cwd/envheaders 里带
典型场景本地命令行工具 server远端托管 server

两阶段同步:先拉全量,再一次性换表

连接 ready 之后要把 server 的工具搬进全局工具表。这里最关键的设计是两阶段,而不是边 list 边挂。

本节源码依据(4 处)

MCP 工具注册:apply 保留 server 名后 startConnection(stdio 擦环境子进程 / http 连 URL)→ initialize → syncTools 两阶段(Phase1 listTools refresh 校验重名,Phase2 一次性旧摘新挂、失败回滚);publicToolName 加 server 前缀防撞名;断线指数退避重连。

为什么要拆两阶段。 如果边拉边挂,拉到一半 server 报错,工具表就停在”一半旧一半新”的状态——模型可能拿到一个刚被摘、或刚被挂的错位工具。两阶段把”决策”(Phase1 算出完整新表、含重名校验)和”生效”(Phase2 一次性换表)分开:决策阶段不碰全局表,全算对了才原子替换,错了就回滚到上一张好表。这是把事务思想用到工具注册上。

publicToolName:外部工具怎样不撞名

DSH 自己有几十个工具,外部 server 也可能叫 search、read。直接用 server 给的裸工具名必然撞车。publicToolName 给外部工具统一加 server 前缀。

本节源码依据(5 处)

命名规则很克制:server 名 + 分隔符 + 裸工具名,总长压到 64 字符内;真超长就截短再拼 12 位哈希。这样既保证”一眼看出这是哪个 server 的工具”(可读),又保证跨 server 不撞(唯一)。为什么要哈希而不是报错:外部 server 的工具名是别人定的,长是常态;报错会让一个超长名字的工具整个不可用,哈希则让它照样进表、只是名字丑一点——可用性优先于美观。

连接监督器:断线重连与代次收尾

连接不是”连上就完事”。外部 server 会崩、会重启,监督器要自动重连;host 关闭时要干净收尾。

本节源码依据(4 处)
维度默认值含义
初始重连延迟500ms第一次断线后等多久重试
退避上限30s两次重连之间最长间隔
最大重连次数10超过即放弃该 server
指令截断32768 字节server instructions 上限
代次收尾超时5sclose 一代连接最多等多久
工具调用超时60s单次 callTool 超时

serverName 命名空间预留:撞名当场拒,不伤旧实例

多个 mcp-client 实例的 public 工具名都长在 mcp__<serverName>__ 这个命名空间下。如果两个 server 配了同一个 serverName,它们的工具就会互相覆盖——这不是”后写赢”能糊弄过去的,得在加载期就拦住。

本节源码依据(3 处)

为什么要”当场拒、留旧实例”而不是静默后写。 撞名本质是配置错误——两个 server 想用同一个门面。后写赢会让旧 server 的工具名被悄悄顶替,模型下次调旧工具就调到了新 server 上,是最难查的一类错。所以这里选”新实例当场炸、旧实例毫发无损”:错误立即可见(启动期就崩),正确实例不受牵连。命名空间按 scope owner 分桶,意味着两个不同 Agent 可以各自复用同一个 serverName——它们的工具彼此不可见,不算撞。

和撞名配套的是”启动等不等”。apply 是 async 的:它会 await connection.ready,把”连上 + 首次工具同步”这一段算进插件激活期。failOnStartupError 默认 false——这时首次连不上只记日志、监督器自己进重连环,host 不因一个外部 server 没起来就整体起不来;把它设成 true,则首次失败直接抛错回滚整个 fiber。这个开关把”外部依赖的脆弱性”留给部署方自己权衡:是宁可慢也要工具齐,还是先起来再说。两种默认都不是 bug,而是不同部署环境的取舍。

图片结果的准入闸门:不信任 server 喂来的图

外部 server 返回的 content 里可能带 image 块。DSH 不能照单全收:一来图的 MIME 类型和 base64 编码可能是伪造的,二来当前模型路线根本不一定支持看图。所以图片要过一道”准入”,过不了就降级成一段说明文字,但保留原始值给程序化调用者。

本节源码依据(4 处)

为什么”降级成文字”而不是整轮报错。 图片 server 返回了图,但当前模型是纯文本的——这不是 server 的错,也不是该让这一轮工具调用失败的事。正确的处理是:模型眼前看到一句”这有张图但你看不了”,而原始结构化结果仍完整保留,下游(比如把结果转成 JSON 给别的系统)照样拿得到图的字节。准入闸门因此是”面向模型的投影层”,不是”面向协议的校验层”——后者早已由 SDK 的 CallToolResult schema 做过一轮。

还有个细节:投影要保持 MCP content 数组的原始顺序。文本块被先攒在一个 text 缓冲里,碰到图才 flush 成一段、再在原位置插入 image 块;图全部被拒时,图的位置换成一句说明文字,文本块的相对顺序不变。这样模型看到的文本流和”图根本没来”时是连贯的,不会因为中间夹了个被拒的图就把两段文本错误地拼到一起。(toolName) returned no model-visible content 这个兜底串也说明:投影层宁可给一句占位,也绝不把”空结果”原样塞给模型。

小结

MCP 工具注册是”外部进程即工具源”:一个 server 名一条受监督连接,stdio 子进程先擦环境再叠显式 env,两阶段把工具原子换进全局表,publicToolName 加前缀防撞车,断线指数退避重连、代次 5s 收尾。下一章看仓库自己怎样变成一个能力包——第 41 章:.dsh 目录被自动发现、宿主改自己的技能文件又不触发重载环的边界。