Agent 不会报错,只会变笨:给 MCP Server 补上契约测试和回放测试

前几篇把 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 端口并挂住进程。

这是个很典型的可测性障碍,而且改起来只要五分钟:把"注册能力"和"启动传输"拆成两个文件。

javascript 复制代码
// 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;
}
ini 复制代码
// 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 自带的内存管道,不开端口:

javascript 复制代码
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 出来是"整行删掉整行新增",你根本看不出改了什么。

所以导出时做三件事:排序、去噪、按行展开。

typescript 复制代码
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");
}

导出来的东西长这样,三十几行,一眼能读完:

bash 复制代码
## 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) | 把与某个主题相关的笔记整理成「进展 / 问题 / 下一步」三段周报

这份文件的价值不在"能跑通",在于它是模型看到的那份东西的全集。工具名、标题、描述、参数名、参数说明、必填项、默认值、枚举、范围约束------全在里面。任何一个变了,测试都会告诉我。

用例本身短得不像话:

ini 复制代码
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:

python 复制代码
if (!note) {
  return {
    isError: true,
    content: [{ type: "text", text: `笔记 ${id} 不存在,可用 id 有:${notes.map(n => n.id).join(", ")}` }],
  };
}

这段代码的意义不在于"返回了错误",而在于"返回了一句能让模型自我纠正的话"。所以测试要断言的也不是 isError === true,而是那句线索还在:

dart 复制代码
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 字实现的,所以可以直接断言正文后半段不该出现:

javascript 复制代码
assert.doesNotMatch(text, /服务端可以选择回普通 JSON 还是 SSE 流/);

第四步:回放,把模型的决定录下来反复跑

前两层都是单点验证。真正容易出问题的是组合:搜索拿到的 id,传给读全文还用得上吗?传错了之后,下一步纠得回来吗?

这类问题以前只能靠真模型跑一遍。但每跑一次要真实 key、真实网络、几秒到几十秒,还不稳定。折中方案是回放:把模型当初做的决定录成 fixture,之后拿这份录好的决定,去跑真实的 MCP 链路。

css 复制代码
{
  "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 上,记下每次的真实返回:

php 复制代码
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,必须真的出现在第一步的结果里:

javascript 复制代码
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 了。而线上表现,仍然是"模型偶尔读不到笔记"。

第二类是纠错链路:

ini 复制代码
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:

arduino 复制代码
# tests 8
# pass 4
# fail 4

四个挂了。契约那一条的报错是这样的:

css 复制代码
契约变了。下面是模型能看到的差异(- 是线上,+ 是现在):

- 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 就摆在那儿,一眼看完。- 是线上,+ 是现在。

回放那条挂得更直接:

yaml 复制代码
not ok 7 - 回放:搜索 → 读全文 → 总结,三步都能接住
  Expected values to be strictly equal:
  true !== false

因为 fixture 里录的是 { keyword: "MCP" },打到新服务上变成参数校验失败:

lua 复制代码
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 的启动开销,便宜到可以每次提交都跑

想自己搭一遍,一共三个文件加一个目录,两分钟能跑起来:

bash 复制代码
npm i @modelcontextprotocol/sdk zod
npm i -D tsx typescript @types/node
bash 复制代码
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 框架。

适用边界:这套测试解决的是"我的改动有没有破坏已有契约",不解决"我的工具设计得好不好"。后者只能靠真实模型跑评测集,或者更土的办法------自己当用户用一周。先把前者做掉,因为它是纯收益:一次写好,之后每次改动都有兜底。

相关推荐
AI Coder 码农王2 小时前
AgentScope Java v2 第5课,两个注解背后的调用链
人工智能
Bode_20022 小时前
美的集团数智化创新‌
大数据·人工智能
kaixin_啊啊2 小时前
【零基础学AI】第 19 章课后练习与答案
人工智能
A小码哥2 小时前
RAG 召回差?先改切片和重排
人工智能
JAI科研2 小时前
把一个 Qwen3.8的聊天模型改造成 JEV 决策模型
人工智能·语言模型·自然语言处理·chatgpt·transformer·vllm·sglang
瓶瓶持续学习中2 小时前
空间智能与物理AI:国内厂商如何与英伟达协同发展
大数据·人工智能·信息可视化·架构
阿乔外贸日记2 小时前
外贸风控|美国市场7大类欺诈模式解析与风控框架
大数据·人工智能·物联网·microsoft·智能手机
金融小师妹2 小时前
时间序列分析:历次联储利率调整与政策周期变化研究
大数据·人工智能·逻辑回归
水如烟2 小时前
孤能子视角:算账要算到存续------脱锚环境下的能效判据层上移:安全与发展、认知与伦理,是存续层能效的两个对位轴
人工智能