别让大模型陪你跑测试:MCP Server 的分层测试实践

发布时间:2026/10/13 11:37:04
别让大模型陪你跑测试:MCP Server 的分层测试实践
别让大模型陪你跑测试MCP Server 的分层测试实践前言前面几篇把 MCP 的安全话题聊得差不多了从整体的安全边界到工具描述投毒、供应链再到权限模型安全三部曲算是收了尾。从这篇开始转向工程化——一个 MCP Server 从能跑到敢上线中间缺的东西。第一个话题是测试。MCP Server 的测试有个天然的坑整条链路的大脑是大模型而大模型是不确定的。同一句输入这次调工具、下次直接回答参数这次是合法 JSON、下次缺个字段。很多人因此干脆不写测试上线全靠手动聊两轮——聊完感觉没问题就发了。但回头看我们修过的 bug绝大多数根本轮不到大模型出场工具 schema 写错导致客户端解析失败、工具抛异常没被捕获导致连接挂掉、stdio 里混进了日志导致消息流错乱。这些问题在协议层和工具层就能测干净不需要任何模型参与。这篇文章讲我怎么把 MCP Server 拆成三层来测协议层、工具层、传输层各测各的大模型只出现在最后一步冒烟。文中代码基于 TypeScript 官方 SDKmodelcontextprotocol/sdk全部实测跑通。一、先分层大模型到底在哪一环一个 MCP Server 从下到上可以拆成三层传输层字节怎么进来出去。stdio 模式是标准输入输出按行分隔HTTP 模式是 Streamable HTTP。这一层的 bug 表现为消息收不到/收到乱的。协议层JSON-RPC 2.0 的方法分发。initialize 握手、tools/list 列工具、tools/call 调工具加上错误码的约定。这一层的 bug 表现为客户端握手失败/列出来的工具缺字段。工具层你真正写的业务函数。schema 定义、输入校验、执行逻辑、错误返回。日常 bug 大部分在这层。分层之后测试策略就清楚了工具层跑普通单元测试协议层用 SDK 的客户端直连测传输层做一条集成链路最后才让真 LLM 跑一遍冒烟。反过来一上来就用 Claude/Cursor 连着聊是最慢的调试方式每次失败你都不知道是哪层的问题。二、协议层用 InMemoryTransport 直连不碰进程不碰网络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后面的测试都建立在这个前提上。2.1 测 tools/listschema 是契约不是注释工具清单是客户端注入给大模型的菜单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 校验 描述非空检查就能拦住。2.2 测 tools/call分清两种失败MCP 有个新手必踩的概念区分协议错误和工具执行错误是两条路。请求一个不存在的工具、参数不符合 schema → JSON-RPC 协议错误客户端会收到异常工具执行了但业务上失败查不到数据、文件不存在→ 正常返回但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); });第二种尤其重要。如果工具实现里直接throwSDK 会把它包装成协议错误返回——有的客户端遇到协议错误会把整个会话打断而isError结果会被当成普通的工具输出交还给模型模型还能据此重试或换路。业务失败也应该是模型可读的输入这个设计意图要靠测试固化下来。三、工具层把业务函数拆出来别隔着协议测逻辑协议层测试解决能不能被正确调用工具内部的逻辑还是老老实实写单元测试。前提是结构上可测工具注册和业务逻辑分离。// 业务函数纯逻辑不碰 MCP export async function getTodo(id: string): PromiseTodo { 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 模式下传输层最容易出两个事故我都真实踩过坑一日志污染协议通道。stdio 模式里 stdout 是协议专用通道任何库往 stdout 打印一行日志那一行会被当成一条 JSON-RPC 消息解析直接报错或者更糟——悄悄错位。测法很简单把 server 当子进程拉起来往 stdin 喂标准消息流逐行校验 stdout 的每一行都是合法 JSONimport { 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断言三件事模型确实决定调用了目标工具而不是自己编了答案工具输出回到对话后最终回答里包含关键事实从工具结果里来的不可能是模型先验知识的那种比如刚才创建的待办 ID整个过程没有协议错误日志。LLM 的不确定性意味着断言必须宽容不断言具体措辞不断言调用路径细节只断言调没调和事实有没有进来。这条测试跑一次几十秒它不负责发现 bug——前三层已经把 bug 清完了——它负责确认拼起来之后世界仍然正常。写在最后总结一下这套分层测试的性价比排序协议层 InMemoryTransport 直连零环境依赖、毫秒级、覆盖握手/schema/错误语义是性价比最高的一层没有理由不写工具层单元测试常规工程重点是错误翻译层的断言传输层集成测试就防 stdio 日志污染和不优雅退出这两个惯犯LLM 端到端冒烟一条就够断言宽容跑在 CI 的最后一步。原则一句话把大模型挡在测试金字塔的最顶端。它是最贵的测试资源也是最不稳定的断言工具——能用 JSON Schema 断言的事情别让 token 替你判断。下一篇继续工程化话题可观测性——server 上线之后你怎么知道模型都在拿你的工具干什么。