青雲的博客
深入浅出 DeepSeek Harness 第六部:能力接入——Skills、MCP和插件 第 36 章

MCP 工具注册与 Repository Plugin

外部 MCP server 的工具如何进入 DSH 运行时的 tool registry,以及 Repository Plugin (tool-cordis) 如何让 agent 自行修改运行时配置。MCP 桥接插件管理连接、命名空间隔离、工具同步;Repository Plugin 提供 define/run/stop/undefine 四个操作让 agent 增删 MCP server。

源码版本
47f943859bef60e4160492346772ded9b24f765a
验证日期
Commit
47f943859bef60e4160492346772ded9b24f765a

问题:外部 MCP 工具如何进入运行时工具注册表

你在 cordis.yml 里声明了一个 MCP server 条目——也许是一个 GitHub 工具服务、也许是一个数据库查询代理。你期望模型马上就能调用它暴露的工具。但你不清楚:工具名是怎么从 MCP server 的声明变成模型可见的函数调用的?注册表里的 MCP 工具和内置的 tool-bashtool-fs 有什么本质区别?连接断了工具去哪了?

更深层的问题:harness 怎么让 agent 修改自己的运行时配置——定义新插件、启动它、停止它——而不需要人工编辑配置文件?

本章拆开两条机制:MCP 客户端桥接(把外部 server 的工具注入 ToolRuntime),以及 Repository Plugin(tool-cordis)让 agent 获得自修改能力。

先把 MCP 这事摆正:它不是“第二套工具执行系统”。外部 server 的工具进来之后,还是照样走 ToolRuntime 那条流水线——调度、并发、审批、沙箱,一个都少不了。

然后是命名空间。serverName 这个前缀不是装饰品,是为了别撞名;真撞了,apply 阶段直接 fail-fast,别指望悄悄覆盖。

最后是 Repository Plugin。它不是让 agent 直接改文件,而是把“改运行时”这件事封成一组受控动作(define/run/stop/undefine),让自修改也有边界。

读法也很简单:先顺着“声明 → 连接 → syncTools → 注册”把 MCP 注入跑通,再回头看映射和断线清理;最后再看 Repository Plugin 那条配置变更链。


第一部分:MCP 客户端桥接

mcp-client 插件:一个实例连一个 server

@deepseek-ai/dsh-mcp-client 是一个标准的 Cordis 命名空间插件。它声明 inject = ['tools'],意味着它依赖 ToolRuntime 服务才能激活。每一个 cordis.yml 中的 mcp-client 条目产生一个独立实例,连接一个 MCP server。

apply 函数的生命周期分三步:

  1. 验证重连策略——resolveReconnectPolicyreconnect 配置做边界检查(initialDelayMs 必须正有限且不超过 MAX_TIMER_DELAY_MSmaxAttempts 必须正整数)。配置错误在此直接抛出,拒绝整个实例。

  2. 预留 serverName 命名空间——activeServerNames 是一个 WeakMap<Context, Set<string>>,以 ctx.root 为键。如果你在同一个 app 中加载了两个 serverName: "github" 的实例,第二个在 apply 阶段就会抛出 "serverName already in use"。命名空间预留通过 ctx.effect 注册,Fiber dispose 时自动释放。

  3. 启动连接 supervisor 并等待初始同步——startConnection 创建监督器,然后 await connection.ready。如果 failOnStartupError: true 且初始连接失败,整个 Fiber 被拒绝回滚;否则错误仅记录日志,supervisor 进入重连循环。

传输层:stdio 与 Streamable HTTP

Config 是一个判别联合类型(discriminated union),transport 字段决定走哪条路径:

  • stdio——spawn 一个子进程,通过 stdin/stdout 通信。配置包含 commandargsenv(合并在经过凭据清洗的父进程环境之上)、cwd
  • streamable-http——连接到一个 URL 端点,通过 SSE 接收响应。配置包含 urlheaders

createTransport 工厂函数负责实例化:

