本文是 DeepSeek Harness 系列博文的第三篇。面向已有 Agent 框架开发经验的读者,深入源码分析 Harness 如何用统一的插件抽象消解 MCP、Skill 等独立概念。
问题:协议碎片化带来的工程复杂度
主流 Agent 框架的一个结构性问题是:每引入一种外部协议,就引入一套独立的生命周期管理。
以 MCP 为例,Claude Code 的处理方式是:
- 独立的
mcp.json配置文件 - 独立的连接状态机(connecting → connected → reconnecting → failed)
- 独立的 tool 注册/注销路径
- 独立的错误处理和重试逻辑
再加上 Skill/Rules、LLM Adapter、File System 各自一套,框架内部变成了多套并行的生命周期管理器,行为模式各异。
DeepSeek Harness 的解法是:让所有能力走同一条注册路径,复用同一个生命周期引擎------Cordis。
Cordis 的核心机制:Effect-scoped Registration
在进入 MCP/Skill 的具体实现之前,先理解 Cordis 提供的基础设施。
Cordis 的注册模型只有一个规则:通过 ctx 发起的任何注册,在该 ctx 对应的插件 fiber 卸载时自动撤销。
typescript
export function apply(ctx: Context) {
// 这个 register 调用返回一个 disposer
// 但你不需要手动管理它------Cordis 在插件卸载时自动调用
ctx.tools.register(defineTool({ /* ... */ }))
// 对于非 Cordis-native 的资源,用 ctx.effect() 显式绑定
ctx.effect(() => {
const connection = connectExternal()
return () => connection.close() // 卸载时执行
})
}
服务依赖通过 inject 声明:
typescript
export const inject = ['tools']
// Cordis 保证:在 apply 执行时 ctx.tools 已就绪
// 如果 ctx.tools 的 provider 被热替换,本插件自动 dispose 并重新 apply
这两个机制组合起来,意味着任何外部协议的集成只需要做一件事:把协议的资源映射到 ctx 上的注册调用。
MCP Bridge 源码分析:一个 170 行的 apply
@deepseek-ai/dsh-mcp-client 的入口(packages/mcp/mcp-client/src/index.ts)是一个标准的 Cordis 函数插件:
typescript
export const name = 'mcp-client'
export const inject = ['tools']
export async function apply(ctx: Context, config: Config): Promise<void> {
// 1. 校验 reconnect 配置
const reconnect = resolveReconnectPolicy(config.reconnect, ...)
// 2. 抢占 serverName 命名空间(effect-scoped,卸载自动释放)
ctx.effect(() => {
names.add(config.serverName)
return () => void names.delete(config.serverName)
}, 'mcp-client.serverName')
// 3. 启动连接 supervisor
const connection = startConnection(ctx, config, reconnect)
// 4. 绑定连接到 fiber 生命周期
ctx.effect(() => {
return () => connection.dispose()
}, 'mcp-client.connection')
// 5. 阻塞 fiber 激活直到首次 tool 发现完成
const outcome = await connection.ready
if (outcome.error && config.failOnStartupError) throw ...
}
关键设计决策:
serverName 命名空间是 effect-scoped 的。 通过 ctx.effect() 注册,如果两个实例用了相同的 serverName,后加载的那个在 apply 阶段就会 throw------不是运行时静默覆盖。而当插件被 HMR 热替换时,旧的 namespace reservation 自动释放,新实例可以用相同的名字。
连接 supervisor 是 Cordis-oblivious 的。 startConnection() 返回一个普通对象 { ready, dispose() }------它不知道 Cordis 的存在。Cordis 集成只发生在 ctx.effect(() => () => connection.dispose()) 这一行------把 supervisor 的生命周期绑到 fiber 上。
Tool 注册:从 MCP 协议到 ctx.tools
tools.ts 中的 syncTools 函数执行两阶段切换:
typescript
export async function syncTools(
client: Client,
ctx: Context,
opts: ToolBridgeOptions,
previous: ToolDisposers, // 上一代 tool 的 disposer 集合
): Promise<ToolDisposers> {
// Phase 1: Fetch --- 分页遍历 MCP 的 tools/list,构建 ToolDefinition
const definitions = new Map<string, ToolDefinition>()
let cursor: string | undefined
do {
const response = await listToolsUncached(client, cursor)
for (const tool of response.tools) {
const publicName = publicToolName(opts.serverName, tool.name)
definitions.set(publicName, {
name: publicName,
description: tool.description ?? '',
parameters: tool.inputSchema,
output: createOutput(tool.name, supportedOutputSchema(tool.outputSchema)),
execute: createExecutor(client, tool.name, ...),
})
}
cursor = response.nextCursor
} while (cursor)
// Phase 2: Swap --- 原子替换 tool 注册
for (const dispose of previous.values()) dispose()
const disposers: ToolDisposers = new Map()
try {
for (const [publicName, definition] of definitions) {
disposers.set(publicName, ctx.tools.register(definition))
}
} catch (error) {
// 冲突回滚:全部注销,不留 partial set
for (const dispose of disposers.values()) dispose()
return new Map()
}
return disposers
}
这段代码的核心洞察:MCP tool 注册和原生 tool 注册走完全相同的 ctx.tools.register() 路径。 这意味着:
- 模型看到的 tool schema 格式完全一致
tools/pre-execute→tools/execute→tools/post-execute事件管道完全一致- 权限策略、timeout、compaction 行为完全一致
- 不存在"MCP tool 是特殊的"这种概念
命名规范化:确定性函数
公共名 mcp__<serverName>__<rawName> 的生成是纯函数:
typescript
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
// 有损规范化时,附加 12 字符 SHA-256 hash 防碰撞
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}`
}
不依赖连接顺序、不依赖其他 server 的存在------相同的 (serverName, rawName) 输入永远产生相同的公共名。
Reconnect Supervisor:有限状态机
连接管理实现了一个标准的断线重连状态机,核心语义:
scss
connecting → connected → (transport close) → backoff → connecting → ...
└→ (budget exhausted) → disabled
关键参数:
initialDelayMs:首次重连延迟(默认 500ms),指数递增maxDelayMs:延迟上限(默认 30s),同时作为"稳定连接"的判定阈值maxAttempts:连续失败次数预算(默认 10)
一个连接存活超过 maxDelayMs 会重置预算------这意味着偶尔崩溃的 server 可以无限恢复,但 crash-loop 会被正确终止。
整个 reconnect 逻辑是通用的连接管理------和 MCP 协议本身无关。 如果未来出现另一种需要 stdio/HTTP 连接的协议,这段逻辑可以被提取复用。
Skill 系统源码分析:Scoped Layered Registry
Skill 的架构比 MCP bridge 更复杂,因为它不只是桥接一个外部协议,而是一个完整的 Capability Seam。
Service Definition:SkillRegistry
typescript
declare module '@deepseek-ai/cordis' {
interface Context {
skills: SkillRegistry
}
interface Events {
'skills/change'(): void
}
}
export class SkillRegistry extends Service {
// 分层注册表:全局层 + 每个 scope(agent preset)一层
private readonly layers = new ScopedLayers<SkillLayer>(...)
registerProvider(create: (control: SkillProviderControl) => SkillProvider): () => void
register(skill: SkillRegistration): () => void
async list(options: SkillViewOptions = {}): Promise<SkillSummary[]>
async snapshot(options: SkillViewOptions = {}): Promise<SkillCatalogSnapshot>
async get(name: string, options: SkillViewOptions = {}): Promise<SkillDefinition | undefined>
}
核心数据结构是 ScopedLayers<SkillLayer>------和 ctx.tools 使用同一种分层模型。一个注册根据调用者的 scope 落入对应的层:
- 宿主行和仓库插件 → 全局层
- Agent preset 的 standing composition → 该 preset 的 scope 层
- 读取时,全局层 + 查看者 scope 链逐层合并,近层同名 entry 直接覆盖远层
Provider 接口
typescript
export interface SkillProvider {
readonly name: string
readonly list: (options: SkillLookupOptions) =>
Promise<readonly SkillCandidate[] | SkillProviderObservation>
readonly get: (candidate: SkillCandidate, options: SkillLookupOptions) =>
Promise<SkillDefinition | undefined>
}
export interface SkillProviderControl {
readonly signal: AbortSignal // 注册被销毁时 abort
readonly invalidate: () => void // 通知 registry 清除缓存
}
Provider 注册时拿到一个 SkillProviderControl:
signal在精确的这个注册被 dispose 时 abort------provider 可以用它取消进行中的发现工作invalidate()通知 registry 刷新缓存------只在注册仍然存活时生效
这个设计让 provider 是无状态可替换的:一个文件系统 provider、一个 Redis provider、一个 HTTP registry provider 都实现同一个接口。
Consumer:dsh-tool-skill
Consumer 做的事:
- 在每个
agent/pre-step注入初始 skill catalog(name + description 列表) - 每步之前检查 catalog 是否变化,变了就注入更新
- 提供
skill({ name })tool 给模型主动加载完整 skill body
Skill body 是按需加载 的------catalog 只暴露 name 和 description(控制 token 开销),模型决定是否调用 skill tool 获取完整内容。
运行时注册
除了 provider 发现的 skill,任何插件也可以直接注册 runtime skill:
typescript
export const inject = ['skills']
export function apply(ctx: Context) {
ctx.skills.register({
name: 'my-runtime-skill',
description: 'Dynamic skill contributed by a plugin',
source: 'runtime',
content: '# Instructions\n...',
})
// 插件卸载时自动从 registry 移除
}
和 provider 发现的 skill 在同一个 registry 中合并------优先级由 rank 决定(runtime rank = 250,介于 project 和 user 之间)。
对比:三种能力的注册路径
| MCP Tool | 原生 Tool | Skill | |
|---|---|---|---|
| 注册目标 | ctx.tools.register() |
ctx.tools.register() |
ctx.skills.register() 或 registerProvider() |
| 生命周期 | fiber dispose → syncTools 回滚 |
fiber dispose → auto | fiber dispose → auto |
| 配置入口 | cordis.yml 行 | cordis.yml 行 | cordis.yml 行 |
| 隔离机制 | isolate: { tools: true } |
isolate: { tools: true } |
scope chain |
| HMR | dispose old → re-apply → re-sync | dispose old → re-apply | dispose old → re-apply → re-discover |
| 模型可见性 | tool schema in prompt | tool schema in prompt | catalog message + skill tool |
三者共享的关键路径:
- 插件加载 :Cordis fiber 激活 →
apply()执行 - 注册 :调用
ctx.<service>.register()→ 返回 disposer - 依赖等待 :
inject声明 → 框架保证服务就绪 - 卸载清理:fiber dispose → 所有 effect 逆序执行
- 热替换:配置变更 → dispose old fiber → create new fiber → re-apply
Capability Seam 模式:以 Shell 为参照
MCP 和 Skill 都不是最完整的 Capability Seam 示例。最经典的是 Shell(Bash 执行):
scss
dsh-shell (Service Definition)
├── dsh-bash-local (Provider: 本机执行)
├── dsh-bash-e2b (Provider: E2B 沙箱执行)
└── dsh-tool-bash (Consumer: 模型可见 tool)
在 cordis.yml 中切换执行环境:
yaml
# 本地执行
- name: '@deepseek-ai/dsh-bash-local'
# 换成 E2B 沙箱------只改这一行
# - name: '@deepseek-ai/dsh-bash-e2b'
# config:
# sandboxId: '...'
Consumer (dsh-tool-bash) 完全不变。它只 inject: ['shell'],不知道也不关心后面是本机进程还是远程沙箱。
MCP 在这个框架中的位置:它是 ctx.tools 的一种 Provider,和 defineTool 手写的 tool、和 Skill tool consumer 注册的 tool 共享同一个 Service Definition。
Skill 的位置:ctx.skills 是独立的 Service Definition,有自己的 Provider(filesystem/runtime/badge),Consumer 通过 ctx.tools.register() 暴露 skill tool 给模型。
实际影响:开发一个自定义 Skill Provider
假设你要从公司内部 Git 仓库发现 skill,实现如下:
typescript
import type { Context } from '@deepseek-ai/cordis'
import type { SkillProvider, SkillProviderControl } from '@deepseek-ai/dsh-skill'
export const name = 'skill-git-remote'
export const inject = ['skills']
export function apply(ctx: Context) {
ctx.skills.registerProvider((control: SkillProviderControl) => ({
name: 'git-remote',
async list(options) {
// control.signal 在注册被 dispose 时 abort
const response = await fetch('https://internal.git/api/skills', {
signal: options.signal ?? control.signal,
})
const skills = await response.json()
return skills.map(s => ({
name: s.name,
description: s.description,
invocation: { modelInvocable: true, userInvocable: true },
source: 'custom' as const,
provider: 'git-remote',
rank: 350, // 介于 custom dirs(300) 和 user-dsh(400) 之间
locator: s.url, // 不透明句柄,get() 时回传
}))
},
async get(candidate, options) {
const response = await fetch(candidate.locator as string, {
signal: options.signal ?? control.signal,
})
return {
...candidate,
content: await response.text(),
}
},
}))
}
cordis.yml 中挂载:
yaml
- id: skill-git-remote
name: './plugins/skill-git-remote.ts'
完成。不需要修改框架代码,不需要了解 skill catalog 的渲染逻辑,不需要处理 HMR------registerProvider 的 effect-scoped 语义保证了热替换时旧 provider 自动注销、缓存自动失效。
总结:统一抽象的工程价值
Harness 的做法不是"弱化"MCP 和 Skill------MCP bridge 有完整的 reconnect 状态机和两阶段原子 swap,Skill registry 有分层合并和增量 catalog 推送------功能一点不少。
区别在于,这些复杂性被封装在各自的插件内部 ,对外只暴露统一的 ctx.tools.register() 或 ctx.skills.registerProvider()。框架层面不存在"MCP 连接管理器"和"Skill 发现引擎"这种并行的基础设施------有的只是 Cordis fiber 的 effect-scoped lifecycle。
工程价值:
- 一套 HMR 规则覆盖所有子系统 ------改
cordis.yml配置,对应 fiber 热替换 - 一套隔离机制覆盖所有服务 ------
isolate: { tools: true }同时隔离原生 tool 和 MCP tool - 一套错误传播语义------inject 的服务消失 → 依赖它的 fiber 自动 dispose
- 一套测试基础设施 ------mock
ctx.tools就能测所有 tool 注册者,不区分来源
这是 Cordis 作为 plugin orchestration 框架的核心卖点:把"连接管理"、"发现机制"、"注册/注销"这些每个子系统都要做的事情收敛到一个统一的 effect 模型中。
参考
DeepSeek Harness 系列文章:
- 第一篇:DeepSeek Harness 框架介绍
- 第二篇:插件开发实战
- 第三篇:MCP 和 Skill 如何被统一为 Cordis 插件(本文)