首页
/ Reactive Resume 的 dsh-plugin 实现计划深度解析:如何把 MCP 简历工具接入 DeepSeek Harness 会话

Reactive Resume 的 dsh-plugin 实现计划深度解析:如何把 MCP 简历工具接入 DeepSeek Harness 会话

2026-09-05 23:17:00作者:滑思眉Philip

本文围绕 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 /mcp endpoint, curates the tool surface, and teaches the model Reactive Resume's JSON Patch semantics.

拆解一下:插件通过 Reactive Resume 已有的 /mcp 端点把 Harness 会话连到用户账户,收敛暴露给模型的工具面,并教会模型 Reactive Resume 的 JSON Patch 语义。架构(Architecture)定义为:

一个命名空间的 Cordis 插件,导出 nameinjectConfigapply(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>,默认 serverNameresume
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 仍在 → NOtools 键按 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

报告的关键事实(全部来自实际运行与源码阅读):

  1. 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
    
  2. 修好异常后仍触达不到。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() 永远只作用于作用域从祖先继承的工具面。

  3. 报告还记录了环境细节:dsh-toolsnext 版本会把 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.jsontarget ES2023module ESNextmoduleResolution bundlerstrictnoUncheckedIndexedAccessverbatimModuleSyntaxallowImportingTsExtensionsnoEmit,include 为 srctestscripts
  • tsdown.config.ts:入口 src/index.ts,输出 lib/format: ['esm']dts: trueclean: true
  • vitest.config.tstest.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.jsfiles["dist", "cordis.patch.yml"],peer 版本推进到 @deepseek-ai/cordis ^4.0.1dsh-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,实际发布版有两处关键差异,均能追溯到计划之外的新约束:

  1. apiKey 不再是必填,改为 z.string().default("")。源码注释解释原因:bundle patch 在安装时就挂载该插件,所以缺失 key 必须是一个“惰性 no-op”,而不是把整个 profile 在启动时拖垮——apply 会打警告并什么都不挂载(见下文);
  2. serverName 的校验前移到 parse 阶段z.string().pattern(SERVER_NAME_PATTERN),模式 ^[A-Za-z0-9_-]{1,32}$ 在 schema 层就拒绝非法值,apply 不再需要自己抛错;
  3. 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.tserrorHint 里已经在输出的错误提示——那些提示存在,正是因为模型恰好会犯这些错。

测试锁定三件事:

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_resumesread_resumeapply_resume_patchupdate_resumeunlock_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-lockfilepnpm biome ci .pnpm typecheckpnpm testpnpm build

发布前校验包内容:

pnpm build && pnpm pack --dry-run

预期 tarball 只含 lib/**package.jsonREADME.mdLICENSE;出现 src/test/ 就修 files 字段。

真实会话冒烟测试(不通过就不发布):把打好的 tarball 装进一个 Harness workspace,配置 cordis.yml 行并注入真实 key,启动会话后逐条确认:

  1. “List my resumes” 返回真实标题;
  2. “Read <title>” 返回真实内容;
  3. “Change my headline to X” 应用后在站点上可见;
  4. (若 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.ymlid 覆盖任意字段(每行后写者胜)即可接入自部署实例。这与 README 的自部署示例(url: http://localhost:3000)对应。

十、可复用的工程方法论

这份实现计划的价值不只在插件本身,更在几个可迁移的做法:

  1. 先 spike 后承诺:对一个可能不成立的 API 假设(restrict() 的可见性),花不超过 30 分钟用最小桩探针拿到书面判定,判定结果直接门控公开 API 面——“事后删除公开配置键是破坏性变更”是这里的决策准则;
  2. 失败模式驱动提示词:系统提示词段落不是拍脑袋写的,而是镜像服务器 errorHint 中已经在输出的错误提示,把“模型实际会犯的错”编码为行为约束(先读后改、UUID 与 index 不可混用、锁的恢复、删除的确认);
  3. 三层防漂移:提示词中点名的工具名,靠“正则自守护测试 + 服务器权威名称表比对 + (早期方案的)live 卡片定时漂移检查”三层守住,且明确“测试红了是真实发现,不许改文案迁就”;
  4. 配置即契约:schemastery schema 让每个字段 parse 后必然填充,默认值、格式校验(serverName 正则)、以及“缺失 key 是 no-op 而非启动失败”的可用性决策都集中在一个 Config 里表达;
  5. fake context 锁住桥接配置:不启动 Harness,用一个记录调用的假上下文把 transporturl 拼接、x-api-key 头、超时等不变量全部钉死在测试里。

对想在 Reactive Resume 生态里开发或扩展 Harness 插件的开发者来说,这条链路——MCP 端点(/mcp)+ dsh-mcp-client 桥接 + systemPrompt.section 注入 + 工具名一致性测试——提供了一个从独立仓 spike 到 monorepo 落地、且每个设计决策都能追溯到证据(spike 报告、源码注释、测试断言)的完整参照。

登录后查看全文
热门项目推荐
相关项目推荐