case 'stdio':
  return new StdioClientTransport({
    command: config.command,
    args: config.args,
    env: buildChildEnv(config.env),  // scrubbedParentEnv() + 用户指定的 env
    cwd: config.cwd,
  })
case 'streamable-http':
  return new StreamableHTTPClientTransport(
    new URL(config.url),
    { requestInit: { headers: config.headers } },
  )

注意 buildChildEnv 调用了 scrubbedParentEnv()——这是 @deepseek-ai/dsh-subprocess 提供的,会从 process.env 中删除所有看起来像凭据的变量(形如 *_TOKEN*_SECRET*_KEY)和过时的 DSH_* 变量。MCP server 子进程看到的是清洗后的环境加上你显式声明的 env 字段。

连接 supervisor:世代、重连与工具同步

startConnection 返回一个 ConnectionHandle,它拥有:

  • ready: Promise<ConnectionOutcome> —— 首次连接结果
  • dispose() —— 停止重连、关闭当前 client、等待 in-flight sync 静默、注销所有工具

supervisor 内部维护”当前世代”——一个 Client 实例。每次连接建立后,它注册 ToolListChangedNotification 处理器(这样 server 动态增减工具时会触发 re-sync),然后执行初始 syncTools

连接断开时的行为由 ReconnectConfig 决定:

参数默认值含义
enabledtrue是否自动重连
initialDelayMs500首次重连延迟,后续指数翻倍
maxDelayMs30000退避上限,也是”稳定窗口”
maxAttempts10单次 outage 内最大连续失败次数

如果连接存活时间超过 maxDelayMs,代表上一次 outage 已结束——失败计数器重置。如果连续失败超过 maxAttempts,supervisor 注销该 server 的所有工具并永久停止。唯一的恢复手段是 HMR 重载或进程重启。

关键设计:所有 syncTools 调用(初始同步 + 通知驱动的 re-sync + 重连后的 re-sync)被串行化到一条 Promise 链上(syncChain)。这保证了两次 sync 的 “dispose 旧世代 → 注册新世代” 不会交织,避免了 double-dispose 或泄露。

syncTools:两阶段原子 swap

这是 MCP 工具进入 ToolRuntime 的核心机制。两个阶段,要么整代成功注册,要么一个也不注册:

Phase 1:Fetch + 构建下一代 ToolDefinition

分页拉取 tools/list(使用 listToolsUncached 绕过 SDK 的 per-page output-validator 缓存),对每个 MCP tool 构建一个 ToolDefinition

definitions.set(publicName, {
  name: publicName,          // mcp__<serverName>__<rawName>
  description: tool.description ?? '',
  parameters: tool.inputSchema,
  output: createOutput(tool.name, supportedOutputSchema(tool.outputSchema)),
  execute: createExecutor(client, tool.name, ..., opts),
})

如果同一个 server 在 tools/list 中返回了重复的 rawName,Phase 1 直接抛出——拒绝整个同步,旧世代保持不变。

Phase 2:Swap

先 dispose 前一代(for (const dispose of previous.values()) dispose()),然后逐个调 ctx.tools.register(definition) 收集新的 disposers。如果任何一个注册抛出(比如一个外部注册占据了 mcp__<serverName>__ 命名空间),立即 rollback 已注册的,返回空 Map。

publicToolName:命名规范化与冲突防护

模型看到的工具名永远是 mcp__<serverName>__<rawName> 格式。但这个拼接必须满足 DeepSeek 函数名约束:

  • 最多 64 字符
  • 只允许 [A-Za-z0-9_-]

