Reactive Resume 的 DeepSeek Harness 插件设计:把 33 个简历管理 MCP 工具接入 Agent 会话
本文基于 Reactive Resume 仓库中的设计文档 DeepSeek Harness Plugin for Reactive Resume — Design 展开,完整还原该插件从目标定义、架构决策到最终落地的全过程:如何把 Reactive Resume 远程 MCP 服务器(33 个工具、3 个 prompt、2 个 resource)通过一条 cordis.patch.yml 配置行桥接进 DeepSeek Harness 会话,并额外贡献一段针对"模型经常写错的 JSON Patch 语义"的系统提示词段落;文末还会结合 spike 报告 与 实际源码 讲清 ctx.tools.restrict() 为何被砍、漂移检测方案如何从"定时抓取 server card"演进为 monorepo 内的契约测试。读完后你可以掌握:Harness 插件的 apply(ctx, config) 生命周期、MCP Streamable HTTP 桥接配置、Cordis 作用域(scope)语义对工具可见性的影响,以及一套"设计 → spike 验证 → 按证据裁剪"的插件开发方法论。
1. 目标:一条配置行 + 一个 API Key
设计文档的目标只有一句话:发布一个 DeepSeek Harness 插件,把 Harness 连接到 Reactive Resume 账户,让用户在自己的 agent 会话中读取、创建和编辑简历与求职申请,前提只是一行配置和一个 API Key。
这不是要再造一套简历 API,而是把已有的远程控制面"搬"进 Agent 上下文。文档给出的差距分析(The gap this plugin fills)非常具体:
dsh-mcp-client本身没有工具过滤能力,也无法贡献提示词文本;- 如果用户直接把一条原始 MCP 配置写进
cordis.yml,33 个工具的完整 schema 会全部挤占模型的 context budget,而且没有任何关于 Reactive Resume JSON Patch 语义的引导——而这类语义恰恰是模型最容易犯错的地方。
这两点就是该插件存在的全部理由:薄桥接 + 提示词段落(prompt section)。
2. 前置能力盘点:Reactive Resume 已提供什么
设计文档强调"已对发布包做了验证,而不仅仅依赖文档"。Reactive Resume 仓库中已具备完整的远程 MCP 服务器,插件无需改动服务器端任何东西:
- 33 个工具的注册。
packages/mcp注册了list_resumes、read_resume、apply_resume_patch、tailor_resume_for_application等 33 个工具,外加 3 个 prompts 和 2 个 resources。工具名的权威清单集中在 MCP_TOOL_NAME 常量表:resume 组 13 个(list_resumes、read_resume、download_resume_pdf、create_resume、import_resume、duplicate_resume、apply_resume_patch、update_resume、delete_resume、lock_resume、unlock_resume、get_resume_statistics、list_resume_tags),applications 组 20 个(list_applications、create_application、bulk_update_applications、autofill_application_from_job、score_application_match、draft_application_message等)。 - 端点挂载。
apps/server/src/http/app.ts挂载了/mcp与/mcp/*(Streamable HTTP 传输),以及/.well-known/mcp/server-card.json(SEP-1649 server card)。 - 双路鉴权。
apps/server/src/mcp/auth.ts同时接受 OAuth 的Authorization: Bearertoken 或x-api-keyheader。插件选择 API key 路径,因为它不需要任何交互式流程。 - API key 管理已内建。Web 应用在
/dashboard/settings/api-keys提供 key 的创建与管理,用户开通(provisioning)是已解决问题。
DeepSeek Harness 侧提供的能力(同样经过对发布包的实际核验):
- 插件是一个 TypeScript 模块,导出
name、可选的inject和apply(ctx, config);配置 schema 使用@deepseek-ai/schemastery; @deepseek-ai/dsh-mcp-client每个插件实例桥接一个外部 MCP 服务器,其StreamableHttpConfig的完整形状为:
interface StreamableHttpConfig {
transport: 'streamable-http'
serverName: string // [A-Za-z0-9_-]{1,32},且在存活的实例间唯一
url: string
headers: Record<string, string>
toolCallTimeoutMs: number
failOnStartupError: boolean
}
被桥接的工具对模型呈现为 mcp__<serverName>__<rawName>;
ctx.tools是ToolRegistry,暴露restrict(filter: ToolRestriction): () => void,其中ToolRestriction为{ allow?: readonly string[]; deny?: readonly string[] };ctx.systemPrompt(来自@deepseek-ai/dsh-system-prompt)暴露section(section: PromptSection): () => void,PromptSection为{ name, order, text, complete? }。order 约定:-100是 harness 身份,0是部署人格,100–199是工具引导;- 插件通过 npm 分发,经 GitHub 上的
dsh-plugintopic 被发现。
3. 四个关键设计决策
设计文档用一个决策表锁定了方案边界:
| 决策 | 选择 | 理由 |
|---|---|---|
| 范围 | 薄 MCP 桥接 + 提示词段落 | 工具层已存在且由 Reactive Resume 维护。对 oRPC 重新实现 33 个工具契约,会让每个版本都产生漂移。 |
| 仓库 | (原设计)独立仓库,放在 monorepo 之外 | 插件不 import Reactive Resume 的任何东西——它只说 HTTP。而 monorepo 当时没有 npm 发布管线:根包和每个包都是 private: true,没有构建产物、没有 changesets、没有 NPM_TOKEN、没有发布 workflow。为发一个零依赖的包去搭一条管线,是无收益的成本。 |
| 鉴权 | 仅 x-api-key |
在 Reactive Resume 设置里点两下即可。OAuth 需要一个 token 存储和交互式流程,没有收益。 |
| 漂移防护 | CI 中对 server card 做契约测试 | 替代"同在 monorepo 内"本应提供的 lockstep 保障,而不需要发布管线。 |
后续演变(重要):决策表中"独立仓库"一项后来被推翻——插件最终落进了 monorepo,就放在它桥接的 MCP 服务器旁边(
packages/dsh-plugin),与packages/mcp相邻。实际 package.json 也印证了这一点:publishConfig.access为public、prepublishOnly触发构建,且devDependencies中以workspace:*直接依赖@reactive-resume/mcp。漂移检测因此不再需要网络抓取,改为直接读 workspace 内的工具名表(见第 7 节)。
4. 包形态与配置面
4.1 包形态(package shape)
设计文档给出的模块导出契约,镜像 dsh-mcp-client 自身的导出形式:
export const name = 'reactive-resume'
export const inject = ['tools', 'systemPrompt']
export const Config: z<Config>
export async function apply(ctx: Context, config: Config): Promise<void>
@deepseek-ai/cordis、@deepseek-ai/dsh-tools、@deepseek-ai/dsh-system-prompt 与 @deepseek-ai/dsh-mcp-client 全部声明为 peerDependencies,让插件绑定宿主已安装的运行时版本,而不是装入第二份副本。实际 package.json 中的 peer 依赖为 @deepseek-ai/cordis@^4.0.1、@deepseek-ai/dsh-mcp-client@^0.1.0-rc.6、@deepseek-ai/dsh-system-prompt@^0.1.0-rc.6、@deepseek-ai/schemastery@^3.18.1,并有 "dsh": { "bundle": { "patch": "./cordis.patch.yml" } } 字段——这正是 README 所说"声明了 dsh.bundle,profile 会自动把它作为一层挂载"的机制来源。
对照 src/index.ts,最终实现与设计有一处刻意的偏差:inject 只有 ["systemPrompt"],没有 tools。源码注释解释了原因:ctx.tools.restrict() 需要 agent 作用域的上下文,而插件自己的 apply(ctx, config) 永远拿不到;dsh-mcp-client 会自己声明对 tools 服务的依赖,桥接层拿得到它需要的东西,插件不必等待。这是 spike 结论(第 6 节)直接塑造的 API 面。
4.2 Config
设计文档中的配置接口(含后来被砍掉的 tools 键):
interface Config {
/** API key from <url>/dashboard/settings/api-keys. */
apiKey: string
/** Reactive Resume instance origin. Default 'https://rxresu.me'. */
url?: string
/** Tool namespace: tools appear as mcp__<serverName>__<rawName>. Default 'resume'. */
serverName?: string
/** Which tool group to expose. Default 'all'. */
tools?: 'resume' | 'applications' | 'all'
/** Per-tool-call timeout. Default inherited from dsh-mcp-client. */
toolCallTimeoutMs?: number
}
url 接受任意 origin,自托管实例无需改动即可工作。
实际发布的 config.ts 与上表相比,除了移除 tools 键,还有三处值得注意的实现细节,均超出设计文档的描述:
serverName的模式校验前置。const SERVER_NAME_PATTERN = /^[A-Za-z0-9_-]{1,32}$/通过z.string().pattern(SERVER_NAME_PATTERN).default("resume")在解析期拒绝非法命名空间——源码注释明确"Config 在 apply 运行之前就已拒绝非法 serverName"。这与StreamableHttpConfig对serverName的约束一致。toolCallTimeoutMs有了明确默认值60_000(z.natural()保证为正整数),而非设计稿中的"继承自 dsh-mcp-client"。apiKey的默认值是空字符串而非 required。这是为配合dsh.bundle的安装路径设计的:bundle patch 在包安装时就把插件行挂载进 profile,彼时用户还没有生成 key。如果apiKey是必填项,缺失的 key 会在启动时造成校验错误、拖垮整个 profile;改为.default("")后,空 key 在 apply() 中只触发一条 warn 并什么都不挂载——"安装它永远不会让 profile 无法启动"。
对应 README 中的选项表:
| 键 | 默认值 | 说明 |
|---|---|---|
apiKey |
'' |
来自 <url>/dashboard/settings/api-keys。为空时不挂载任何东西。 |
url |
https://rxresu.me |
实例 origin,自托管时设置。 |
serverName |
resume |
工具命名空间,工具以 mcp__<serverName>__<rawName> 呈现。 |
toolCallTimeoutMs |
60000 |
单次工具调用超时。 |
5. apply():三步到两步的落地
5.1 设计稿中的三步
- 挂载桥接:
ctx.plugin(mcpClient, { transport: 'streamable-http', serverName, url: '${url}/mcp', headers: { 'x-api-key': apiKey }, toolCallTimeoutMs, failOnStartupError: true })。其中failOnStartupError: true把"API key 写错"变成一次响亮的激活期失败,而不是在调用时才静默报错的工具集。 - 策划工具面:当
tools !== 'all'时,调用ctx.tools.restrict({ deny: [...] }),用从生成的工具名清单计算出的命名空间名排除掉被裁掉的组。(此步后证实不可行,见第 6 节。) - 贡献提示词引导:
ctx.systemPrompt.section({ name: 'reactive-resume', order: 150, text: PATCH_GUIDE })。
三步都返回 disposer;Cordis 的 effect scoping 会在卸载时统一回收,无需手动清理。
5.2 最终实现
apply() 的最终代码 保留了第 1、3 步,并新增了防御与命名空间处理:
export async function apply(ctx: Context, config: Config): Promise<void> {
// bundle patch 在还没人生成 key 之前就挂载了这行。
// 宁可不挂载任何东西,也别让整个 profile 启动失败。
if (config.apiKey === "") {
ctx.logger.warn("no apiKey configured — set one at %s/dashboard/settings/api-keys to enable the tools", config.url);
return;
}
const origin = config.url.replace(/\/+$/, "");
await ctx.plugin(mcpClient, {
transport: "streamable-http",
serverName: config.serverName,
url: `${origin}/mcp`,
headers: { "x-api-key": config.apiKey },
toolCallTimeoutMs: config.toolCallTimeoutMs,
failOnStartupError: true,
});
ctx.systemPrompt.section({
// 按 serverName 加命名空间,避免第二个实例(比如自托管
// 账户与托管账户并存)在 section name 上撞车。
name: `reactive-resume:${config.serverName}`,
order: 150,
text: buildPatchGuide(config.serverName),
});
}
与设计稿的差异点:空 key 早退(对应 4.2 的默认值设计)、url 尾斜杠归一化(replace(/\/+$/, ""),保证 ${origin}/mcp 不产生双斜杠)、prompt section 的 name 从固定的 reactive-resume 变成 `reactive-resume:${serverName}`(多实例防碰撞),以及 order: 150 落在设计文档约定的工具引导区间 100–199 内。
6. Open Risk 成为主事件:restrict() 为什么砍掉了 tools 键
设计文档在"Open risk"一节引用了 ToolRegistry.restrict 的契约原文:"Per-scope filter over the tools a scope INHERITS — the global layer and every ancestor layer on its chain. Restrictions intersect, and do not affect the scope's own registrations."(作用域继承的工具过滤器——全局层与链上所有祖先层。限制互相求交,不影响作用域自身的注册。)
问题在于拓扑:ctx.plugin(mcpClient, …) 把桥接挂在插件上下文的子作用域里,被桥接的工具注册在一个后代层,而非调用 restrict() 的作用域的祖先层。父作用域的限制能否触达它们,未经验证——而 tools 是公开配置键,事后移除属于破坏性变更,所以必须在承诺配置面之前用 spike 定案。
spike 报告给出了明确裁决:
Verdict up front:
restrict reaches child-scope tools: NO
spike 的关键发现(全部基于 0.0.1-rc.1 版本的编译产物,逐行阅读并实际运行验证):
ctx.tools是 Cordis 服务单例(declare module '@deepseek-ai/cordis' { interface Context { tools: ToolRegistry } }),沿整个上下文树共享;真正决定register()/restrict()可见性的不是 Cordis 插件上下文树,而是@deepseek-ai/dsh-scope提供的另一套显式作用域层——上下文必须被createScope(ctx, key)打过标才携带 scope tag,普通的ctx.plugin(child)上下文不受其约束。- Probe 1(字面拓扑复现):在无 scope 的
root上挂载 stub 桥接后从root调restrict({ deny: [...] }),第一行scopeOf(this.ctx)判定为undefined即抛错,实际输出:
RESTRICT_THREW tools.restrict() requires a scoped context (agent.ctx): a context-global restriction would mask every agent — deny the tool for the intended agent instead
这不是静默 no-op,是硬抛异常。
3. Probe 2(最优修复:给插件一个真 scope):spike 定位到真正构造 agent.ctx 的代码在 dsh-agent-loop(createScope(loopCtx, this) 一行),并据此搭建"有 scope 的 restrictor + 同 scope 内桥接"的组合,仍然抛错:
RESTRICT_THREW tools.restrict() names unknown inherited tool "mcp__resume__list_applications"; a restriction filters what this scope inherits, never what it registers itself. Restrictable tools: (none)
桥接的工具落在该 scope 自身层(它作为 scope.ctx 的 Cordis 子节点挂载,因此继承了该 scope tag),而 restrict() 只过滤 scope 继承的表面——两条路都到不了该插件所需的拓扑。
4. spike 还顺带确认了 0.0.1-rc.1 中真实的 ToolDefinition 形状(output 是必填的 { schema, render },execute() 返回规范 JSON 值而非 ContentBlock[]),以及 pnpm 安装该依赖树时的 dsh-type-meta 404 陷阱与 auto-install-peers=false 的解法——这些是"对发布包做了验证而非只看文档"这句话的具体产物。
于是文档预设的 fallback 生效:0.1.0 不带 tools 键(等价于 'all'),策展留待 0.2.0——"prompt section 本身就足以证明这个发布的价值"。设计文档头部的 Post-implementation note 也明确:tools 配置键和 ctx.tools.restrict() 调用从 v0.1.0 中被切除,0.1.0 不做任何过滤地发布全部工具;文档其余部分按原始写作保留以存档,不代表实际发布内容。
7. PATCH_GUIDE:插件的真正实质
设计文档称 PATCH_GUIDE 是"the plugin's substance",约 40 行,覆盖 Reactive Resume 已经以 errorHint 形式编码进 packages/mcp/src/tools.ts 的失败模式——这些错误提示存在本身,就是因为模型会踩这些坑。设计稿列出的六条:
- 调
apply_resume_patch之前先调read_resume,绝不盲打 patch; apply_resume_patch接收的是对 resume 数据文档的 RFC 6902 操作;- 构造路径前先取
resume://_meta/schemaresource; - section 条目是以 UUID 为键的对象数组,
id不是索引; - 被锁定的简历拒绝写入——先调
unlock_resume; list_resumes是 404 之后恢复有效 id 的手段。
写成静态文本而非 provider 函数,因为它不随组装变化。最终实现 prompt.ts 的 buildPatchGuide(serverName) 比设计稿更进一步:它把每个工具名都动态包装成 `mcp__${serverName}__${raw}`,使提示词与配置的命名空间严格一致,内容上扩充为四节——
- Reading before writing:
list_resumes发现 id(id 是 UUID,绝不是标题或 slug);编辑前必须先read_resume;"not found" 时重跑list_resumes而不是猜 id。 - Editing:RFC 6902 语义;路径要基于本会话已读到的简历构造,并对齐
apply_resume_patch工具描述里自带的具体示例(/basics/name、/sections/experience/items/-、/sections/experience/items/0/company、/metadata/template);条目按"从刚读到的文档里定位索引"寻址;多个操作合并进一个 patch 优于多个单操作 patch(操作按序应用、整体原子失败);并划清update_resume(只改元数据:name、slug、tags、public 可见性)与import_resume(从完整数据文档创建新简历,不覆盖已有简历)的边界。 - Locking:锁定的简历拒绝一切写入;因锁定失败时
unlock_resume→ 修改 →lock_resume,"把锁恢复到你发现它时的状态"。 - Scope:除非用户在本对话中明确要求删除,否则不删除任何简历或申请;用户用散文描述变更时,先复述即将执行的具体编辑再动手。
7.1 漂移检测:从"定时抓 server card"到 monorepo 契约测试
设计稿的原始方案(现已标注"Superseded by the move into the monorepo"):
- 构建步骤抓取
<url>/.well-known/mcp/server-card.json,生成src/tool-names.generated.ts,内含拆分为resume/applications两组的原始工具名; - 生成文件被提交进仓库,
npm install永远不需要网络; - 一个定时 CI job 从
https://rxresu.me重新抓取,当已提交清单与线上 card 不再一致时失败——这个失败就是"该切一个新插件版本"的信号。
落进 monorepo 后,方案简化为一行测试:tool-names.test.ts 直接从 workspace 依赖 @reactive-resume/mcp/tool-names 读取工具名表,用正则 mcp__resume__([a-z0-9_]+) 提取 PATCH_GUIDE 中引用的每个工具名,断言它们都存在于 MCP_TOOL_NAME 的 33 个值之中——工具一改名,同一个 PR 里这个包就先挂。另有一条防"正则改废"的自守护测试(断言提取到的工具名非空)。这正是决策表里"漂移防护"一行在 monorepo 内的最终形态,比定时网络 job 更早、更确定。
8. 安装体验与测试
8.1 安装 UX
设计稿给出的 README 复制块:
- insert:
- id: reactive-resume
name: dsh-plugin-reactive-resume
config:
apiKey: !!js process.env.RXRESUME_API_KEY
实际 README 的路径更进一步:dsh plugin --profile <name> add dsh-plugin-reactive-resume 一条命令安装,dsh.bundle 声明使 profile 自动挂载该行;key 通过 https://rxresu.me/dashboard/settings/api-keys 生成并导出为 RXRESUME_API_KEY,需要显式覆盖时按 id patch cordis.patch.yml。自托管示例:
- id: reactive-resume
config:
apiKey: !!js process.env.RXRESUME_API_KEY
url: http://localhost:3000
8.2 测试策略
- Spike(阻塞项):本地起 Reactive Resume(
dotenvx run -f .env.local -- pnpm dev,端口 3000),把一份临时 Harness 配置指向http://localhost:3000/mcp并带x-api-key,确认 (a) Streamable HTTP 桥接通且列出 33 个工具,(b) 从插件作用域调ctx.tools.restrict能否隐藏被桥接工具。(结论见第 6 节。) - 单元:Config schema 的默认值与校验、从生成工具名计算 deny 清单、PATCH_GUIDE section 以 order 150 与预期名称注册。
- 契约:server card 抓取与
src/tool-names.generated.ts一致(monorepo 化后等价于 tool-names.test.ts)。 - 发布前手动冒烟:在真实 Harness 会话中对着 rxresu.me——列出简历、读一个、打一个 patch、在 Web UI 里验证变更生效。
8.3 构建顺序(设计稿原样保留)
- Spike:
restrict()语义 + 对 localhost 的 Streamable HTTP 桥; - 仓库脚手架、Config schema、桥接挂载、README——端到端可用的安装;
- PATCH_GUIDE 提示词段落;
- 工具 profile、生成名清单、漂移 CI(monorepo 化后此步收敛为契约测试);
- 发布 0.1.0、仓库打
dsh-plugintopic、提交进 awesome-deepseek-harness。
实际发布状态:dsh-plugin-reactive-resume@0.1.0,MIT 许可,Node ^22.19.0 || >=24.0.0,构建器 tsdown。
9. 范围外与适用前提
设计文档明确排除在 0.1.0 之外:OAuth 支持、ctx.commands 快捷方式、随包附带的 Harness skill、本地简历缓存、Harness 内的 PDF 渲染——每一项都是纯增量,不阻塞一个有用的 0.1.0。
适用前提需要说清:本插件是 DeepSeek Harness 生态的组件,依赖宿主的 Cordis 运行时(@deepseek-ai/cordis@^4.0.1 等 peer 依赖);被桥接端要求 Reactive Resume 实例开放 /mcp(Streamable HTTP)端点并支持 x-api-key 鉴权,托管实例默认 origin 为 https://rxresu.me,自托管实例通过 url 指向即可。0.1.0 的一个已知取舍是不做工具面策展——33 个工具全量进入上下文,README 对此有直接说明:"Narrowing that set is not currently possible from a plugin"。
10. 小结
这份设计文档的价值不在"写了一个插件",而在它完整演示了一条可复制的证据链:先把双方(Reactive Resume 的 33 工具 MCP 服务器与 Harness 的插件/桥接/提示词 API)的能力逐条验证到发布包级别;用决策表锁定"薄桥接 + prompt section"的最小范围;把唯一的架构疑点(restrict() 的作用域可达性)显式标为阻塞项,用两个 probe 拿到两条不同的抛错路径后,果断把 tools 键从公开配置面中切除;漂移防护则随仓库归属的变化从"网络定时任务"自然演进为"同 PR 契约测试"。最终交付的 packages/dsh-plugin 只做了两件 dsh-mcp-client 做不了的事:把错误 API key 变成启动期响亮失败(failOnStartupError: true),以及把模型最容易写错的 JSON Patch 语义写成与命名空间严格一致的系统提示词段落。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0622
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00