MCP 工具注册与 Repository Plugin
外部 MCP server 的工具如何进入 DSH 运行时的 tool registry,以及 Repository Plugin (tool-cordis) 如何让 agent 自行修改运行时配置。MCP 桥接插件管理连接、命名空间隔离、工具同步;Repository Plugin 提供 define/run/stop/undefine 四个操作让 agent 增删 MCP server。
问题:外部 MCP 工具如何进入运行时工具注册表
你在 cordis.yml 里声明了一个 MCP server 条目——也许是一个 GitHub 工具服务、也许是一个数据库查询代理。你期望模型马上就能调用它暴露的工具。但你不清楚:工具名是怎么从 MCP server 的声明变成模型可见的函数调用的?注册表里的 MCP 工具和内置的 tool-bash、tool-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 函数的生命周期分三步:
-
验证重连策略——
resolveReconnectPolicy对reconnect配置做边界检查(initialDelayMs必须正有限且不超过MAX_TIMER_DELAY_MS,maxAttempts必须正整数)。配置错误在此直接抛出,拒绝整个实例。 -
预留 serverName 命名空间——
activeServerNames是一个WeakMap<Context, Set<string>>,以ctx.root为键。如果你在同一个 app 中加载了两个serverName: "github"的实例,第二个在apply阶段就会抛出"serverName already in use"。命名空间预留通过ctx.effect注册,Fiber dispose 时自动释放。 -
启动连接 supervisor 并等待初始同步——
startConnection创建监督器,然后await connection.ready。如果failOnStartupError: true且初始连接失败,整个 Fiber 被拒绝回滚;否则错误仅记录日志,supervisor 进入重连循环。
传输层:stdio 与 Streamable HTTP
Config 是一个判别联合类型(discriminated union),transport 字段决定走哪条路径:
- stdio——spawn 一个子进程,通过 stdin/stdout 通信。配置包含
command、args、env(合并在经过凭据清洗的父进程环境之上)、cwd。 - streamable-http——连接到一个 URL 端点,通过 SSE 接收响应。配置包含
url和headers。
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 决定:
| 参数 | 默认值 | 含义 |
|---|---|---|
enabled | true | 是否自动重连 |
initialDelayMs | 500 | 首次重连延迟,后续指数翻倍 |
maxDelayMs | 30000 | 退避上限,也是”稳定窗口” |
maxAttempts | 10 | 单次 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 是一个确定性纯函数:
- 拼接
mcp__${serverName}__${rawName} - 将非法字符替换为
_ - 如果替换或截断改变了名称,追加一个 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)。
调用时:
- 把 model 传来的参数规范化为
Record<string, unknown>(非 object 降级为{},让 MCP server 自己报缺参错误) - 通过
callToolUncached发送tools/call请求,传入rawName(永远不传 public name) - 处理返回:
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 的视角完全等价。它们都经过:
- pre-execute waterfall(
tools/pre-execute)——allow / deny / ask - Guards——注册在全局层或 scope 层的单调拒绝函数
- Around-dispatch waterfall(
tools/execute)——超时、重试、metrics 包装 - Tool body——对 MCP 工具就是
callToolUncached - Post-execute waterfall(
tools/post-execute)——accept / replace / block - Content finalization——definition-owned 的最终内容变换
- Result notification(
tools/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-client 的 apply 运行在无 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、不请求用户批准。返回值包含分配的 pluginId 和 packageId。
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 接受 pluginId、packageId 和 mode('run' 或 'update')。流程:
- 如果 Package 未被授权,创建一个审批请求——用户在 UI 中看到 “Plugin X 请求执行”,可以批准或拒绝
- 授权后,Host 半部分在 VM 沙箱中求值(受
vmTimeoutMs约束),Client 半部分广播到浏览器页面 - 成功后
currentPackageId更新;失败后旧 current 和目标 next 保持不变
返回值报告状态(awaiting-approval / starting / running)和运行时信息(Host provides/waits-for、Client status)。
自修改的边界
Repository Plugin 的设计有明确的安全边界:
-
Session 作用域——动态 Plugin 只在定义它的 session 中可见和可控。其他 session 看不到也碰不到。
-
进程生命周期——动态 Plugin 活在 DSH 进程内存中。进程重启后全部消失。不创建 Plugin 文件、不修改
cordis.yml、不改变持久化配置。 -
沙箱不是安全边界——VM 沙箱隔离了 Node globals,把
ctx.fs、ctx.web、ctx.bash等重定向到 Cordis 服务。但 host-realm helpers 仍然可达——把这个能力等同于 bash access。 -
@pluginId引用注入——用户消息中出现@abc-1这样的引用时,tool-cordis的agent/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() 的核心逻辑:
- 验证
output声明(必须有schema+render) assertSupportedJsonSchema(output.schema)验证输出 schema- 拒绝保留名称
run_code - 通过
this.layers.effect(ctx, layer => layer.tools.insert(name, definition))插入到 ScopedLayers - 返回 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(无
parenttoken)只能叫run_code - SDK sub-dispatch(有
parenttoken)可以调用任何可见工具 - 违反 collapse 的调用收到一个带路由提示的
UNKNOWN_TOOL错误
第四部分:故障模式清单
| 故障 | 表现 | 根因 |
|---|---|---|
| serverName 重复 | apply 阶段 throw,Fiber 拒绝 | activeServerNames 检测到冲突 |
| MCP server 无法启动 | failOnStartupError=true 时 Fiber 拒绝;否则进入 reconnect 循环 | 子进程 spawn 失败或连接超时 |
| tools/list 返回重复 rawName | Phase 1 throw,旧世代保持 | server 实现 bug |
| 注册时命名空间冲突 | Phase 2 rollback,0 工具注册,错误日志 | 另一个注册占据了 mcp__<serverName>__ 前缀 |
| 连接断开 | 工具仍注册但调用会 timeout | supervisor 进入 reconnect |
| maxAttempts 耗尽 | 工具被注销,调用返回 UNKNOWN_TOOL | 连续失败过多 |
| 动态 Plugin VM 超时 | run 失败,currentPackageId 不变 | host 半部分代码执行超过 vmTimeoutMs |
| 用户拒绝审批 | run 返回 rejected | 安全策略,不可绕过 |
第五部分:实验验证路径
如果你要验证本章描述的机制:
-
追踪 publicToolName 的 hash 逻辑——构造一个超过 64 字符的
serverName + rawName组合,验证返回值包含 12-hex hash 后缀 -
观察 syncTools 的两阶段 swap——在测试中 mock 一个返回两个工具的 MCP server,然后让它发送
ToolListChanged通知(减少一个工具),验证 ToolRuntime 中的注册数量从 2 变为 1 -
验证 reconnect 退避——配置一个总是失败的 MCP server,观察日志中的延迟从 500ms 翻倍到 30000ms,最终看到 “giving up” 消息
-
验证 MCP 工具走标准 pipeline——注册一个
tools/pre-execute监听器 deny 特定 MCP 工具名,验证该工具调用返回 deny 原因而非执行 -
动态 Plugin 自修改循环——调用
cordis_define→cordis_run→cordis_inspect_self读取源码 →cordis_define(existing, 新版本)→cordis_run(update),验证currentPackageId变化 -
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 之后就是同一类公民。