首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >别让大模型陪你跑测试:MCP Server 的分层测试实践

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

原创
作者头像
用户11136834
发布于 2026-10-12 01:28:13
发布于 2026-10-12 01:28:13
60
举报

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

前言

前面几篇把 MCP 的安全话题聊得差不多了:从整体的安全边界,到工具描述投毒、供应链,再到权限模型,安全三部曲算是收了尾。从这篇开始转向工程化——一个 MCP Server 从"能跑"到"敢上线"中间缺的东西。

第一个话题是测试。MCP Server 的测试有个天然的坑:整条链路的"大脑"是大模型,而大模型是不确定的。同一句输入,这次调工具、下次直接回答;参数这次是合法 JSON、下次缺个字段。很多人因此干脆不写测试,上线全靠手动聊两轮——聊完感觉没问题就发了。

但回头看我们修过的 bug,绝大多数根本轮不到大模型出场:工具 schema 写错导致客户端解析失败、工具抛异常没被捕获导致连接挂掉、stdio 里混进了日志导致消息流错乱。这些问题在协议层和工具层就能测干净,不需要任何模型参与。

这篇文章讲我怎么把 MCP Server 拆成三层来测:协议层、工具层、传输层各测各的,大模型只出现在最后一步冒烟。文中代码基于 TypeScript 官方 SDK(@modelcontextprotocol/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——完整走协议,但没有子进程、没有端口、没有网络,单测跑起来毫秒级。

代码语言:typescript
复制
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/list:schema 是契约,不是注释

工具清单是客户端注入给大模型的"菜单",schema 写错,轻则模型传错参数,重则客户端直接拒绝渲染这个工具。用 Ajv 之类的校验器把每个工具的 inputSchema 过一遍:

代码语言:typescript
复制
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 里。

两种都要测:

代码语言:typescript
复制
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 结果会被当成普通的工具输出交还给模型,模型还能据此重试或换路。"业务失败也应该是模型可读的输入",这个设计意图要靠测试固化下来。

三、工具层:把业务函数拆出来,别隔着协议测逻辑

协议层测试解决"能不能被正确调用",工具内部的逻辑还是老老实实写单元测试。前提是结构上可测:工具注册和业务逻辑分离。

代码语言:typescript
复制
// 业务函数:纯逻辑,不碰 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 模式下传输层最容易出两个事故,我都真实踩过:

坑一:日志污染协议通道。 stdio 模式里 stdout 是协议专用通道,任何库往 stdout 打印一行日志,那一行会被当成一条 JSON-RPC 消息解析,直接报错或者更糟——悄悄错位。测法很简单:把 server 当子进程拉起来,往 stdin 喂标准消息流,逐行校验 stdout 的每一行都是合法 JSON:

代码语言:typescript
复制
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,断言三件事:

  1. 模型确实决定调用了目标工具(而不是自己编了答案);
  2. 工具输出回到对话后,最终回答里包含关键事实(从工具结果里来的,不可能是模型先验知识的那种,比如"刚才创建的待办 ID");
  3. 整个过程没有协议错误日志。

LLM 的不确定性意味着断言必须宽容:不断言具体措辞,不断言调用路径细节,只断言"调没调"和"事实有没有进来"。这条测试跑一次几十秒,它不负责发现 bug——前三层已经把 bug 清完了——它负责确认拼起来之后世界仍然正常。

写在最后

总结一下这套分层测试的性价比排序:

  • 协议层 InMemoryTransport 直连:零环境依赖、毫秒级、覆盖握手/schema/错误语义,是性价比最高的一层,没有理由不写;
  • 工具层单元测试:常规工程,重点是错误翻译层的断言;
  • 传输层集成测试:就防 stdio 日志污染和不优雅退出这两个惯犯;
  • LLM 端到端冒烟:一条就够,断言宽容,跑在 CI 的最后一步。

原则一句话:把大模型挡在测试金字塔的最顶端。它是最贵的测试资源,也是最不稳定的断言工具——能用 JSON Schema 断言的事情,别让 token 替你判断。

下一篇继续工程化话题:可观测性——server 上线之后,你怎么知道模型都在拿你的工具干什么。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • 别让大模型陪你跑测试:MCP Server 的分层测试实践
    • 前言
    • 一、先分层:大模型到底在哪一环
    • 二、协议层:用 InMemoryTransport 直连,不碰进程不碰网络
      • 2.1 测 tools/list:schema 是契约,不是注释
      • 2.2 测 tools/call:分清两种"失败"
    • 三、工具层:把业务函数拆出来,别隔着协议测逻辑
    • 四、传输层:stdio 的两个经典坑,各值一条集成测试
    • 五、最后一步才请出大模型:一条冒烟测试就够了
    • 写在最后
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档