前几篇把 MCP Server 从 stdio 写到 HTTP,从单 Agent 写到多 Agent。功能一路加,有一个问题一直没碰:这些代码怎么测。
不是没测过。每次改完我都习惯性地起个服务,挂到 Claude Desktop 或自己的 Agent 上跑一轮,看着它把 search_notes 调对了,就认为没问题。
直到有一次,我把 search_notes 的参数名从 keyword 改成了 query。理由是"跟其他工具统一"。改完照例手测了一遍——模型还是能把笔记搜出来,因为我同时改了工具描述,它照着新描述填了新参数名。
但两天后有人反馈: "最近搜东西老是搜不准。"
没有异常,没有 5xx,日志干净得很。我翻了半天才发现,另一个客户端缓存了旧的工具清单,参数名对不上,模型每次都在瞎猜,而 Server 很诚实地拿着一个 undefined 去搜索,返回"没有找到"。
这件事让我意识到 MCP Server 有一类特别的 bug:它不会报错,只会变笨。
参数类型错了、id 不存在,这些会抛 isError,好查。但工具改名、参数改名、描述改模糊、返回结构变了一层嵌套——这些改动全部合法,HTTP 200,日志正常,只有模型的行为会悄悄变差。而模型不会给你发 issue。
这篇文章写两层防线:契约快照锁住模型看到的那份 schema,回放测试锁住整条调用链。八个用例,内存里跑,不起端口不用网络。
为什么 MCP Server 比普通接口难测
普通 HTTP 接口好测,因为输入输出都是确定的:GET /users/1 就该返回那个 user。MCP Server 不一样,有三个结构性困难。
第一,真正的调用方不是你的代码。 你没法写 expect(search_notes({keyword:"MCP"})).toBe(...) 就完事——因为决定"调哪个工具、参数填什么"的是模型,不是你。你能断言的只有"工具被正确调用时,返回是不是对的",以及"工具被错误调用时,有没有给出有用的线索"。
第二,常规跑法太重。 起一个端口、配一份客户端 JSON、拉一个 Host 起来,才能点一下。这套流程跑一次两分钟,跑两次就没人愿意跑了。而这类改动恰恰是最需要每改必跑的。
第三,它的"接口定义"本身就是提示词。 这一点前几篇反复说过:工具描述、参数说明、枚举值、错误文案,全都会进模型上下文。所以改一句描述,等于改了一句 prompt——而你不会有任何编译错误提醒。
第三点是根子上的。既然定义即 prompt,那测试也该盯住"模型看到的那份东西",而不只是"handler 算得对不对"。
第一步:先把 Server 变成可测的
原来的 server.ts 里,createNotesServer() 和 httpServer.listen(3001) 写在一起。这意味着任何测试只要 import 它,就会占住 3001 端口并挂住进程。
这是个很典型的可测性障碍,而且改起来只要五分钟:把"注册能力"和"启动传输"拆成两个文件。
// src/server-core.ts —— 只有能力,没有传输
export function createNotesServer(seed: Note[] = SEED_NOTES) {
const notes: Note[] = structuredClone(seed); // 每个实例一份独立数据
const server = new McpServer({ name: "notes-server", version: "1.0.0" });
// ... registerTool / registerResource / registerPrompt
return server;
}
// src/server.ts —— 只有传输,二十行
import { createNotesServer } from "./server-core.js";
const httpServer = createServer(async (req, res) => {
const server = createNotesServer();
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
await server.connect(transport);
await transport.handleRequest(req, res, body);
});
httpServer.listen(3001);
注意那个 structuredClone。原来 notes 是模块级常量,所有实例共享——听起来没问题,直到你写了"保存一条笔记"的测试:第一个用例存进去的数据,会出现在第二个用例里。测试跑第一次全绿、跑第二次开始挂,十个里有八个是这种模块级状态。
数据进工厂函数,每个 server 实例拿一份,这个坑就没了。顺带说一句,这也让"造一个只有一条笔记的 Server"变成一行:createNotesServer([{ id: "n1", ... }])。
传输那一侧,测试用 SDK 自带的内存管道,不开端口:
export async function connectInMemory(server: McpServer): Promise<Client> {
const [clientT, serverT] = InMemoryTransport.createLinkedPair();
const client = new Client({ name: "contract-test", version: "0.0.1" }, { capabilities: {} });
await Promise.all([server.connect(serverT), client.connect(clientT)]);
return client;
}
createLinkedPair() 返回一对互相连通的 transport,一个给 Server 一个给 Client。之后你调 client.callTool(),走的是完整的 JSON-RPC 编解码、zod 校验、handler 执行——除了网络那一层,其他全是真代码。
快到有点反直觉:连跑 300 次 search_notes 取平均,单次往返 0.034 毫秒。走 HTTP 至少是毫秒级,因为要过一轮 TCP 和 JSON 序列化。这意味着你不需要"精简测试用例",想加就加。
第二步:契约快照,锁住模型看到的那份 schema
这是整套测试里我最想推荐的部分。
思路很简单:把 tools/list、resources/list、prompts/list 的结果导成一份可读的文本,提交进仓库。以后每次改代码,跑一次比对,不一致就失败并打出 diff。
难点在"怎么导"。直接 JSON.stringify 不行,两个原因:
一是协议噪声。tools/list 每个工具都带 $schema、execution: {taskSupport:"forbidden"} 这类协议层加的字段,不是我们维护的,进快照只会制造假 diff。
二是JSON 没法读 diff。一整行几 KB 的 JSON,改一个参数名,diff 出来是"整行删掉整行新增",你根本看不出改了什么。
所以导出时做三件事:排序、去噪、按行展开。
const NOISE = new Set(["$schema", "execution", "_meta"]);
export async function dumpContract(client: Client): Promise<string> {
const lines: string[] = [];
const { tools } = await client.listTools();
lines.push(`## tools (${tools.length})`);
for (const t of [...tools].sort((a, b) => a.name.localeCompare(b.name))) {
lines.push(`### ${t.name}`);
lines.push(`title: ${t.title ?? ""}`);
lines.push(`description: ${t.description ?? ""}`);
lines.push(`inputSchema: ${JSON.stringify(stable(stripNoise(t.inputSchema)))}`);
if (t.outputSchema) lines.push(`outputSchema: ${JSON.stringify(stable(stripNoise(t.outputSchema)))}`);
lines.push("");
}
// resources / prompts 同理
return lines.join("\n");
}
导出来的东西长这样,三十几行,一眼能读完:
## tools (3)
### get_note
title: 读取笔记全文
description: 根据 search_notes 返回的 id 读取笔记的完整内容。id 必须来自搜索结果。
inputSchema: {"properties":{"id":{"description":"笔记 id,例如 n1","type":"string"}},"required":["id"],"type":"object"}
### search_notes
title: 搜索笔记
description: 按关键词搜索笔记的标题、正文和标签,返回匹配笔记的 id、标题和摘要。只返回摘要,不返回全文。
inputSchema: {"properties":{"keyword":{"description":"要搜索的关键词,中文或英文都行","minLength":1,"type":"string"},"limit":{"default":5,"description":"最多返回几条,默认 5","maximum":10,"minimum":1,"type":"integer"}},"required":["keyword"],"type":"object"}
## resources (1)
- notes://index | notes-index | text/markdown
## prompts (1)
- weekly-review(topic) | 把与某个主题相关的笔记整理成「进展 / 问题 / 下一步」三段周报
这份文件的价值不在"能跑通",在于它是模型看到的那份东西的全集。工具名、标题、描述、参数名、参数说明、必填项、默认值、枚举、范围约束——全在里面。任何一个变了,测试都会告诉我。
用例本身短得不像话:
test("工具契约没有意外变化", async () => {
const client = await connectInMemory(createNotesServer());
const actual = await dumpContract(client);
await client.close();
if (!fs.existsSync(SNAPSHOT) || process.env.UPDATE_SNAPSHOT === "1") {
fs.writeFileSync(SNAPSHOT, actual);
return;
}
const expected = fs.readFileSync(SNAPSHOT, "utf8");
if (expected !== actual) {
assert.fail(`契约变了:\n\n${diffLines(expected, actual)}\n\n` +
`确认这是有意的改动,再执行:UPDATE_SNAPSHOT=1 npm test`);
}
});
我用的是 Node 自带的 node:test,没有装 vitest 或 jest。理由很实际:这套测试不需要 mock 框架、不需要浏览器环境、不需要覆盖率报告,node --test 够了。多一个依赖,就多一个半年后跑不起来的原因。
第三步:行为测试,锁住"纠错线索"
契约快照锁的是模型看到的声明。但有一类东西不在声明里,同样影响模型——错误信息。
第二篇里踩过一个坑:get_note 拿不到笔记时,如果直接 throw,就变成了传输层错误,模型只能看到"tool call failed",它不知道该怎么办。正确做法是标 isError: true,并在文本里写清楚可用 id:
if (!note) {
return {
isError: true,
content: [{ type: "text", text: `笔记 ${id} 不存在,可用 id 有:${notes.map(n => n.id).join(", ")}` }],
};
}
这段代码的意义不在于"返回了错误",而在于"返回了一句能让模型自我纠正的话"。所以测试要断言的也不是 isError === true,而是那句线索还在:
test("get_note 传错 id 时返回 isError,并列出可用 id", async () => {
const client = await connectInMemory(createNotesServer());
const res = await client.callTool({ name: "get_note", arguments: { id: "n404" } });
await client.close();
assert.equal(res.isError, true);
// 这句话是给模型看的纠错线索,少了它模型只能瞎猜
assert.match(res.content[0].text, /可用 id 有:n1, n2, n3/);
});
以后谁"顺手优化"了这句文案,把可用 id 列表去掉,测试会拦住。这不是洁癖——那句话删掉,模型的自愈能力就跟着没了,而线上表现只是"偶尔卡住重试"。
同类的还有一条:search_notes 应该只返回摘要不返回全文。这条不是靠模型自觉,是靠截断 40 字实现的,所以可以直接断言正文后半段不该出现:
assert.doesNotMatch(text, /服务端可以选择回普通 JSON 还是 SSE 流/);
第四步:回放,把模型的决定录下来反复跑
前两层都是单点验证。真正容易出问题的是组合:搜索拿到的 id,传给读全文还用得上吗?传错了之后,下一步纠得回来吗?
这类问题以前只能靠真模型跑一遍。但每跑一次要真实 key、真实网络、几秒到几十秒,还不稳定。折中方案是回放:把模型当初做的决定录成 fixture,之后拿这份录好的决定,去跑真实的 MCP 链路。
{
"name": "搜索 MCP 并总结",
"user": "帮我在笔记里找一下 MCP 相关的记录,然后总结给我",
"turns": [
{
"tool_calls": [
{ "name": "search_notes", "arguments": { "keyword": "MCP", "limit": 5 } }
]
},
{
"tool_calls": [
{ "name": "get_note", "arguments": { "id": "n2" } }
]
},
{ "content": "MCP 和 Function Calling 的区别在于……" }
]
}
跑它的代码二十行,核心就是把 tool_calls 一条条打到真实 client 上,记下每次的真实返回:
for (const turn of fixture.turns) {
if ("tool_calls" in turn) {
for (const call of turn.tool_calls) {
const res = await client.callTool({ name: call.name, arguments: call.arguments });
calls.push({ name: call.name, args: call.arguments, isError: !!res.isError, result: textOf(res) });
}
} else {
final = turn.content;
}
}
断言分两类。第一类是结构一致性——第二步用的 id,必须真的出现在第一步的结果里:
const ids = [...calls[0].result.matchAll(/[(n\d+)]/g)].map((m) => m[1]);
assert.ok(ids.includes(String(calls[1].args.id)),
`get_note 用的 id ${calls[1].args.id} 不在搜索结果 ${ids.join(",")} 里`);
这条断言看着像废话(fixture 明明是我自己写的),但它抓的是接口退化:如果哪天 search_notes 的返回格式从 - [n1] 标题 改成了别的形状,这条会立刻炸——因为它提取不出 id 了。而线上表现,仍然是"模型偶尔读不到笔记"。
第二类是纠错链路:
assert.equal(calls[0].isError, true);
assert.match(calls[0].result, /可用 id 有:n1, n2, n3/);
assert.equal(calls[1].isError, false); // 第二跳必须成功,否则线索断了
这里必须说清边界:回放锁得住"工具改名、参数改名、返回结构变了、错误分支没了",锁不住"模型会不会选对工具"。后者只能靠真模型跑评测,回放替代不了。
但它便宜到可以每次提交都跑——这才是它能起作用的原因。一个跑得慢的测试,等于没有测试。
跑一次真实改动,看它能不能抓住
上面都是"应该能"。我直接改了一次,看它到底报什么。
把 search_notes 的参数从 keyword 改名成 query,描述不变,handler 逻辑同步改掉。这是完全合法、能编译、能跑通的改动。然后 npm test:
# tests 8
# pass 4
# fail 4
四个挂了。契约那一条的报错是这样的:
契约变了。下面是模型能看到的差异(- 是线上,+ 是现在):
- inputSchema: {"properties":{"keyword":{"description":"要搜索的关键词,中文或英文都行",
"minLength":1,"type":"string"},"limit":{...}},"required":["keyword"],"type":"object"}
+ inputSchema: {"properties":{"limit":{...},"query":{"description":"要搜索的关键词,中文或英文都行",
"minLength":1,"type":"string"}},"required":["query"],"type":"object"}
keyword 和 query 就摆在那儿,一眼看完。- 是线上,+ 是现在。
回放那条挂得更直接:
not ok 7 - 回放:搜索 → 读全文 → 总结,三步都能接住
Expected values to be strictly equal:
true !== false
因为 fixture 里录的是 { keyword: "MCP" },打到新服务上变成参数校验失败:
MCP error -32602: Input validation error: Invalid arguments for tool search_notes:
Invalid input: expected string, received undefined at query
行为测试挂的原因一样。八个用例里四个挂,覆盖了改名这件事的三个面向:声明变了、调用断了、纠错线索的断言失效了。
而我如果没写这些测试,这次改动会安静地上线——模型在旧客户端上继续填 keyword,服务端拿到 undefined,返回"没有找到包含「undefined」的笔记"。用户看到的只是"最近搜不准了"。
什么时候不值得写这套测试
诚实一点说,这套东西有维护成本:快照会频繁变,fixture 会过时,每次有意改动都要 UPDATE_SNAPSHOT=1 重写并 review diff。这些成本换来的收益,取决于你的 Server 有几个"用户"。
我的判断标准:
| 情况 | 建议 |
|---|---|
| 自己写、只挂在自己电脑的 Host 里、三五个工具 | 一个冒烟测试足够,别搞快照 |
| 有别人在用,或要发布出去 | 契约快照必须有 |
| 工具描述改过一轮以上,或者多人协作改 | 快照 + 行为测试 |
| 接了多个客户端(自研 Agent、Cursor、别人写的 Host) | 三层都上,尤其回放 |
| 工具带副作用(写文件、发消息、下单) | 再加一层:副作用前后状态断言 |
最后一行是我想单独提的:前面所有测试都只验证了"返回对不对",没验证"该不该做"。save_note 被错误调用一次,返回是成功的,测试全绿,但数据脏了。带副作用的工具,测试得断言"调用前 3 条、调用后 4 条"这种状态变化——createNotesServer() 每实例独立数据的设计,正好让这件事变得可行。
总结
- MCP Server 的大部分线上问题不会抛异常,只会让模型变笨,所以必须靠测试主动发现
- 可测性的第一步是把
createNotesServer()和httpServer.listen()拆开,顺带把模块级数据搬进工厂函数 - 用
InMemoryTransport.createLinkedPair()直连,单次调用往返 0.034 毫秒,不开端口不走网络 - 契约快照要排序、去噪、按行展开:JSON 的 diff 看不出改了什么,行的 diff 可以
- 断言错误信息的内容,不只是断言
isError——那句话是模型自我纠正的唯一依据 - 回放锁得住接口退化,锁不住模型选得对不对,别指望它替代真实评测
- 八个用例的断言总耗时不到 100 毫秒,剩下两秒多全是 tsx 的启动开销,便宜到可以每次提交都跑
想自己搭一遍,一共三个文件加一个目录,两分钟能跑起来:
npm i @modelcontextprotocol/sdk zod
npm i -D tsx typescript @types/node
src/
├── server-core.ts # registerTool 那一层,导出 createNotesServer()
└── testing/
├── contract.ts # connectInMemory + dumpContract + diffLines
└── replay.ts # replay(client, fixture)
test/
├── contract.test.ts # 契约快照
├── behavior.test.ts # 行为测试
├── replay.test.ts # 回放
├── fixtures/*.json
└── __snapshots__/contract.txt
package.json 里加一行 "test": "node --import tsx --test test/*.test.ts",然后 npm test。
环境是 Node 22 + @modelcontextprotocol/sdk 1.30 + zod 4,测试用 node:test,不需要装 vitest、jest 或任何 mock 框架。
适用边界:这套测试解决的是"我的改动有没有破坏已有契约",不解决"我的工具设计得好不好"。后者只能靠真实模型跑评测集,或者更土的办法——自己当用户用一周。先把前者做掉,因为它是纯收益:一次写好,之后每次改动都有兜底。