前面几篇把 MCP 的安全话题聊得差不多了:从整体的安全边界,到工具描述投毒、供应链,再到权限模型,安全三部曲算是收了尾。从这篇开始转向工程化——一个 MCP Server 从"能跑"到"敢上线"中间缺的东西。
第一个话题是测试。MCP Server 的测试有个天然的坑:整条链路的"大脑"是大模型,而大模型是不确定的。同一句输入,这次调工具、下次直接回答;参数这次是合法 JSON、下次缺个字段。很多人因此干脆不写测试,上线全靠手动聊两轮——聊完感觉没问题就发了。
但回头看我们修过的 bug,绝大多数根本轮不到大模型出场:工具 schema 写错导致客户端解析失败、工具抛异常没被捕获导致连接挂掉、stdio 里混进了日志导致消息流错乱。这些问题在协议层和工具层就能测干净,不需要任何模型参与。
这篇文章讲我怎么把 MCP Server 拆成三层来测:协议层、工具层、传输层各测各的,大模型只出现在最后一步冒烟。文中代码基于 TypeScript 官方 SDK(@modelcontextprotocol/sdk),全部实测跑通。
一个 MCP Server 从下到上可以拆成三层:
分层之后测试策略就清楚了:工具层跑普通单元测试,协议层用 SDK 的客户端直连测,传输层做一条集成链路,最后才让真 LLM 跑一遍冒烟。反过来(一上来就用 Claude/Cursor 连着聊)是最慢的调试方式,每次失败你都不知道是哪层的问题。
SDK 里有个专门为测试准备的宝贝:InMemoryTransport.createLinkedPair()。它创建一对内存里直连的传输管道,一端接你的 Server,一端接 SDK 的 Client——完整走协议,但没有子进程、没有端口、没有网络,单测跑起来毫秒级。
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
async function makePair() {
const server = new Server(
{ name: "todo-server", version: "1.0.0" },
{ capabilities: { tools: {} } }
);
const client = new Client({ name: "test-client", version: "1.0.0" });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await Promise.all([
server.connect(serverTransport),
client.connect(clientTransport),
]);
return { server, client };
}client.connect() 内部会自动完成 initialize 握手。所以第一组测试就白送了:如果这段代码能跑通,说明握手和能力协商没问题。把它写进 beforeEach,后面的测试都建立在这个前提上。
工具清单是客户端注入给大模型的"菜单",schema 写错,轻则模型传错参数,重则客户端直接拒绝渲染这个工具。用 Ajv 之类的校验器把每个工具的 inputSchema 过一遍:
import Ajv from "ajv";
test("每个工具的 inputSchema 都是合法的 JSON Schema", async () => {
const { client } = await makePair();
const { tools } = await client.listTools();
expect(tools.length).toBeGreaterThan(0);
const ajv = new Ajv({ strict: false });
for (const tool of tools) {
expect(() => ajv.compile(tool.inputSchema)).not.toThrow();
expect(tool.description?.length ?? 0).toBeGreaterThan(10);
}
});这里有个容易忽略的点:inputSchema 必须是 type: "object" 的完整 JSON Schema,而你用 Zod 定义工具时 SDK 会帮你转换——转换结果对不对,测一下才知道。我曾经把一个 .describe() 写漏了,schema 转出来能过编译,但模型永远猜不对那个参数的用途,这种问题 schema 校验 + 描述非空检查就能拦住。
MCP 有个新手必踩的概念区分:协议错误和工具执行错误是两条路。
isError: true,错误信息在 content 里。两种都要测:
test("不存在的工具返回协议错误", async () => {
const { client } = await makePair();
await expect(client.callTool({
name: "no_such_tool",
arguments: {},
})).rejects.toThrow();
});
test("工具业务失败返回 isError 而不是抛异常", async () => {
const { client } = await makePair();
const result = await client.callTool({
name: "get_todo",
arguments: { id: "not-exist" },
});
expect(result.isError).toBe(true);
const text = (result.content as Array<{ type: string; text: string }>)
.filter((c) => c.type === "text")
.map((c) => c.text)
.join("");
expect(text).toContain("not found");
});第二种尤其重要。如果工具实现里直接 throw,SDK 会把它包装成协议错误返回——有的客户端遇到协议错误会把整个会话打断,而 isError 结果会被当成普通的工具输出交还给模型,模型还能据此重试或换路。"业务失败也应该是模型可读的输入",这个设计意图要靠测试固化下来。
协议层测试解决"能不能被正确调用",工具内部的逻辑还是老老实实写单元测试。前提是结构上可测:工具注册和业务逻辑分离。
// 业务函数:纯逻辑,不碰 MCP
export async function getTodo(id: string): Promise<Todo> {
const todo = await store.find(id);
if (!todo) throw new TodoError(`Todo ${id} not found`);
return todo;
}
// 注册层:薄壳,只做 schema 绑定和错误翻译
server.setRequestHandler(CallToolRequestSchema, async (request) => {
try {
// 按 name 分发到业务函数
...
} catch (e) {
if (e instanceof TodoError) {
return { content: [{ type: "text", text: e.message }], isError: true };
}
throw e; // 未预期的错误继续抛,交给 SDK 的兜底
}
});业务函数测输入边界、并发、存储交互,都是常规操作。想强调的是错误翻译层值得单独测一遍:每个业务错误类型,最终在协议层看到的是什么形态(isError 文本?还是意外抛出?)。上篇讲权限模型时提过错误信息会进模型上下文,这里同理——��误文本就是给模型读的 API 文档,得像对待正常输出一样对它做断言。
stdio 模式下传输层最容易出两个事故,我都真实踩过:
坑一:日志污染协议通道。 stdio 模式里 stdout 是协议专用通道,任何库往 stdout 打印一行日志,那一行会被当成一条 JSON-RPC 消息解析,直接报错或者更糟——悄悄错位。测法很简单:把 server 当子进程拉起来,往 stdin 喂标准消息流,逐行校验 stdout 的每一行都是合法 JSON:
import { spawn } from "node:child_process";
test("stdout 的每一行都是合法 JSON-RPC", async () => {
const child = spawn("node", ["dist/server.js"], { stdio: "pipe" });
const lines: string[] = [];
child.stdout.on("data", (chunk) => {
lines.push(...chunk.toString().split("\n").filter(Boolean));
});
child.stdin.write(JSON.stringify({
jsonrpc: "2.0", id: 1, method: "initialize",
params: {
protocolVersion: "2025-06-18",
capabilities: {},
clientInfo: { name: "test", version: "1.0.0" },
},
}) + "\n");
await new Promise((r) => setTimeout(r, 500));
for (const line of lines) {
expect(() => JSON.parse(line)).not.toThrow();
}
child.kill();
});这条测试配上一条"import 顺序即日志开关"的团队约定(入口文件第一行把日志重定向到 stderr),基本能杜绝这类事故。
坑二:进程不优雅退出。 客户端断开后 server 还挂着,占着端口占着文件句柄。测法是发起断开后断言子进程在超时时间内退出,顺便把 SIGTERM 处理里的清理逻辑(关连接、刷缓冲)覆盖到。
HTTP 传输相对简单,Streamable HTTP 有官方的 StreamableHTTPClientTransport,起一个本地端口做一轮完整握手即可,不展开。
前三层都绿了之后,才让真模型进场跑端到端:起一个真实 server 进程,用真实客户端(或 Inspector headless 模式)连上,给一句固定 prompt,断言三件事:
LLM 的不确定性意味着断言必须宽容:不断言具体措辞,不断言调用路径细节,只断言"调没调"和"事实有没有进来"。这条测试跑一次几十秒,它不负责发现 bug——前三层已经把 bug 清完了——它负责确认拼起来之后世界仍然正常。
总结一下这套分层测试的性价比排序:
原则一句话:把大模型挡在测试金字塔的最顶端。它是最贵的测试资源,也是最不稳定的断言工具——能用 JSON Schema 断言的事情,别让 token 替你判断。
下一篇继续工程化话题:可观测性——server 上线之后,你怎么知道模型都在拿你的工具干什么。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。