publicToolName 是一个确定性纯函数:

  1. 拼接 mcp__${serverName}__${rawName}
  2. 将非法字符替换为 _
  3. 如果替换或截断改变了名称,追加一个 12-hex-char 的 SHA-256 hash(identity = serverName\0rawName),确保不同的 MCP 标识永远不会映射到同一个 public name
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}`
}

重要约束:serverName 本身必须匹配 [A-Za-z0-9_-]{1,32},在 Config schema 层就验证了。raw name 只在 MCP wire protocol 上传输(tools/call 请求),永远不从 public name 反向解析。

execute 闭包:rawName 走 MCP 协议

createExecutor 为每个 MCP 工具创建一个 execute 闭包。它捕获了 client(当前世代的 MCP Client 实例)、rawName(server 自己的工具名)和 opts(包含 toolCallTimeoutMs)。

调用时:

  1. 把 model 传来的参数规范化为 Record<string, unknown>(非 object 降级为 {},让 MCP server 自己报缺参错误)
  2. 通过 callToolUncached 发送 tools/call 请求,传入 rawName(永远不传 public name)
  3. 处理返回:
    • result.content 是数组时,extractText 提取 text blocks、替换 image/audio/resource 为占位符
    • result.isError === true 时 throw,让 ToolRuntime 走统一的 isError 路径
    • 否则返回 { content, structuredContent? } 作为 canonical result

MCP 工具与内置工具共享同一执行管线

一旦注册到 ctx.tools,MCP 工具和内置工具(tool-bash、tool-fs 等)从 ToolRuntime 的视角完全等价。它们都经过:

  1. pre-execute waterfalltools/pre-execute)——allow / deny / ask
  2. Guards——注册在全局层或 scope 层的单调拒绝函数
  3. Around-dispatch waterfalltools/execute)——超时、重试、metrics 包装
  4. Tool body——对 MCP 工具就是 callToolUncached
  5. Post-execute waterfalltools/post-execute)——accept / replace / block
  6. Content finalization——definition-owned 的最终内容变换
  7. Result notificationtools/result)——冻结后广播

模型无法区分一个 MCP 工具和一个 native 工具。它们出现在同一个 schema 列表里,经过同一条 policy pipeline,支持同样的 scope restriction。

工具可见性与 Scope Restriction

ToolRuntime 的 ScopedLayers 机制决定哪些工具对哪个 agent 可见:

  • 全局层——所有 ctx.tools.register(...) 在无 scope 上下文中注册的工具
  • Scope 层——通过 agent.ctx 注册的工具,只对该 agent 可见,且会遮蔽同名全局工具
  • Restrictions——ctx.tools.restrict({ allow?, deny? }) 可以在 scope 级别过滤掉特定全局工具

MCP 工具注册在全局层(因为 mcp-clientapply 运行在无 scope 的上下文中)。如果你需要对某个 agent 隐藏特定 MCP 工具,可以通过该 agent 的 scope context 调用 tools.restrict({ deny: ['mcp__github__create_issue'] })

工具生命周期收口

cordis.yml 加载
  → mcp-client apply
    → reserveServerName (防重复)
    → startConnection
      → createTransport (stdio/streamable-http)
      → new Client → connect
      → 注册 ToolListChanged 通知
      → syncTools Phase 1: tools/list → ToolDefinitions
      → syncTools Phase 2: dispose prev → register new
      → ready 解决
    → 连接存活:模型可调用工具
    → 连接断开:
      → scheduleReconnect (指数退避)
      → 重连成功 → syncTools re-sync
      → maxAttempts 耗尽 → 注销所有工具,永久停止
    → Fiber dispose / HMR:
      → connection.dispose()
      → 停止 reconnect timer
      → 关闭 client(等 5s close timeout)
      → 等 syncChain 静默
      → dispose 所有 tool disposers
      → 释放 serverName 预留

第二部分:Repository Plugin——Agent 的自修改能力

问题背景

如果 agent 只能调用预先注册好的工具,它的能力就是静态的。但 DeepSeek Harness 提供了一种机制:agent 可以在会话中定义、运行、停止和移除动态 Cordis 插件——不需要人工编辑任何配置文件。

这就是 @deepseek-ai/dsh-tool-cordis(我们在本章称为 “Repository Plugin”)的核心能力。

tool-cordis:五个模型面向的工具

tool-cordis 是一个 Cordis 命名空间插件,inject = ['tools', 'systemPrompt', 'dynamicCordisRunner', 'cordisInspect']。它注册以下五个工具:

工具名动词作用
cordis_inspect_list列出所有 Cordis Inspect Provider(Host + Client 的服务清单)
cordis_inspect_query对特定 Provider 运行只读查询(Service methods、Event modes、Slot trees)
cordis_inspect_self查看当前会话拥有的动态 Plugin 及其 Package 源码和诊断信息
cordis_define定义一个不可变 Package(语法检查 + 录入,不执行)
cordis_run激活一个 Package(请求批准 → 沙箱执行 → 注册效果)
cordis_stop停止当前 Run(保留定义和版本指针)
cordis_undefine永久移除 Plugin 及其所有 Package

cordis_define:不可变 Package 的提交

当模型调用 cordis_define 时,它提交:

  • plugin{ kind: 'new', idPrefix } 创建新 Plugin,或 { kind: 'existing', pluginId } 在已有 Plugin 上追加版本
  • name:可读名称
  • purpose:一句话描述
  • code{ host?, client? } —— 纯 JavaScript 函数体(不是 TypeScript,不做转译)

define 只做语法验证和录入——它不执行代码、不改变 currentPackageId、不请求用户批准。返回值包含分配的 pluginIdpackageId

Defined plugin-abc-1/pkg-7 (Weather Widget); it is not running yet.
Use cordis_run to activate this Package.

每个 Package 是不可变的。要修改行为,提交一个新 Package(kind: 'existing' + 原 pluginId),然后 cordis_run 切换到新版本。旧版本永远保留,支持回滚。

cordis_run:激活与审批

cordis_run 接受 pluginIdpackageIdmode'run''update')。流程:

  1. 如果 Package 未被授权,创建一个审批请求——用户在 UI 中看到 “Plugin X 请求执行”,可以批准或拒绝
  2. 授权后,Host 半部分在 VM 沙箱中求值(受 vmTimeoutMs 约束),Client 半部分广播到浏览器页面
  3. 成功后 currentPackageId 更新;失败后旧 current 和目标 next 保持不变

返回值报告状态(awaiting-approval / starting / running)和运行时信息(Host provides/waits-for、Client status)。

自修改的边界

Repository Plugin 的设计有明确的安全边界:

  1. Session 作用域——动态 Plugin 只在定义它的 session 中可见和可控。其他 session 看不到也碰不到。

  2. 进程生命周期——动态 Plugin 活在 DSH 进程内存中。进程重启后全部消失。不创建 Plugin 文件、不修改 cordis.yml、不改变持久化配置。

  3. 沙箱不是安全边界——VM 沙箱隔离了 Node globals,把 ctx.fsctx.webctx.bash 等重定向到 Cordis 服务。但 host-realm helpers 仍然可达——把这个能力等同于 bash access。

  4. @pluginId 引用注入——用户消息中出现 @abc-1 这样的引用时,tool-cordisagent/pre-step 钩子会自动注入该 Plugin 的上下文(Package 源码、版本状态、操作指引),让模型在下一步就能 inspect + define + run 修改版本。

// pre-step 钩子:检测 @pluginId 引用,注入上下文
ctx.on('agent/pre-step', async ({ agent, messages, signal }, next) => {
  const decision = await next()
  const ids = referencedPluginIds(messages)
  if (ids.length === 0) return decision
  // 为每个引用创建一条 instructions 消息
  const contexts = ids.map((id) => {
    const reference = ctx.dynamicCordisRunner.reference(agent, CordisDynamicPluginId(id))
    return createUserMessage({ content: [...], source: { kind: 'plugin', plugin: name, form: 'instructions' } })
  })
  return { kind: 'enter', messages: [...decision.messages, ...contexts] }
})

cordis_inspect_self:读取自己的动态 Plugin

cordis_inspect_self 支持三个粒度:

  • 不传参数:列出当前 session 所有 Plugin 的摘要(id、name、state、packageCount)
  • 只传 pluginId:返回版本指针、最新 Run、所有 Package 摘要
  • pluginId + packageId:返回该不可变 Package 的完整源码和运行时诊断

这让模型能够:检视当前运行版本的源码 → 发现问题 → define 新版本 → run 切换。一个完整的自修改循环。

cordis_stop 与 cordis_undefine

  • cordis_stop:停止当前 Run,取消未完成的审批请求。保留 Plugin、所有 Package、currentPackageId——后续可以直接 cordis_run 恢复。
  • cordis_undefine:永久删除——先 stop(如果在运行),然后删除所有 Package、grant、版本指针。pluginId 失效,@ 引用失效。

第三部分:两套机制的交汇

MCP 工具注册和动态 Plugin 都通过同一个入口进入 ToolRuntime:ctx.tools.register(definition)

维度MCP 工具动态 Plugin 注册的工具
注册方式syncTools Phase 2 批量注册Plugin apply 中调 ctx.tools.register()
命名空间mcp__<serverName>__<rawName>插件自行命名
生命周期绑定连接世代;断连/maxAttempts/dispose 注销绑定 Plugin Run;stop/undefine/进程重启注销
执行管线完全相同的 pre/around/post waterfall完全相同
Scope 可见性全局层(所有 agent 可见,除非被 restrict)取决于注册时的 context scope
持久性只要连接活着就存在只在进程内存中

模型看到的工具列表是这两类来源的并集,经过 ScopedLayers 过滤和 ToolRestriction 遮罩后呈现。模型无法也不需要区分一个工具的来源——它们走同一条执行管线,受同一套 policy 约束。

ToolRuntime 的注册机制

ToolRuntime.register() 的核心逻辑:

  1. 验证 output 声明(必须有 schema + render
  2. assertSupportedJsonSchema(output.schema) 验证输出 schema
  3. 拒绝保留名称 run_code
  4. 通过 this.layers.effect(ctx, layer => layer.tools.insert(name, definition)) 插入到 ScopedLayers
  5. 返回 disposer(调用时注销该工具)

注册成功后,ToolRuntime 发出 tools/change 事件,触发所有依赖工具列表的组件(system prompt 重新组装、UI 更新等)。

Code Mode 下的工具可见性

如果部署配置了 mode: 'code'mode: 'both',模型不直接调用工具名——它只能调用 run_code,在程序内通过生成的 SDK 间接调用其他工具。但 MCP 工具和内置工具同样出现在生成的 SDK 中(TypeScript 或 Python),无差别。

mode: 'code' 下的 collapse 逻辑:

  • Model-direct call(无 parent token)只能叫 run_code
  • SDK sub-dispatch(有 parent token)可以调用任何可见工具
  • 违反 collapse 的调用收到一个带路由提示的 UNKNOWN_TOOL 错误

第四部分:故障模式清单

故障表现根因
serverName 重复apply 阶段 throw,Fiber 拒绝activeServerNames 检测到冲突
MCP server 无法启动failOnStartupError=true 时 Fiber 拒绝;否则进入 reconnect 循环子进程 spawn 失败或连接超时
tools/list 返回重复 rawNamePhase 1 throw,旧世代保持server 实现 bug
注册时命名空间冲突Phase 2 rollback,0 工具注册,错误日志另一个注册占据了 mcp__<serverName>__ 前缀
连接断开工具仍注册但调用会 timeoutsupervisor 进入 reconnect
maxAttempts 耗尽工具被注销,调用返回 UNKNOWN_TOOL连续失败过多
动态 Plugin VM 超时run 失败,currentPackageId 不变host 半部分代码执行超过 vmTimeoutMs
用户拒绝审批run 返回 rejected安全策略,不可绕过

第五部分:实验验证路径

如果你要验证本章描述的机制:

  1. 追踪 publicToolName 的 hash 逻辑——构造一个超过 64 字符的 serverName + rawName 组合,验证返回值包含 12-hex hash 后缀

  2. 观察 syncTools 的两阶段 swap——在测试中 mock 一个返回两个工具的 MCP server,然后让它发送 ToolListChanged 通知(减少一个工具),验证 ToolRuntime 中的注册数量从 2 变为 1

  3. 验证 reconnect 退避——配置一个总是失败的 MCP server,观察日志中的延迟从 500ms 翻倍到 30000ms,最终看到 “giving up” 消息

  4. 验证 MCP 工具走标准 pipeline——注册一个 tools/pre-execute 监听器 deny 特定 MCP 工具名,验证该工具调用返回 deny 原因而非执行

  5. 动态 Plugin 自修改循环——调用 cordis_definecordis_runcordis_inspect_self 读取源码 → cordis_define(existing, 新版本)→ cordis_run(update),验证 currentPackageId 变化

  6. scope restriction 对 MCP 工具的过滤——在 agent scope 中 tools.restrict({ deny: ['mcp__srv__some_tool'] }),验证该 agent 的 tool schema 列表不包含该工具


设计决策回顾

为什么 public name 不可逆——MCP tool 的 wire identity 是 (serverName, rawName)。public name 是一个不可逆映射(可能截断+hash)。这意味着:如果你重命名 serverName,所有工具名都变了——模型需要重新学习。这是 intentional:serverName 是你对模型的承诺,不应随意变更。

为什么 syncTools 是全量 swap 而非增量 diff——MCP 协议的 tools/list 返回完整列表,没有增量 API。全量 swap 保证了原子性(要么新世代全部成功,要么旧世代不变),代价是每次 re-sync 都要重新注册所有工具。对于典型的 MCP server(十几个工具),这个代价可以忽略。

为什么连接 supervisor 用”outage budget”而非简单计数器——一个 crash-looping server 可能短暂地连接成功然后再次崩溃。如果每次短暂成功都重置计数器,它就能永远重启。supervisor 用”稳定窗口”(连接存活超过 maxDelayMs)来判断 outage 是否真正结束。只有稳定窗口过后的下次断连才开始新的 budget。

为什么 syncChain 串行化所有 sync 操作——两个并发的 syncTools 调用(比如一个重连 sync 和一个通知 sync 同时到达)如果交织执行,Phase 2 的 “dispose previous → register new” 可能导致一次 sync dispose 了另一次 sync 刚注册的工具。串行化消除了这个竞态,代价是通知可能被延迟到重连 sync 完成后——但这是安全的,因为通知 re-sync 会拉到最新的工具列表。

为什么动态 Plugin 不持久化——这是 intentional design:动态 Plugin 是”实验台”,不是生产部署路径。如果你要保留一个有用的 Plugin,正确做法是让 agent 通过正常开发流程(创建文件、编辑 cordis.yml)将其固化为正式 Plugin。进程内存的生命周期是一个天然的 “session sandbox”。

为什么 MCP 工具和内置工具共享 pipeline——统一性。如果 MCP 工具走独立路径,所有 policy(approval、guard、timeout、metrics)都需要重复实现。共享 pipeline 意味着你只需要在一个地方实现 policy,它就自动覆盖所有工具来源。

为什么 serverName 预留用 WeakMap 而非全局 Set——测试场景中一个进程可能运行多个独立的 Cordis app(不同的 ctx.root)。WeakMap 以 root 为键,确保多个 app 的命名空间互不干扰,且 app 被 GC 后自动释放对应的 Set。

为什么 cordis_define 和 cordis_run 分离——Define 只做语法验证和录入,Run 才执行。这让模型和用户各自保持控制权:模型可以自由草拟代码(define),但实际执行需要用户审批(run 的 approval 流程)。如果 define 直接执行,就没有审批介入点了。分离也让回滚变得自然——你不需要”撤销执行”,只需要 run 一个旧 Package。


收口

MCP 工具进入运行时的路径很短,但每一步都很硬:cordis.yml 声明 → mcp-client 插件实例化 → 传输层连接 → syncTools 两阶段原子 swap → ctx.tools.register。走完这条线之后,MCP 工具和内置工具在 ToolRuntime 里就没有等级差了。模型看到统一的 schema 列表,所有工具也走同一条执行管线。

Repository Plugin(tool-cordis)处理的是更高一层的自修改:define → run → inspect → redefine → update。但这个能力被关在 session 作用域和进程生命周期里,还要受用户审批约束。两套机制最后都落到 ctx.tools.register():工具从哪里来不重要,进了 ToolRuntime 之后就是同一类公民。