ARTICLE · 1006740
DeepSeek Harness 源码-MCP(Model Context Protocol)集成
DeepSeek Harness 源码解析系列
第 20 讲:MCP(Model Context Protocol)集成
基于 DeepSeek Harness 源码 · 2026-09-04
💡 本讲一句话:MCP server 是"别人写的工具供应商",DeepSeek Harness 用一个约 930 行的客户端插件把它接进来——serverName 命名空间防冲突、指数退避 + 稳定性窗口防崩溃循环、两阶段原子换表保证模型永远看到完整工具集。读完你能看懂任何 MCP 集成里最容易出事故的三件事:重连、命名、信任边界。
一、插件入口:一个实例 = 一台 MCP server
@deepseek-ai/dsh-mcp-client 的定位写在包描述里:「connects to MCP servers and registers their tools on ctx.tools」。它不是服务(service),而是一个命名空间插件——只有具名导出 name / inject / Config / apply,没有默认导出。每个插件实例只连一台 server;要接多台就在 cordis.yml 里加载多个实例。
先看仓库里的真实用法(examples/mcp-memory/engram.cordis.yml,第 3-11 行):
📄 examples/mcp-memory/engram.cordis.yml (第 3-11 行)
- insert: # cordis.yml 的插入式组合:把下面这个插件实例挂进当前 agent 配置 - id: memory-engram # 实例 id,HMR/诊断时用来定位"是哪一台 server" name: '@deepseek-ai/dsh-mcp-client' # 指向 mcp-client 包;一个实例只服务一台 MCP server config: # 该实例的连接配置块:命名空间 + stdio 传输参数 serverName: engram # 本地命名空间:模型看到的工具名会是 mcp__engram__<原始名>,必须全局唯一 transport: stdio # 传输方式选子进程 stdio(另一种是 streamable-http) command: engram # 要启动的可执行文件——MCP server 本身是个独立进程 args: [mcp] # 直接传参、不经过 shell 插值,避免注入面 cwd: !!js process.cwd() # cordis.yml 允许 !!js 标签:工作目录取宿主当前目录为什么这样设计:把"一台 server = 一个插件实例"而不是"一个插件管 N 台 server",是刻意的。Cordis 的生命周期(effect、dispose、HMR)都是按实例走的——实例化即连接、销毁即断开,命名空间预留/释放天然跟着 effect 走。如果做成多 server 管理器,一台 server 的重连状态机就会和插件生命周期纠缠在一起,而本讲后面会看到重连逻辑本身已经足够复杂。
入口 apply() 做了四件事:校验重连配置、预留命名空间、启动连接监督器、阻塞激活直到首次同步完成(packages/mcp/mcp-client/src/index.ts,第 140-181 行):
📄 packages/mcp/mcp-client/src/index.ts (第 140-181 行)
export async function apply(ctx: Context, config: Config): Promise<void> { // 插件入口;显式保持 async——Cordis 会把"带原型的普通函数"当构造函数,其返回的 Promise 不算启动工作 const reconnect = resolveReconnectPolicy(config.reconnect, `mcp-client(${config.serverName}): reconnect`) // 加载期就 fail loud:重连配置写错(含绕过 schema 的程序化构造)直接拒绝本实例,而不是等运行时才炸 ctx.effect(() => { // effect = 注册即生效、dispose 时自动回滚;命名空间预留必须跟着插件生命周期走 let names = activeServerNames.get(ctx.root) // 按 app 根上下文取"已占用 serverName 集合"——同进程多 app(测试场景)互不可见 if (!names) { // 该 app 第一次有 mcp-client:先建集合 names = new Set() // 用 Set 存名字,查重 O(1) activeServerNames.set(ctx.root, names) // WeakMap 挂在 ctx.root 上——app 销毁后整条记录自动回收,不泄漏 } if (names.has(config.serverName)) { // 重名 = 配置错误:两个实例的工具会互相覆盖模型可见的名字 throw new Error( // fail loud + 给出可操作的修复建议(去 cordis.yml 改唯一名字),绝不静默遮蔽 `mcp-client: serverName "${config.serverName}" is already in use by another mcp-client instance — pick a unique serverName in cordis.yml`, // 报错信息给出可操作的修复建议——去 cordis.yml 换一个唯一名字 ) } names.add(config.serverName) // 预留成功,本实例独占这个命名空间 return () => void names.delete(config.serverName) // disposer:插件销毁/HMR 换实例时释放名字,新实例可复用同名 }, 'mcp-client.serverName') // effect 注册完成:名字随插件生命周期自动预留/释放 const connection = startConnection(ctx, config, reconnect) // 监督器接管一切:client/transport 世代、重连循环、工具注册;dispose 会停重连、静默在途工作、注销当前世代 ctx.effect(() => { // 把连接清理挂成第二个 effect——HMR 换实例时旧连接被 dispose、新实例重建 return () => connection.dispose() // 把"断开连接"挂成 effect——HMR 热替换 = dispose 旧实例 + 新建实例,同名 serverName 复现同样的工具名 }, 'mcp-client.connection') // effect 命名用于诊断:日志里能看出是哪个钩子在清理 const outcome = await connection.ready // 阻塞插件激活直到首次连接+工具发现落定:Cordis 消费者在 fiber 激活后立刻能看到工具 if (outcome.error !== undefined && config.failOnStartupError) { // failOnStartupError=true 时,启动失败让 fiber reject(Cordis 回滚整个插件);否则只记日志、监督器继续重连 throw new Error(`mcp-client(${config.serverName}): initial connection or tool synchronization failed`, { cause: outcome.error }) // 严格模式:启动失败让 fiber reject,Cordis 回滚整个插件 }}为什么这样设计:注意 await connection.ready 的位置——它在两个 effect 注册之后。这意味着即使启动失败,命名空间预留和 dispose 钩子已经就位:Cordis 回滚 fiber 时 effect disposer 会执行,名字被释放、连接被清理,不会留下"半注册"的僵尸状态。而 failOnStartupError 默认是 false——MCP server 启动慢、依赖外部服务很常见,默认策略是"先激活、后台重连",把严格模式留给确实需要 fail-fast 的场景。
🔑 本讲主线:一个插件实例 = 一台 server = 一条"世代(generation)"生命周期。后面所有机制——重连、换表、清理——都是围绕"当前这一代 client 还活着吗?"这一个判断展开的。
二、传输层:stdio 与 Streamable HTTP 的分叉点
transport.ts 只有 50 行,但藏着一个安全细节:stdio 子进程的环境变量不是原样透传的(第 21-50 行):
📄 packages/mcp/mcp-client/src/transport.ts (第 21-50 行)
function buildChildEnv(extra: Record<string, string>): Record<string, string> { // 组装子进程环境:先清洗宿主环境,再叠加配置里显式声明的变量 return { ...scrubbedParentEnv(), ...extra } // scrubbedParentEnv() 来自 dsh-subprocess:丢掉凭证形状和过期的 DSH_* 名字;MCP SDK 负责真正 spawn,这里只共享"清洗定义"不共享 spawn 路径}export function createTransport(config: Config): Transport { // 工厂函数:按 discriminated union 的 transport 字段分叉出两种传输 switch (config.transport) { // 按传输类型分叉——两种 transport 的构造参数完全不同 case 'stdio': // 本地子进程模式:MCP server 是个可执行文件,JSON-RPC 走 stdin/stdout return new StdioClientTransport({ // stdio 传输:MCP SDK 负责 spawn,这里只给参数 command: config.command, // 要启动的可执行文件(如 engram) args: config.args, // 参数直传、不经 shell——没有插值就没有注入面 env: buildChildEnv(config.env), // 清洗后的环境 + 显式变量:宿主里的 API key 不会漏给第三方 server cwd: config.cwd, // 子进程工作目录,空串 = 继承宿主 }) case 'streamable-http': // 远程模式:MCP server 是 HTTP 端点(SSE/Streamable HTTP) return new StreamableHTTPClientTransport( // HTTP 传输:JSON-RPC 走 SSE,无需本地子进程 new URL(config.url), // 先解析成 URL 对象——非法 URL 在这里就抛错,而不是等连接时才失败 { requestInit: { headers: config.headers } }, // 附加请求头(鉴权 token 通常走这里) ) as Transport // cast 收尾:SDK 内部类型与公共 Transport 接口的加宽记录 }}为什么这样设计:scrubbedParentEnv() 是复用 subprocess 能力包(第 14 讲分析过)的清洗逻辑——MCP server 是第三方代码,宿主环境里的 DEEPSEEK_API_KEY、内部凭证如果原样透传,等于把钥匙交给任意外部进程。"共享清洗定义而非 spawn 路径"则保持了能力边界:MCP SDK 拥有子进程生命周期(第 20 讲的主角),subprocess 包只贡献安全策略。
两种传输的对照:
三、重连策略:指数退避 + 稳定性窗口
connection.ts(351 行)是整个包的灵魂。先看策略解析——为什么 schema 校验之外还要再写一遍校验?因为"程序化构造可能绕过 Schemastery",所以每个默认值和边界都在这里重新裁决一次(第 65-90 行):
📄 packages/mcp/mcp-client/src/connection.ts (第 65-90 行)
export function resolveReconnectPolicy(config: ReconnectConfig | undefined, path: string): ResolvedReconnectPolicy { // 唯一的"原始配置 → 运行策略"解析点;path 用于报错时指明是哪个实例的哪段配置 if (config !== undefined) { // 只校验显式给出的键——省略的键直接落默认值,不用逐个判空 for (const key of Object.keys(config)) { // 白名单校验:出现未知键直接抛错——拼写错误(如 maxAttempt)不该被静默忽略 if (!Object.hasOwn(RECONNECT_DEFAULTS, key)) throw new Error(`${path}.${key} is not a reconnect option`) // 报错带上 path 前缀,定位到具体实例的哪段配置 } } const enabled = config?.enabled ?? RECONNECT_DEFAULTS.enabled // 缺省用默认值(true):断线后自动重连是期望行为,不是可选项 const initialDelayMs = config?.initialDelayMs ?? RECONNECT_DEFAULTS.initialDelayMs // 首次重试延迟,默认 500ms——太快会打爆刚崩的 server const maxDelayMs = config?.maxDelayMs ?? RECONNECT_DEFAULTS.maxDelayMs // 退避上限,默认 30s;它同时是"稳定性窗口"(见下) const maxAttempts = config?.maxAttempts ?? RECONNECT_DEFAULTS.maxAttempts // 单次故障的连续失败预算,默认 10 次——耗尽即永久放弃 if (!Number.isFinite(initialDelayMs) || initialDelayMs <= 0 || initialDelayMs > MAX_TIMER_DELAY_MS) { // 延迟必须是正有限数且不超过定时器安全上限(2^31-1ms,setTimeout 溢出边界) throw new Error(`${path}.initialDelayMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`) // 报错指明合法范围,配置者一眼能改对 } if (!Number.isFinite(maxDelayMs) || maxDelayMs <= 0 || maxDelayMs > MAX_TIMER_DELAY_MS) { // 上限同理:NaN/负数/超界都会让退避序列失去意义 throw new Error(`${path}.maxDelayMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`) // 上限越界会让退避序列失去意义,同样拒绝 } if (initialDelayMs > maxDelayMs) { // 初始延迟不能大于上限——否则第一次重试就顶到天花板,退避曲线不存在 throw new Error(`${path}.initialDelayMs must be less than or equal to maxDelayMs`) // 初始延迟大于上限 = 第一次重试就顶到天花板,退避曲线不存在 } if (!Number.isInteger(maxAttempts) || maxAttempts < 1) { // 预算必须是正整数:0 次 = 永不重试,与 enabled=true 语义矛盾 throw new Error(`${path}.maxAttempts must be a positive integer`) // 预算为 0/负数与 enabled=true 语义矛盾,拒绝 } return Object.freeze({ enabled, initialDelayMs, maxDelayMs, maxAttempts }) // freeze:策略在加载期定死,运行中不可变——状态机只读它}为什么这样设计:四个参数里最微妙的是 maxDelayMs 的双重身份——它既是退避上限,又是"稳定性窗口":连接存活超过这个时长就视为"上次故障已结束",下次断线时重试预算清零。这个设计精准打击一类恶意/病态 server:崩溃循环者——每次 connect 都短暂成功、然后立刻挂掉。没有稳定性窗口,它的每次"成功"都会重置预算,重连永远停不下来;有了窗口,它必须连续撑过 maxDelayMs 才算数,否则照样耗尽 10 次预算被永久拉黑。
📊 重连参数速查:enabled(默认 true)|initialDelayMs(500,每次 ×2)|maxDelayMs(30s,退避上限 + 稳定性窗口)|maxAttempts(10,单次故障预算)。耗尽后工具被注销、重连停止——唯一恢复途径是 HMR reload 或重启 Host。
四、世代管理:一次连接尝试的完整生命周期
connectGeneration() 是监督器的核心动作:新建 client、挂 onclose、注册工具列表变更处理器、连接、排队首次同步(第 237-305 行)。注意 MCP SDK 的一个硬约束——一个 Protocol 绑定一个 transport,终身不换,所以每次重连都是"全新世代":
📄 packages/mcp/mcp-client/src/connection.ts (第 237-305 行)
async function connectGeneration(startup: boolean): Promise<void> { // 一次连接尝试;startup=true 表示插件激活那次——它的失败语义更严格 const generation = new Client( // 每次重连都是全新 client——MCP SDK 的 Protocol 绑定 transport 终身不换 { name: 'dsh-mcp-client', version: '0.0.1' }, // MCP 协议握手用的客户端身份:对端 server 靠它识别"谁在连我" { capabilities: {} }, // 声明零能力——本桥只做 tools/call,不提供 resources/prompts 等反向服务 ) const closed: PromiseWithResolvers<void> = Promise.withResolvers() // "关闭屏障":onclose 触发时 resolve;dispose/失败路径靠 await 它确认旧世代真的死了 let attemptSettled = false // 本次尝试是否已落定(成功或失败)——区分"连接中收到 close"和"已建立后断开"两条路径 let closeObserved = false // onclose 是否已触发;hasClosed() 读它,避免在 await 间隙里状态被翻转 const hasClosed = (): boolean => closeObserved // 小闭包封装读取点——所有判断都走这里,方便审计"谁在读关闭状态" client = generation // 宣告当前世代:isCurrent() 从此刻起以它为基准(旧世代的在途回调全部失效) clientClosed = closed.promise // dispose 要等这个屏障;先于 close 捕获,避免所有权清空后拿不到 generation.onclose = () => { // SDK 的断开信号:进程退出、连接 reset、主动 close 都会走到这里 closeObserved = true // 记录"已观察到关闭"——后续所有路径都据此判断旧世代是否已死 closed.resolve() // 放行等待屏障的人(dispose / 失败清理) if (attemptSettled) generationDown(generation) // 只有"已建立后断开"才触发重连决策;连接中失败的 close 由 catch 路径自己处理——避免双重调度 } generation.setNotificationHandler( // tools/list_changed:server 主动说"我的工具变了" ToolListChangedNotificationSchema, // MCP 协议定义的通知 schema——按类型路由到本回调 async () => { // 异步处理器:重同步是网络操作,必须 await if (!isCurrent(generation)) return // 旧世代的迟到通知直接丢弃——它对应的 client 已不属于当前实例 ctx.logger.info(`${label}: tool list changed, re-syncing`) // 记日志:工具列表变更是低频事件,值得留痕 try { // 排队重同步;fetch 阶段失败要单独兜住——不能炸掉通知通道 await enqueueSync(generation) // 排队重同步——串行链保证不和别的 sync 交错换表 } catch (error) { // fetch 阶段失败要单独兜住——不能炸掉通知通道,旧工具列表继续服务 if (!disposed) ctx.logger.error(`${label}: tool re-sync failed: ${String(error)}`) // fetch 阶段失败:上一代工具仍注册着、disposers 仍持有它——继续服务最后的好列表,而不是清空 } }, ) try { // connect + sync 整体包在 try 里:任何一步失败都走 catch 的清理路径 await generation.connect(createTransport(config)) // 真正建立连接(stdio spawn / HTTP 握手);每次都是全新 transport——SDK 不允许换绑 if (hasClosed()) { // connect 返回的瞬间可能已经收到 close(server 启动即崩):按"已断开"处理,不走成功路径 attemptSettled = true // connect 返回的瞬间已收到 close:按"连接中失败"处理,不走成功路径 generationDown(generation) // 进入重连决策(退避/预算/放弃) return // 本次尝试到此结束——不记为成功,也不走 sync } await enqueueSync(generation, startup ? startupOpts : opts) // 排队首次工具同步;startup 时冲突会 throw(failOnStartupError),重连时永远 contain } catch (error) { // connect 或 sync 抛错:统一走失败清理路径,绝不向外 reject(本函数契约 Never rejects) if (firstAttemptError === undefined) firstAttemptError = error // 只留第一次的真实错误——ready promise 要拿它做启动诊断,后续重试的错误是噪音 if (isCurrent(generation)) ctx.logger.warn(`${label}: connection attempt failed: ${String(error)}`) // dispose 会先清所有权再 close——只有"活着的监督器"才报告失败,避免销毁期刷日志 try { await generation.close() } catch { /* transport already gone */ } // 尽力关闭;transport 可能已经没了,close 抛错是正常情况 const quiesced = hasClosed() || await waitForClose(closed.promise) // 等关闭屏障(5s 超时):必须确认旧世代"真的死了"才允许下一世代之——否则两个 server 进程重叠 attemptSettled = true // 失败路径落定——onclose 若再触发,generationDown 的 isCurrent 守卫会拦下 if (!isCurrent(generation)) return // 等待期间被 dispose/新世代抢占:本尝试的清理到此为止,不再做决策 if (!quiesced) { // 5s 内没关干净:fail closed——宁可停止重连也不冒"重叠子进程"的风险(stdio 模式下两个 server 抢同一资源) client = undefined // 清掉当前世代引用——isCurrent() 从此对旧世代返回 false clientClosed = undefined // 关闭屏障一并释放:后续 dispose 不再等它 ctx.logger.error(`${label}: failed generation did not close within ${GENERATION_CLOSE_TIMEOUT_MS}ms — reconnect stopped to avoid overlapping server processes; reload the plugin or restart the Host to retry`) // fail closed:宁可停止重连也不冒两个 server 进程重叠的风险 return // 放弃本次重连——唯一恢复途径是 HMR reload 或重启 Host } generationDown(generation) // 确认已死 → 进入重连决策(退避/预算/放弃) return // 失败路径收尾——onclose 若再触发,isCurrent 守卫会拦下 } attemptSettled = true // connect + sync 全部落定:本次尝试成功 if (hasClosed()) { // 同步期间连接又断了:按断开处理,不记为"已建立" generationDown(generation) // 触发重连决策——这次不算数 return // 本次尝试结束——等待下一世代的调度 } if (!isCurrent(generation)) return // 极端竞态:被 dispose 或新世代抢占——静默退出 connectedAt = Date.now() // 记录建立时刻——稳定性窗口(maxDelayMs)从这一刻起算 if (failedAttempts > 0) ctx.logger.info(`${label}: reconnected and re-synced tools (attempt ${failedAttempts}/${policy.maxAttempts})`) // 重连成功留痕:运维能看出"这台 server 断过几次、第几次恢复的"}为什么这样设计:这段代码里最值钱的不是重连本身,而是三个竞态防护:① isCurrent(generation) 让"迟到的回调"全部失效——旧世代的 onclose、通知、catch 都不会再影响新世代;② closed 屏障 + 5s 超时保证"上一代确认死亡"才放行下一代,宁可停止重连也不让两个 server 进程重叠;③ attemptSettled 区分"连接中失败"和"建立后断开",两条路径的 close 信号只被消费一次。这三层合起来,才让"崩溃循环 server 耗尽预算被拉黑"这个承诺在并发下依然成立。
五、命名契约:mcp__<server>__<tool> 与 SHA-256 防碰撞
模型看到的工具名必须满足 DeepSeek function-name 契约:≤64 字符、只允许 [A-Za-z0-9_-]。但 MCP server 的原始工具名可以是任意字符串——publicToolName() 负责这个归一化,而且必须确定性(第 96-102 行):
📄 packages/mcp/mcp-client/src/tools.ts (第 96-102 行)
export function publicToolName(serverName: string, rawName: string): string { // (serverName, rawName) → 模型可见名;纯函数、确定性——HMR 重建实例后名字必须复现 const joined = `mcp__${serverName}__${rawName}` // 干净情况下的目标形态:前缀 mcp__ + server 命名空间 + 原始名,一眼看出"这是哪台 server 的哪个工具" const normalized = joined.replace(INVALID_NAME_CHARS, '_') // 非法字符(点、斜杠、空格…)统一替换成下划线——满足 [A-Za-z0-9_-] 契约 if (normalized === joined && normalized.length <= MAX_PUBLIC_NAME_LENGTH) return normalized // 没被改动且 ≤64 字符:原样返回,这是绝大多数工具的路径(零开销、可读性最好) const hash = createHash('sha256').update(`${serverName}\0${rawName}`).digest('hex').slice(0, HASH_LENGTH) // 有损归一化时追加 12 位 SHA-256:对"身份"(server\0raw) 取哈希,而不是对 normalized——因为两个不同原始名可能归一成同一个字符串 return `${normalized.slice(0, MAX_PUBLIC_NAME_LENGTH - HASH_LENGTH - 1)}_${hash}` // 截断到 64-12-1=51 字符 + '_' + 12 位哈希:不同 MCP 身份永远不会塌缩成同一个公开名(防碰撞)}为什么这样设计:关键在哈希的输入是 serverName\0rawName(原始身份)而不是归一化后的字符串。反例:server A 的工具 a.b 和 server B 的工具 a_b,归一化后都变成 mcp__x__a_b——如果只靠替换+截断,两个不同工具会撞名、互相覆盖。对原始身份取哈希后,它们的后缀必然不同。另外注意 rawName 只在 wire 上出现(tools/call 请求里),公开名永远不被反向解析回 rawName——这是命名契约的单向性,避免"名字即协议"的脆弱耦合。
search_memory | mcp__engram__search_memory | |
memory.search | mcp__engram__memory_search | |
a-very-long-tool-name-that-exceeds-the-sixty-four-character-limit-for-sure | mcp__engram__a-very-long-tool-name-that-exceeds_9f3ab2c1d0e4 | |
a.ba_b | …_<hashA> / …_<hashB> |
六、两阶段同步:fetch 与 swap 的原子性
syncTools() 把"工具列表变更"拆成两个阶段,核心承诺是:模型要么看到完整的新世代,要么继续用旧世代——永远看不到半套(第 128-174 行):
📄 packages/mcp/mcp-client/src/tools.ts (第 134-173 行)
// Phase 1: fetch and build the next generation without touching the registry. const definitions = new Map<string, ToolDefinition>() // publicName → 工具定义;Map 保序且查重 O(1) let cursor: string | undefined // MCP tools/list 是分页协议:cursor 驱动翻页直到 server 说"没有了" do { // MCP tools/list 是分页协议:循环拉取直到 server 不再给下一页 const response = await listToolsUncached(client, cursor) // "uncached":绕过 SDK 的 per-page 输出校验缓存——桥自己决定信任哪些 schema,不让 SDK 抢先按它认识的契约校验 for (const tool of response.tools) { // 逐工具构建定义——任何异常都会让整页 fetch 失败、旧世代不动 const publicName = publicToolName(opts.serverName, tool.name) // 归一化成模型可见名(上一节讲过的确定性映射) if (definitions.has(publicName)) { // server 自己列了重名工具:这是无效列表,直接拒绝——否则后一个会静默覆盖前一个 throw new Error( // 重名工具 = 无效列表:拒绝整个 fetch,而不是让后一个静默覆盖前一个 `mcp-client(${opts.serverName}): server listed tool "${tool.name}" more than once — invalid tool list`, // 报错指明是哪台 server、哪个工具重复——配置者能直接去 server 侧修 ) } definitions.set(publicName, { // 构建 harness ToolDefinition:注册表里存的是公开名 + 执行闭包 name: publicName, // 注册表里的名字 = 模型看到的名字(公开名,不是 rawName) description: tool.description ?? '', // 描述缺失给空串——schema 要求 string,不能是 undefined parameters: tool.inputSchema, // MCP inputSchema → harness 参数 schema;不支持的词汇由 assertSupportedJsonSchema 在别处兜底 output: createOutput(tool.name, supportedOutputSchema(tool.outputSchema)), // 输出契约:content 数组 + 可选 structuredContent(server 声明了支持的 schema 才保留) execute: createExecutor(client, tool.name, tool.execution?.taskSupport === 'required', opts), // 执行器闭包捕获 rawName——调用时发 wire 的是原始名,不是公开名 }) } cursor = response.nextCursor // server 没给下一页就退出循环 } while (cursor) // 有 nextCursor 就继续拉——保证拿到全量工具列表 // Phase 2: swap generations. for (const dispose of previous.values()) dispose() // 注销上一代全部工具(它们对应的 client 可能已死,但注册项必须清掉) const disposers: ToolDisposers = new Map() // 本世代的"注销句柄表":publicName → disposer,dispose/放弃重连时靠它精确清理 try { // 注册阶段:冲突要能整体回滚,所以包在 try/catch 里 for (const [publicName, definition] of definitions) { // 逐个注册本世代工具——register 返回的 disposer 存进 disposers disposers.set(publicName, ctx.tools.register(definition)) // register 返回 disposer——harness 注册表的贡献都是 effect 式的,可逆 } } catch (error) { // 注册冲突:只可能是外部注册项占用了本 server 的 mcp__ 命名空间 // A conflict on an `mcp__<serverName>__`-qualified name means a foreign registration occupies this server's namespace. for (const dispose of disposers.values()) dispose() // 回滚已注册的部分——模型看到的必须是"完整世代或零个工具",绝不是一半 ctx.logger.error(`mcp-client(${opts.serverName}): tool registration failed, no tools registered: ${String(error)}`) // 留痕:运维能看出"这台 server 一个工具都没挂上、原因是什么" if (opts.registrationFailure === 'throw') throw error // startup 严格模式:把冲突抛给父事务(Cordis 回滚整个插件);普通重连则吞掉、返回空表 return new Map() // contain 语义:本 server 零工具,但其他 server 不受影响 } return disposers // 成功:返回本世代持有的全部注销句柄——监督器用它做"精确清理"为什么这样设计:"fetch 失败不动注册表"解决的是可用性——网络抖动、server 重启期间,模型手里的旧工具列表继续可用(配合上一节 extractText 的"最后好列表"策略)。"swap 冲突全回滚"解决的是正确性——半套工具比没有工具更危险:模型可能调用一个"存在但行为未知"的工具。而 registrationFailure: 'throw' | 'contain' 的双模式,让同一个 syncTools 既能服务启动期(严格、可回滚 fiber)又能服务运行期(宽容、保可用),不用写两套逻辑。
🔒 信任边界提醒:MCP server 是外部进程,它返回的 JSON 在协议层"合法"不代表运行时字段齐全。本桥对 content block 的 text/mimeType 全部做了 ?? fallback——这是网络信任边界的防御性编程,不是过度设计。
七、执行器:isError 语义与内容块降级
createExecutor() 是模型每次调用 MCP 工具时真正跑的代码(第 234-277 行):
📄 packages/mcp/mcp-client/src/tools.ts (第 234-277 行)
return async (args: unknown, exec: ToolExecution) => { // execute 闭包:args 是模型给的参数,exec 带 abort signal(agent loop 取消时能打断这次调用) if (taskRequired) { // MCP server 声明"必须走 task-based execution"——本桥不支持这种模式 throw new Error(`Tool "${rawName}" requires task-based execution, which this bridge does not support`) // fail loud:与其假装支持然后行为错乱,不如明确拒绝让模型换工具 } const argsObj = (typeof args === 'object' && args !== null ? args : {}) as Record<string, unknown> // agent loop 传的是 JSON.parse(模型输出)——通常是对象,但模型可能吐裸字符串/数字/null;兜底成 {} 让 server 报"缺必填参数",这个错误模型能学会 const result = await callToolUncached(client, rawName, argsObj, exec, opts) // wire 调用:发的是 rawName(不是公开名)+ abort signal + toolCallTimeoutMs;uncached 避免 SDK 用它的 schema 缓存抢先校验 if (!Array.isArray(result.content)) { // 兼容旧版 MCP server 的 legacy toolResult 形态——content 可能根本不是数组 const rendered: unknown = 'toolResult' in result // legacy 形态:有 toolResult 字段就序列化它当文本 ? JSON.stringify(result.toolResult) // 转成字符串——模型侧只认 text content block : '(no output)' // 什么都没有:给模型一个明确的"无输出"信号,而不是 undefined const text = typeof rendered === 'string' ? rendered : '(no output)' // 兜底成字符串——序列化失败时也有确定值 if (result.isError === true) throw new Error(text) // MCP isError → 抛异常:让 ToolRuntime 的 catch 路径生成 isError 结果——模型靠这个字段知道"调用失败了、原因在这" return { // legacy 路径返回:统一成 harness 结果结构 content: [{ type: 'text', text }], // 统一成 harness ContentBlock 数组 ...result.structuredContent !== undefined // 结构化结果可选:有就透传给 Code Mode,没有就不带键 ? { structuredContent: result.structuredContent as JsonValue } // Code Mode 拿到的是原始 JSON 值——不经过文本化损失 : {}, // 条件展开为空对象——不引入多余的 undefined 字段 } } const content = result.content as unknown as JsonValue[] // SDK 的返回类型因 CallToolResult | CompatibilityCallToolResult 联合而擦除成 any[]——这里显式收窄,下面逐块校验 const text = extractText(content, rawName) // 提取纯文本(text 块拼接、image/audio/resource 降级为占位符) if (result.isError === true) { // 标准路径的 isError:同样抛异常走 ToolRuntime 错误通道——语义统一 throw new Error(text) // 异常消息 = server 给的失败原因文本:模型能读到并据此调整 } return { // 标准路径返回:content + 可选 structuredContent,与 legacy 路径同构 content, // 原始 content 块数组保留(Code Mode 可以拿到非文本内容) ...result.structuredContent !== undefined ? { structuredContent: result.structuredContent as JsonValue } : {}, // 条件展开为空对象——不引入多余的 undefined 字段 }}为什么这样设计:isError → throw 是最关键的一步。MCP 协议里错误是"结果里的一个布尔位",而 harness ToolRuntime 的错误通道是"execute 抛异常"——桥在这里做语义翻译,让模型侧只有一种失败形态(isError 结果 + 原因文本),不需要理解 MCP 的两种错误表达。而 args 兜底成空对象而非直接抛错,是为模型的学习曲线服务:server 返回的"missing required parameter: X"比 harness 侧的类型错误信息更有指导性。
内容块降级逻辑(extractText,第 288-317 行):
📄 packages/mcp/mcp-client/src/tools.ts (第 291-317 行)
for (const value of mcpContent) { // 逐块处理:MCP content 是异构数组,每块的 type 决定怎么读 if (typeof value !== 'object' || value === null || Array.isArray(value)) { // 非对象/嵌套数组:协议外的脏数据——给占位符而不是崩溃(信任边界防御) parts.push('[unsupported content type: unknown]') // 脏数据给占位符而不是崩溃——模型至少知道"这里有个没识别的块" continue // 跳过本块继续处理后面的 } const block = value as unknown as McpContentBlock // 收窄到"我们认识的字段子集"——故意比 SDK 的 ContentBlock 更宽松:buggy server 可能缺必填字段 switch (block.type) { // 按 MCP content block 类型分派:每种类型的降级策略不同 case 'text': // 文本块:唯一被完整保留的内容类型 if (block.text !== undefined) parts.push(block.text) // text 缺失(spec 必填但运行时可能没有)就跳过——不拼出 "undefined" 字符串 break // 文本块处理完,进入下一块 case 'image': // 图像块:harness 的文本通道传不了二进制 parts.push(`[image: ${block.mimeType ?? 'unknown'}, content discarded]`) // 降级为占位符 + mimeType——模型知道"有张图、什么格式" break // 图像块处理完,进入下一块 case 'audio': // 音频块同理:内容丢弃,但保留类型信息 parts.push(`[audio: ${block.mimeType ?? 'unknown'}, content discarded]`) // 占位符带 mimeType——模型能向用户说明"有个音频没拿到" break // 音频块处理完,进入下一块 case 'resource': // resource / resource_link:文件引用,内容不在块里 case 'resource_link': // 两种资源块共用同一降级策略 parts.push('[resource: content discarded]') // 占位符——模型知道"这里有个资源引用" break // 资源块处理完,进入下一块 default: // 未知类型(未来 MCP 版本的新块):显式标注而不是静默丢弃 parts.push(`[unsupported content type: ${block.type}]`) // 带上原始 type——模型能据此向用户说明"有内容没拿到、是什么类型" } } return parts.join('\n') || `(${toolName} returned no text content)` // 块间用换行拼接;全空时给工具名兜底——模型永远拿得到"非 undefined"的文本为什么这样设计:占位符策略([image: image/png, content discarded])比静默丢弃好得多——模型能告诉用户"server 返回了一张图但我这边看不到",而不是假装工具没输出。这是"对模型诚实"原则的体现:harness 的能力边界要显式暴露给模型,让它自己决定怎么向用户解释。
八、端到端数据流
MCP 工具调用全链路
🤖 模型 → mcp__engram__search_memory(公开名)
↓ ToolRuntime.execute → createExecutor 闭包
🔁 查闭包捕获的 rawName search_memory(公开名从不反解)
↓ callToolUncached:tools/call + abort signal + 60s timeout
🌐 MCP server(stdio 子进程 / HTTP 端点)执行并返回 JSON-RPC 结果
↓ isError? → throw;否则 extractText + structuredContent 透传
📥 ToolRuntime 结果 → 模型观察(content 块 / isError 文本)
🔌 断线 → onclose → generationDown → 指数退避(500ms→…→30s)
↓ 存活 ≥ maxDelayMs = 新预算;否则 failedAttempts+1,>10 次 → 注销工具、停止重连
🔄 重连成功 → enqueueSync(串行链)→ fetch 全量 → swap 换表 → 模型看到新世代
九、设计原则小结
| Fail loud | |
| 单一所有者 | |
| 原子换表 | |
| 确定性命名 | |
| 信任边界防御 | |
| 有界重试 |
mcp-client 只有 5 个源文件、约 930 行,但它把"接入一个不受控的外部工具供应商"这件事里最容易出事故的三件事——重连风暴、命名碰撞、半套工具——都用状态机级别的严谨性钉死了。这也是 DeepSeek Harness "everything is a plugin" 哲学的典型样本:再复杂的能力,最终也只是 ctx.effect() + ctx.tools.register() 的组合。
📚 系列导航
← 第 19 讲:Web 能力(搜索/抓取)
→ 第 21 讲:终端持久会话与 LSP
关注公众号「AI技术推荐官」获取更多源码解析内容