Reactive Resume 的 dsh-plugin 实现计划深度解析:如何把 MCP 简历工具接入 DeepSeek Harness 会话
本文围绕 Reactive Resume 仓库中的 dsh-plugin 实现计划 展开,完整复盘这个 npm 分发的 DeepSeek Harness(dsh)插件从技术验证(spike)、仓库脚手架、配置模式、MCP 桥接、系统提示词注入,到工具名漂移检测与发布 0.1.0 的全过程;读完你可以掌握“Cordis 插件如何挂载 MCP 工具 + 系统提示词段落”的设计方法,并能对照 当前实际源码 理解计划与落地之间的工程取舍。
需要说明的是,该计划文档开头有一段历史记录声明:计划写作与执行时插件还独立成仓,现已迁入 Reactive Resume monorepo 的 packages/dsh-plugin,测试改为与源码同目录(src/*.test.ts),构建产物为 dist/,原计划中的“生成的工具名快照 + 定时漂移任务”已被一个针对 @reactive-resume/mcp/tool-names 的本地检查取代——文档里的部分路径已不是现状。本文会把计划原文与当前仓库实际状态对照呈现。
一、目标、架构与技术栈
计划给出的核心目标(Goal)原文:
Ship an npm-distributed DeepSeek Harness plugin that connects a Harness session to a Reactive Resume account through the existing
/mcpendpoint, curates the tool surface, and teaches the model Reactive Resume's JSON Patch semantics.
拆解一下:插件通过 Reactive Resume 已有的 /mcp 端点把 Harness 会话连到用户账户,收敛暴露给模型的工具面,并教会模型 Reactive Resume 的 JSON Patch 语义。架构(Architecture)定义为:
一个命名空间的 Cordis 插件,导出
name、inject、Config和apply(ctx, config)。apply通过 Streamable HTTP 挂载@deepseek-ai/dsh-mcp-client,带x-api-key头;可选地用ctx.tools.restrict收窄桥接的工具集;通过ctx.systemPrompt.section注册一段系统提示词。插件不包含任何 Reactive Resume 代码——它只是对一台已经存在的服务器说话(HTTP)。
技术栈(Tech Stack):TypeScript、pnpm、tsdown(构建)、Vitest(测试)、Biome(lint/格式),以及作为 peer 依赖的 @deepseek-ai/cordis、@deepseek-ai/dsh-mcp-client、@deepseek-ai/dsh-tools、@deepseek-ai/dsh-system-prompt、@deepseek-ai/schemastery。
全局约束(Global Constraints)
计划列出的 8 条硬约束,是理解后续每个任务决策的前提:
| 约束 | 说明 |
|---|---|
| Node 版本 | ^22.19.0 || >=24.0.0,包管理器 pnpm |
| 模块体系 | type: "module",纯 ESM,无 CJS 构建 |
| peer 依赖 | 四个 @deepseek-ai/* 运行时包全部是 peerDependencies(^0.0.1-rc.1),绝不进 dependencies,由宿主(Harness)提供 |
| 工具命名 | dsh-mcp-client 桥接的公共工具名为 mcp__<serverName>__<rawName>,默认 serverName 为 resume |
| serverName 合法性 | 必须匹配 [A-Za-z0-9_-]{1,32} |
| 提示词顺序 | 系统提示词段落的 order 必须落在 100–199 的“工具指导”区间,计划取 150 |
| 默认地址 | https://rxresu.me,无尾斜杠 |
| 认证头 | 精确为 x-api-key(小写) |
| 密钥安全 | 永不提交 API key,测试一律使用字面量 test-key |
二、Task 1:Spike —— restrict() 能否触达子作用域的工具?
这是整个计划中最有工程价值的一环。它产出一份书面结论(而非可发布代码),并**门控(gate)**后续两个决策:Task 3 的 tools 配置键、Task 6 的 ctx.tools.restrict 调用。
疑问本身
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, …) 是把桥接挂载在插件上下文的子作用域。如果父作用域的 restriction 够不到子作用域的注册,tools 键就不该进 0.1.0——事后移除一个已公开的配置键就是破坏性变更。
问题纯粹关于 Cordis 作用域语义,不需要真实 MCP 服务器、Reactive Resume 实例或 API key:一个在子作用域注册单个工具的桩插件就能复现完全相同的拓扑。
探针(Probe)代码
计划给出的探针思路:建一个一次性 workspace(/tmp/dsh-spike),安装 @deepseek-ai/cordis、@deepseek-ai/dsh-tools、@deepseek-ai/schemastery,然后写一个 probe.ts,目标是复现插件拓扑——父上下文加载子插件,子插件注册一个工具,父上下文试图隐藏它:
import { Context } from '@deepseek-ai/cordis'
import * as tools from '@deepseek-ai/dsh-tools'
const root = new Context()
await root.plugin(tools)
/** Stands in for dsh-mcp-client: registers one tool in whatever scope loads it. */
const stubBridge = {
name: 'stub-bridge',
inject: ['tools'],
apply(ctx: Context) {
ctx.tools.register({
name: 'mcp__resume__list_applications',
description: 'stub',
parameters: { type: 'object', properties: {} },
async execute() {
return { content: [{ type: 'text', text: 'ok' }] }
},
})
},
}
// The plugin under design mounts the bridge as a child, exactly like this.
await root.plugin(stubBridge)
const names = () => root.tools.schemas().map((s) => s.name)
console.log('BEFORE', names())
const dispose = root.tools.restrict({ deny: ['mcp__resume__list_applications'] })
console.log('AFTER', names())
dispose()
console.log('DISPOSED', names())
计划明确提示:register 的确切形状必须匹配 @deepseek-ai/dsh-tools 中的 ToolDefinition,上面的字段是“最佳猜测”,编译报错属于预期而非阻塞,应以读类型定义后实际调整的结果为准。运行方式为 node --experimental-strip-types probe.ts。
三种判定
AFTER中工具消失 → YES,Task 3 与 Task 6 按原样发布;AFTER仍在 → NO,tools键按 Task 6 的 fallback 推迟;- 探针根本跑不起来 → INCONCLUSIVE,视同 NO:宁可不出这个键,也不要发一个可能不工作的键。
Step 4 只在上一步为 NO 时执行:尝试从桥接作用域的后代上下文调用 restrict(),或把 restriction 注册进桥接加载的同一作用域;任何从插件自身 apply(ctx, config) 可达的成功排列,都使结论翻为 YES 并注明所需排列。并且有一条明确的时间预算:不超过 30 分钟,未解决的 NO 也是完全可接受的结果——插件照常发布,只是少了工具筛选能力,提示词段落依然构成 0.1.0 的主体。
实际结论(来自仓库中的 spike 记录)
仓库里保留了该 spike 的完整报告:restrict 语义结论,开头一句即是判定:
restrict reaches child-scope tools: NO
报告的关键事实(全部来自实际运行与源码阅读):
-
restrict()在无作用域上下文中直接抛错,不是静默无效。Probe 1(字面复现插件拓扑)的真实输出:BEFORE [ 'mcp__resume__list_applications' ] 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 -
修好异常后仍触达不到。Probe 2 用
createScope()造了一个真实的dsh-scope作用域(这正是dsh-agent-loop构建agent.ctx的方式),再从作用域内部调用restrict(),得到的是另一个明确的拒绝: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)即:桥接的工具位于该作用域自己的层,而
restrict()永远只作用于作用域从祖先继承的工具面。 -
报告还记录了环境细节:
dsh-tools的next版本会把 peer 链引向公开 npm 上 404 的@deepseek-ai/dsh-type-meta,最终靠auto-install-peers=false+strict-peer-dependencies=false并只装运行时真实 import 的包才完成 bootstrap;真实ToolDefinition形状(output.schema+output.render必填,execute返回规范 JSON 值而非 ContentBlock)也与计划里的“猜测形状”不同。
这个 NO 结论直接决定了 0.1.0 的最终形态:没有 tools 配置键,全部工具都暴露。
三、Task 2:仓库脚手架——绿色构建与测试循环
该任务产出一套可用的 pnpm build / pnpm test / pnpm typecheck / pnpm check,所有后续任务都依赖这些命令存在。
计划的 package.json(节选核心字段):
{
"name": "dsh-plugin-reactive-resume",
"version": "0.1.0",
"type": "module",
"main": "lib/index.js",
"types": "lib/index.d.ts",
"exports": {
".": { "types": "./lib/index.d.ts", "default": "./lib/index.js" },
"./package.json": "./package.json"
},
"files": ["lib"],
"publishConfig": { "access": "public" },
"engines": { "node": "^22.19.0 || >=24.0.0" },
"scripts": {
"build": "tsdown",
"test": "vitest run",
"typecheck": "tsc --noEmit",
"check": "biome check --write .",
"generate:tool-names": "node --experimental-strip-types scripts/generate-tool-names.ts",
"prepublishOnly": "pnpm build"
},
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.1-rc.1",
"@deepseek-ai/dsh-mcp-client": "^0.0.1-rc.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1-rc.1",
"@deepseek-ai/dsh-tools": "^0.0.1-rc.1",
"@deepseek-ai/schemastery": "^3.18.1-rc.1"
}
}
注意计划中的一条注释:@deepseek-ai/* 包同时出现在 peerDependencies(消费方必须提供)和 devDependencies(本仓库要 typecheck 和测试),这是标准模式而非笔误。
配套工具配置:
tsconfig.json:target ES2023、module ESNext、moduleResolution bundler、strict、noUncheckedIndexedAccess、verbatimModuleSyntax、allowImportingTsExtensions、noEmit,include 为src、test、scripts;tsdown.config.ts:入口src/index.ts,输出lib/,format: ['esm'],dts: true,clean: true;vitest.config.ts:test.include: ['test/**/*.test.ts'];biome.json:tab 缩进、120 列、单引号、asNeeded分号、recommended 规则集。
随后是标准的 TDD 循环:先写必失败的冒烟测试 test/smoke.test.ts(断言 name === 'reactive-resume'),运行确认它以 Failed to resolve import "../src/index.ts" 失败,再写最小实现:
/** Cordis plugin name used by loader diagnostics. */
export const name = 'reactive-resume'
最后 pnpm test && pnpm typecheck && pnpm build 全绿并提交。
对照当前仓库:脚手架已并入 monorepo,package.json 的入口变为 dist/index.js,files 为 ["dist", "cordis.patch.yml"],peer 版本推进到 @deepseek-ai/cordis ^4.0.1、dsh-mcp-client ^0.1.0-rc.6 等,typecheck 改用 tsgo,并新增了 dsh.bundle 声明(指向 cordis.patch.yml),这些差异都源于插件从独立仓迁入 monorepo 后的重构,核心结构(ESM、peer deps、tsdown、prepublishOnly build)与计划一致。
四、Task 3:配置模式(Config schema)
该任务产出三个接口:
Config接口:{ apiKey: string; url: string; serverName: string; tools: ToolProfile; toolCallTimeoutMs: number }ToolProfile = 'resume' | 'applications' | 'all'- 名为
Config的 schemastery schema,解析结果保证每个字段都已填充。
计划特别强调:解析后的类型没有可选字段——默认值由 schema 应用,下游代码永远不处理 undefined。同时声明了门控关系:tools 键只在 spike 判定为 YES 时才随 0.1.0 发布;NO/INCONCLUSIVE 时从 Config 和 schema 中省略,ToolProfile 本身仍保留(Task 6 要用)。
失败测试先行(test/config.test.ts),覆盖四个用例:默认值填充、显式值保留、缺失 apiKey 拒绝、未知 tools profile 拒绝:
it('applies defaults for every optional field', () => {
const parsed = Config({ apiKey: 'test-key' })
expect(parsed).toEqual({
apiKey: 'test-key',
url: 'https://rxresu.me',
serverName: 'resume',
tools: 'all',
toolCallTimeoutMs: 60_000,
})
})
it('rejects a missing apiKey', () => {
expect(() => Config({})).toThrow()
})
it('rejects an unknown tools profile', () => {
expect(() => Config({ apiKey: 'test-key', tools: 'everything' })).toThrow()
})
实现(计划原文的 src/config.ts):
import z from '@deepseek-ai/schemastery'
export type ToolProfile = 'resume' | 'applications' | 'all'
export interface Config {
/** API key minted at `<url>/dashboard/settings/api-keys`. */
apiKey: string
/** Reactive Resume origin, no trailing slash. */
url: string
/** Tool namespace: tools reach the model as `mcp__<serverName>__<rawName>`. */
serverName: string
/** Tool group to expose. */
tools: ToolProfile
/** Per-tool-call timeout in milliseconds. */
toolCallTimeoutMs: number
}
export const Config: z<Config> = z.object({
apiKey: z.string().required().description('API key from <url>/dashboard/settings/api-keys.'),
url: z
.string()
.default('https://rxresu.me')
.description('Reactive Resume origin. Set this for a self-hosted instance.'),
serverName: z
.string()
.default('resume')
.description('Tool namespace. Must match [A-Za-z0-9_-]{1,32} and be unique across live MCP instances.'),
tools: z
.union(['resume', 'applications', 'all'] as const)
.default('all')
.description('Which Reactive Resume tool group the model sees.'),
toolCallTimeoutMs: z.natural().default(60_000).description('Per-tool-call timeout in milliseconds.'),
})
计划还给了一个后备方案:若 z.union([...] as const) 得不到字面联合类型,改用 z.union([z.const('resume'), z.const('applications'), z.const('all')]),运行时行为相同。
配置键速查(计划版本)
| 键 | 默认值 | 说明 |
|---|---|---|
apiKey |
(必填) | 在 <url>/dashboard/settings/api-keys 铸造的 API key |
url |
https://rxresu.me |
实例 origin;自部署时修改 |
serverName |
resume |
工具命名空间,工具以 mcp__<serverName>__<rawName> 形式到达模型 |
tools |
all |
暴露的工具组:resume / applications / all(受 spike 门控) |
toolCallTimeoutMs |
60000 |
单次工具调用超时(毫秒) |
当前仓库的演进
对照 config.ts,实际发布版有两处关键差异,均能追溯到计划之外的新约束:
apiKey不再是必填,改为z.string().default("")。源码注释解释原因:bundle patch 在安装时就挂载该插件,所以缺失 key 必须是一个“惰性 no-op”,而不是把整个 profile 在启动时拖垮——apply会打警告并什么都不挂载(见下文);serverName的校验前移到 parse 阶段:z.string().pattern(SERVER_NAME_PATTERN),模式^[A-Za-z0-9_-]{1,32}$在 schema 层就拒绝非法值,apply不再需要自己抛错;tools/ToolProfile按 Task 6 Step 6 被彻底移除——spike 判定 NO 后,groupOf()、deniedToolNames()两个函数因无消费者而删除,避免发布“为一个可能永远不存在的功能服务的死代码”。
五、Task 4:挂载 MCP 桥
产出 inject: string[] 与 apply(ctx, config),供 Task 5、6 继续扩展同一个 apply。
测试策略值得一提:不启动真实 Harness,而是手写一个 fake context 记录 apply 调用了什么——锁定的正是桥接配置本身,这才是关键不变量:
function fakeContext() {
return {
plugin: vi.fn(async () => undefined),
tools: { restrict: vi.fn(() => () => undefined) },
systemPrompt: { section: vi.fn(() => () => undefined) },
}
}
it('mounts the MCP bridge with streamable-http and the api key header', async () => {
const ctx = fakeContext()
await apply(ctx as never, Config({ apiKey: 'test-key' }))
expect(ctx.plugin).toHaveBeenCalledTimes(1)
expect(ctx.plugin.mock.calls[0]?.[1]).toEqual({
transport: 'streamable-http',
serverName: 'resume',
url: 'https://rxresu.me/mcp',
headers: { 'x-api-key': 'test-key' },
toolCallTimeoutMs: 60_000,
failOnStartupError: true,
})
})
其余两个用例分别验证:配置的 url 尾斜杠被剥离(http://localhost:3000/ → http://localhost:3000/mcp);serverName: 'has spaces' 被拒绝且错误信息匹配 /serverName/。
计划给出的 src/index.ts 完整实现:
import type { Context } from '@deepseek-ai/cordis'
import * as mcpClient from '@deepseek-ai/dsh-mcp-client'
import type { Config } from './config.ts'
// Re-exports the interface AND the schema — `config.ts` exports both under the
// name `Config`, and Cordis reads the schema export to validate config before
// this plugin starts.
export { Config, type ToolProfile } from './config.ts'
export const name = 'reactive-resume'
/** Services required by this plugin. */
export const inject = ['tools', 'systemPrompt']
/** `dsh-mcp-client` reserves this shape for a server namespace. */
const SERVER_NAME_PATTERN = /^[A-Za-z0-9_-]{1,32}$/
export async function apply(ctx: Context, config: Config): Promise<void> {
if (!SERVER_NAME_PATTERN.test(config.serverName)) {
throw new Error(`Invalid serverName "${config.serverName}": must match ${SERVER_NAME_PATTERN.source}`)
}
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,
})
}
两个值得注意的设计点:其一,Config 同名导出接口和 schema 是有意为之——Cordis 在读到 schema 导出时会在插件启动前完成配置校验;其二,failOnStartupError: true 让连接失败在启动期显式暴露,而不是在第一次工具调用时静默出错。
当前仓库的 apply
对比 index.ts,实际版本反映了 spike 结论与 bundle 安装模型:
export const inject = ["systemPrompt"]
export async function apply(ctx: Context, config: Config): Promise<void> {
// The bundle patch mounts this row on install, before anyone has minted a
// key. Mount nothing rather than failing the profile's boot...
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({
name: `reactive-resume:${config.serverName}`,
order: 150,
text: buildPatchGuide(config.serverName),
})
}
源码注释解释了 inject 为什么从 ['tools', 'systemPrompt'] 收窄为 ['systemPrompt']:ctx.tools.restrict() 需要 agent 作用域上下文,插件的 apply(ctx, config) 永远不是那种上下文,且本插件根本不调用它;dsh-mcp-client 自己声明了对 tools 服务的依赖,桥接照样拿到所需服务。另外 serverName 合法性校验已从 apply 中移除(由 Task 3 的 schema pattern 在 parse 阶段完成),提示词段落的 name 也按 serverName 加了命名空间(reactive-resume:${serverName}),以免同一 profile 里挂第二个实例(例如自部署账号与托管账号并存)时段落名冲突。
六、Task 5:系统提示词段落——插件存在的理由
计划对这段的定位非常直白:“This is the plugin's substance — the reason it exists rather than a raw dsh-mcp-client row.”(这是插件的实质——它区别于直接写一行 dsh-mcp-client 配置的全部价值。)其内容镜像了 Reactive Resume 从 packages/mcp/src/tools.ts 的 errorHint 里已经在输出的错误提示——那些提示存在,正是因为模型恰好会犯这些错。
测试锁定三件事:
it('registers one prompt section in the tool-guidance order band', async () => {
// section.name === 'reactive-resume'
// section.order ∈ [100, 199]
// section.text === PATCH_GUIDE
})
it('names the tools it references with the configured namespace', async () => {
// serverName 为 'rr' 时,text 含 'mcp__rr__read_resume'
// 且不含 'mcp__resume__read_resume'
})
it('covers the documented failure modes', () => {
for (const phrase of ['RFC 6902', 'resume://_meta/schema', 'unlock_resume', 'list_resumes']) {
expect(PATCH_GUIDE).toContain(phrase)
}
})
第二个测试揭示了一个非平凡约束:指南必须是命名空间感知的,因此 PATCH_GUIDE 不能是冻结常量,而是按 serverName 逐实例构建的模板。
计划版实现(src/prompt.ts):
export function buildPatchGuide(serverName: string): string {
const t = (raw: string) => `\`mcp__${serverName}__${raw}\``
return [
'## Reactive Resume',
'',
"These tools operate on the user's real, live resumes and job applications. Changes are immediate and visible in their account.",
'',
'### Reading before writing',
'',
`- Call ${t('list_resumes')} to discover resume IDs. IDs are UUIDs, never titles or slugs.`,
`- Call ${t('read_resume')} before any edit. Never patch a resume you have not read this session.`,
`- If a call fails with "not found", re-run ${t('list_resumes')} rather than guessing an ID.`,
'',
'### Editing',
'',
`- ${t('apply_resume_patch')} takes RFC 6902 JSON Patch operations applied to the resume data document.`,
'- Read the `resume://_meta/schema` resource before constructing paths. Do not infer path shapes from the resume you read — the schema is authoritative about which keys are permitted.',
'- Section entries are arrays of objects, each with its own UUID `id`. Address an existing entry by locating its index from the document you just read; never treat an `id` as an index.',
'- Prefer one patch with several operations over several single-operation patches. Operations apply in order and the whole patch fails atomically.',
`- Use ${t('update_resume')} only for whole-document replacement. For anything smaller, patch.`,
'',
'### Locking',
'',
`- A locked resume rejects every write. When a call fails because the resume is locked, call ${t('unlock_resume')}, make the change, and leave the lock as you found it.`,
'',
'### Scope',
'',
'- Never delete a resume or an application unless the user asked for that specific deletion in this conversation.',
'- When the user describes a change in prose, restate the concrete edit you are about to make before making it.',
].join('\n')
}
/** The prompt section for the default `resume` namespace. */
export const PATCH_GUIDE: string = buildPatchGuide('resume')
在 apply 末尾注册段落:
ctx.systemPrompt.section({
name: 'reactive-resume',
order: 150,
text: buildPatchGuide(config.serverName),
})
当前仓库的提示词文本
prompt.ts 的实际文本与计划版有实质性修正,都源于对服务器工具真实行为的进一步确认:
- “Editing” 一节中,路径构造指引从“先读
resume://_meta/schema资源”改为“从本会话已读的简历构造路径,不要猜;apply_resume_patch自身的工具描述带有具体路径示例(/basics/name、/sections/experience/items/-、/sections/experience/items/0/company、/metadata/template)”——把权威性来源从资源读取改为工具描述,减少模型要做的往返; update_resume的语义被纠正为只改元数据(name、slug、tags、public 可见性),内容变更必须走apply_resume_patch;并明确“没有任何工具能整体替换既有简历内容——import_resume是创建一份全新简历,不是覆盖”;- 锁定流程补全为
unlock_resume→ 修改 →lock_resume,即“把锁留成你发现它时的样子”(计划版只说 leave as found)。
这些细节说明计划中的提示词是“镜像 errorHint”的第一版,落地版是对齐了 packages/mcp 工具实际行为后的修订。
七、Task 6:生成的工具名与漂移检测
该任务在 Task 1 判定 NO 之后被重新定范围:原设计的 groupOf() 与 deniedToolNames()(支撑 tools 键)被砍掉,因为已无消费者。计划明确论证了剩下部分“为什么不是死代码”:buildPatchGuide 点名了五个具体工具(list_resumes、read_resume、apply_resume_patch、update_resume、unlock_resume)——如果 Reactive Resume 改名或删除其中任何一个,提示词就会开始引导模型调用一个不存在的工具,而且是静默地。生成式名称表加两个测试就是防这道漂移的机制。“指南是这一版本的全部产品,所以守住它的准确性就是重点。”
生成器(计划版,针对当时独立仓的 live 服务器卡片)
/**
* Regenerate `src/tool-names.generated.ts` from a live Reactive Resume server card.
* Usage: node --experimental-strip-types scripts/generate-tool-names.ts [origin]
*/
import { writeFileSync } from 'node:fs'
const origin = (process.argv[2] ?? 'https://rxresu.me').replace(/\/+$/, '')
const response = await fetch(`${origin}/.well-known/mcp/server-card.json`)
if (!response.ok) throw new Error(`Server card fetch failed: ${response.status} ${response.statusText}`)
const card = (await response.json()) as { tools: { name: string }[] }
const names = card.tools.map((tool) => tool.name).sort()
if (names.length === 0) throw new Error('Server card listed no tools')
const body = [
'// Generated by scripts/generate-tool-names.ts. Do not edit by hand.',
`// Source: ${origin}/.well-known/mcp/server-card.json`,
'',
'/** Raw (un-namespaced) tool names published by Reactive Resume. */',
'export const TOOL_NAMES = [',
...names.map((name) => `\t'${name}',`),
'] as const satisfies readonly string[]',
'',
].join('\n')
writeFileSync(new URL('../src/tool-names.generated.ts', import.meta.url), body)
console.log(`Wrote ${names.length} tool names from ${origin}`)
计划预期 pnpm generate:tool-names 输出 Wrote 33 tool names from https://rxresu.me,并特意说明“数量不同也没关系——Reactive Resume 可能已经发布了新工具,报告时用真实数字”。
两个测试的职责分工
指南覆盖测试(给生成列表一个消费者,从提示词文本里抽出所有被点名的工具,断言服务器确实发布了它们):
/** Every raw tool name the prompt guide instructs the model to call. */
function toolsReferencedByGuide(): string[] {
const guide = buildPatchGuide('resume')
const matches = guide.matchAll(/mcp__resume__([a-z0-9_]+)/g)
return [...new Set([...matches].map((match) => match[1] as string))]
}
it('references at least one tool', () => {
// Guards the regex itself: a guide rewrite that drops the namespaced form
// would otherwise make the next test pass vacuously.
expect(toolsReferencedByGuide().length).toBeGreaterThan(0)
})
it('only references tools Reactive Resume actually publishes', () => {
const published: readonly string[] = TOOL_NAMES
for (const referenced of toolsReferencedByGuide()) {
expect(published).toContain(referenced)
}
})
第一个测试的注释值得注意:它是在守护正则本身——如果指南重写后丢了命名空间形式,第二个测试会“空洞地通过”,第一个测试就是为了防止这种假绿。计划还规定:第二个测试若失败,是真实发现而非测试 bug,不要悄悄改指南去迁就,要报告。
漂移测试(网络测试,平时离线跳过):
const ORIGIN = process.env.RXRESUME_ORIGIN ?? 'https://rxresu.me'
// Network test: skipped unless RXRESUME_CHECK_DRIFT=1, so ordinary `pnpm test`
// stays offline and deterministic. CI sets the flag on a schedule.
it.runIf(process.env.RXRESUME_CHECK_DRIFT === '1')(
'matches the live server card',
async () => {
const response = await fetch(`${ORIGIN}/.well-known/mcp/server-card.json`)
expect(response.ok).toBe(true)
const card = (await response.json()) as { tools: { name: string }[] }
const live = card.tools.map((tool) => tool.name).sort()
expect(live).toEqual([...TOOL_NAMES])
},
30_000,
)
运行方式两次:pnpm test(离线,漂移测试跳过)与 RXRESUME_CHECK_DRIFT=1 pnpm vitest run test/tool-names-drift.test.ts(对 live 卡片执行)。
定时 CI 工作流(.github/workflows/drift.yml):每周一 06:00(cron 0 6 * * 1)+ 手动触发,Node 24、pnpm 缓存、pnpm install --frozen-lockfile 后运行漂移测试并注入 RXRESUME_CHECK_DRIFT: '1'。
当前仓库的演进:本地化漂移检测
迁入 monorepo 后,这套机制被简化为同 PR 即可失败的本地检查——不再需要生成的快照文件和定时网络任务。tool-names.test.ts 直接读取 @reactive-resume/mcp/tool-names 导出的 MCP_TOOL_NAME 表(即服务器自己的工具名权威来源,例如 listResumes: "list_resumes"、patchResume: "apply_resume_patch"、unlockResume: "unlock_resume"):
it("only references tools the MCP server actually publishes", () => {
// Reads the server's own tool-name table rather than a generated snapshot of
// a live server card, so renaming a tool in `packages/mcp` fails here on the
// same PR instead of drifting until a scheduled network check notices.
const published: readonly string[] = Object.values(MCP_TOOL_NAME)
for (const referenced of toolsReferencedByGuide()) {
expect(published).toContain(referenced)
}
})
从源码结构看,这是一个更优的取舍:插件与服务器共享同一个仓库后,“live 服务器”的真相就是 packages/mcp 里的常量表,改名工具会在同一 PR 让 dsh-plugin 的测试变红,漂移窗口从“一个调度周期”压缩到“零”。README 也明确描述了这一开发约束:“renaming a tool breaks this package in the same pull request.”
八、Task 7:CI、README 与发布 0.1.0
该任务消费前六个任务的全部产物,产出 npm 上的 dsh-plugin-reactive-resume@0.1.0。
CI 工作流(.github/workflows/ci.yml)在 main push 与 PR 上运行:checkout → pnpm setup → Node 24(pnpm 缓存)→ pnpm install --frozen-lockfile → pnpm biome ci . → pnpm typecheck → pnpm test → pnpm build。
发布前校验包内容:
pnpm build && pnpm pack --dry-run
预期 tarball 只含 lib/**、package.json、README.md、LICENSE;出现 src/ 或 test/ 就修 files 字段。
真实会话冒烟测试(不通过就不发布):把打好的 tarball 装进一个 Harness workspace,配置 cordis.yml 行并注入真实 key,启动会话后逐条确认:
- “List my resumes” 返回真实标题;
- “Read <title>” 返回真实内容;
- “Change my headline to X” 应用后在站点上可见;
- (若 spike 判定为 YES)
tools: resume时不出现mcp__resume__*application*工具——本计划中该条因判定 NO 而跳过。
最后 npm publish --access public、给仓库加 dsh-plugin topic(Harness 插件目录靠它发现)、打 tag v0.1.0。
计划版 README 的配置示例
计划中 README 给用户的接入方式(独立仓形态,手工往 cordis.yml 加一行):
- insert:
- id: reactive-resume
name: dsh-plugin-reactive-resume
config:
apiKey: !!js process.env.RXRESUME_API_KEY
自部署形态则加一个 url:
config:
apiKey: !!js process.env.RXRESUME_API_KEY
url: http://localhost:3000
计划版 README 还留了一句诚实的能力边界说明:“All 33 of Reactive Resume's tools are exposed. Narrowing that set is not currently possible from a plugin: Harness's ctx.tools.restrict() requires an agent-scoped context, which a plugin context is not.”——这正是 Task 1 spike 结论在用户文档中的直接体现。
九、落地形态:从独立仓到 monorepo 包
对照计划与当前仓库,0.1.0 的最终形态可以总结为:
| 维度 | 计划(0.1.0 目标) | 当前仓库实际 |
|---|---|---|
| 位置 | 独立 npm 仓库 | monorepo packages/dsh-plugin,与 packages/mcp 同仓 |
| 安装 | 手工加 cordis.yml 行 |
dsh plugin --profile <name> add dsh-plugin-reactive-resume,靠 dsh.bundle + cordis.patch.yml 自动挂载 |
apiKey |
parse 时必填 | 默认 '',缺失时 apply 打警告、什么都不挂载,安装即保持 profile 可启动 |
serverName 校验 |
apply 内正则 + 抛错 |
schema .pattern() 在 parse 阶段拒绝 |
tools 键 |
受 spike 门控 | 判定 NO,彻底移除;README 保留“无法收窄工具集”的边界说明 |
| 提示词段落名 | 固定 reactive-resume |
reactive-resume:${serverName},支持多实例并存 |
| 工具名守护 | 生成的 TOOL_NAMES 快照 + 定时漂移 CI |
本地测试直接对 @reactive-resume/mcp/tool-names,同 PR 失败 |
| 构建产物 | lib/(tsdown + dts) |
dist/,peer 版本推进到 dsh-mcp-client ^0.1.0-rc.6 等 |
其中 bundle patch 的机制值得单独一提:cordis.patch.yml 向 profile 根插入一行 reactive-resume 配置,apiKey 取 !!js process.env.RXRESUME_API_KEY ?? ''——?? '' 让“装了这个包但还没铸 key”的状态合法化;用户从自己 profile 的 cordis.patch.yml 按 id 覆盖任意字段(每行后写者胜)即可接入自部署实例。这与 README 的自部署示例(url: http://localhost:3000)对应。
十、可复用的工程方法论
这份实现计划的价值不只在插件本身,更在几个可迁移的做法:
- 先 spike 后承诺:对一个可能不成立的 API 假设(
restrict()的可见性),花不超过 30 分钟用最小桩探针拿到书面判定,判定结果直接门控公开 API 面——“事后删除公开配置键是破坏性变更”是这里的决策准则; - 失败模式驱动提示词:系统提示词段落不是拍脑袋写的,而是镜像服务器
errorHint中已经在输出的错误提示,把“模型实际会犯的错”编码为行为约束(先读后改、UUID 与 index 不可混用、锁的恢复、删除的确认); - 三层防漂移:提示词中点名的工具名,靠“正则自守护测试 + 服务器权威名称表比对 + (早期方案的)live 卡片定时漂移检查”三层守住,且明确“测试红了是真实发现,不许改文案迁就”;
- 配置即契约:schemastery schema 让每个字段 parse 后必然填充,默认值、格式校验(
serverName正则)、以及“缺失 key 是 no-op 而非启动失败”的可用性决策都集中在一个Config里表达; - fake context 锁住桥接配置:不启动 Harness,用一个记录调用的假上下文把
transport、url拼接、x-api-key头、超时等不变量全部钉死在测试里。
对想在 Reactive Resume 生态里开发或扩展 Harness 插件的开发者来说,这条链路——MCP 端点(/mcp)+ dsh-mcp-client 桥接 + systemPrompt.section 注入 + 工具名一致性测试——提供了一个从独立仓 spike 到 monorepo 落地、且每个设计决策都能追溯到证据(spike 报告、源码注释、测试断言)的完整参照。
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 StartedRust0623
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