首页 › tutorials › 给MCP服务器造一套测试体系

给MCP服务器造一套测试体系:从Inspector探索到CI自动化

概述

本教程面向已经能写出"能跑起来"的MCP服务器、却不知道怎么保证它"在Agent手里不翻车"的开发者。你将学会三层递进的测试策略:用MCP Inspector做交互式探索发现明显问题,用Inspector CLI模式做CI烟雾测试,用@modelcontextprotocol/sdk 客户端 + Vitest写断言级自动化测试。覆盖三原语(工具/资源/提示词)、两类错误通道、三种传输,并接入GitHub Actions流水线。学完即可对任何自研MCP服务器建立可复现、可回归的测试体系。

前置条件

时效性提醒:MCP规范在 2026-04-14 通过 SEP-2577(Final 状态)正式弃用 Roots、Sampling、Logging 三项核心特性。本教程刻意不依赖这三者——测试的是被官方长期支持的稳定特性(工具、资源、提示词、JSON-RPC错误码、进度通知)。如果你看到老教程大谈 sampling/createMessage 或 roots/list,那是在教已弃用特性,请谨慎。


第一步:先理解"MCP服务器到底要测什么"

很多人把MCP服务器当普通REST API测,这是第一个坑。MCP服务器是一份在运行时由你的服务器和LLM客户端协商的契约——模型读你的工具描述和JSON Schema,自己决定调哪个工具、传什么参数。Schema写松了,模型传垃圾进来;描述含糊了,模型调错工具;错误返回形状不对,模型直接迷路,而你拿不到堆栈,只看到"Agent变笨了"。

所以MCP测试要同时覆盖业务逻辑和协议信封两个层面。下面这张矩阵是整个教程的"靶子",后续每一步都在填它的格子:

原语 常见翻车点 必须断言什么
工具 Tool 输入Schema太松/写错 Schema形状、required字段、属性类型
工具 Tool 抛未捕获异常导致崩溃 返回结构化错误,而非进程退出
工具 Tool 副作用不正确 callTool后文件/数据库状态
资源 Resource MIME类型错乱 readResource的mimeType与content
资源 Resource URI陈旧或缺失 列表URI与可读URI一致
提示词 Prompt 必填参数缺失 服务器清晰报错而非返回空消息
协议 Protocol 错误码不符规范 JSON-RPC错误码与规范一致
传输 Transport 连接中断 重连/干净关闭行为

MCP最隐蔽的坑:两类错误通道

这是新手最常踩的雷,也是测试必须分别覆盖的点:

把这两条搞混(比如把业务失败抛成协议错误,或把内部故障包装成 isError:true)是MCP服务器最常见bug。后面的测试会分别打这两条通道。


第二步:准备被测服务器(一个"告警处置手册"服务器)

为了后面所有测试有靶子,我们先写一个覆盖三原语的真实服务器:电信运维场景下的告警处置手册查询服务。完整目录结构:

alarm-runbook-server/
├── package.json
├── tsconfig.json
├── src/index.ts          # 服务器实现
└── test/
    ├── setup.ts          # 测试客户端工厂
    └── runbook.test.ts   # 自动化测试套件

package.json(注意 "type": "module" 和依赖版本):

{
  "name": "alarm-runbook-server",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "build": "tsc",
    "start": "node build/index.js",
    "test": "vitest run",
    "smoke": "npx @modelcontextprotocol/inspector --cli node build/index.js --method tools/list"
  },
  "dependencies": {
    "@modelcontextprotocol/sdk": "^1.18.0"
  },
  "devDependencies": {
    "typescript": "^5.6.0",
    "vitest": "^2.1.0",
    "@types/node": "^22.0.0"
  }
}

tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "outDir": "build",
    "rootDir": "src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src"]
}

src/index.ts(被测服务器,刻意同时演示工具级错误与协议级错误):

import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
  ListToolsRequestSchema, CallToolRequestSchema,
  ListResourcesRequestSchema, ReadResourceRequestSchema,
  ListPromptsRequestSchema, GetPromptRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";

// 内存里的"手册库",真实项目换成数据库/文件
const RUNBOOKS: Record<string, { title: string; steps: string[] }> = {
  "ALARM-1001": { title: "gNB链路中断", steps: ["检查光纤物理连接", "查看NG接口SCTP状态", "联系传输班组确认承载网"] },
  "ALARM-2002": { title: "AMF注册成功率下降", steps: ["查看AMF指标看板", "检查UDM订阅响应", "排查UE鉴权失败码"] },
};

const server = new Server(
  { name: "alarm-runbook-server", version: "1.0.0" },
  { capabilities: { tools: {}, resources: {}, prompts: {} } }
);

// —— 工具 ——
server.setRequestHandler(ListToolsRequestSchema, async () => ({
  tools: [{
    name: "lookup_runbook",
    description: "根据告警码查询处置手册",
    inputSchema: {
      type: "object",
      properties: { alarm_code: { type: "string", description: "告警码,如 ALARM-1001" } },
      required: ["alarm_code"],
    },
  }],
}));

server.setRequestHandler(CallToolRequestSchema, async (req) => {
  const { name, arguments: args } = req.params;
  if (name !== "lookup_runbook") {
    // 未知工具 → 抛错 → 走"协议错误"通道(JSON-RPC -32601)
    throw new Error(`未知工具: ${name}`);
  }
  const code = String(args?.alarm_code ?? "");
  const rb = RUNBOOKS[code];
  if (!rb) {
    // 查不到手册 → 业务失败 → 走"工具级错误"通道(isError: true)
    return { content: [{ type: "text", text: `未知告警码: ${code}` }], isError: true };
  }
  const text = `【${rb.title}】处置步骤:\n` + rb.steps.map((s, i) => `${i + 1}. ${s}`).join("\n");
  return { content: [{ type: "text", text }] };
});

// —— 资源 ——
server.setRequestHandler(ListResourcesRequestSchema, async () => ({
  resources: Object.keys(RUNBOOKS).map((code) => ({
    uri: `runbook://${code}`, name: RUNBOOKS[code].title,
    mimeType: "text/markdown", description: `处置手册 ${code}`,
  })),
}));

server.setRequestHandler(ReadResourceRequestSchema, async (req) => {
  const m = req.params.uri.match(/^runbook:\/\/(.+)$/);
  if (!m) throw new Error(`资源URI无效: ${req.params.uri}`);
  const rb = RUNBOOKS[m[1]];
  if (!rb) throw new Error(`未找到手册: ${req.params.uri}`);
  const md = `# ${rb.title}\n\n## 处置步骤\n` + rb.steps.map((s, i) => `${i + 1}. ${s}`).join("\n");
  return { contents: [{ uri: req.params.uri, mimeType: "text/markdown", text: md }] };
});

// —— 提示词 ——
server.setRequestHandler(ListPromptsRequestSchema, async () => ({
  prompts: [{
    name: "incident_triage", description: "生成告警分诊提示词",
    arguments: [{ name: "alarm_code", description: "告警码", required: true }],
  }],
}));

server.setRequestHandler(GetPromptRequestSchema, async (req) => {
  const code = String(req.params.arguments?.alarm_code ?? "");
  return {
    messages: [{
      role: "user",
      content: { type: "text", text: `请对告警 ${code} 进行分诊,给出影响面、紧急度和首步处置。` },
    }],
  };
});

await server.connect(new StdioServerTransport());

预期结果:npm run build && npm start 后服务器静默运行在stdio上,无报错即说明三原语都已注册成功。

常见问题: - 报错 Cannot find module .../types.js:moduleResolution 没设成 Node16,SDK用 .js 后缀导入ESM子路径,必须用Node16解析。 - server.connect 卡住:stdio服务器本就不输出,这是正常的;要验证它活着,用下一步的Inspector。


第三步:用Inspector做交互式探索(UI模式)

Inspector是Anthropic官方的交互式调试工具,被社区称为"MCP界的Postman"。它不需要全局安装,直接 npx 启动。

# 编译后,对本地stdio服务器启动Inspector
npx @modelcontextprotocol/inspector node build/index.js

# 需要传环境变量给服务器时用 -e
npx @modelcontextprotocol/inspector -e API_KEY=test-key node build/index.js --verbose

# Python服务器也行
npx @modelcontextprotocol/inspector uv run my_mcp_server

启动后控制台打印一个本地URL(默认UI在 http://localhost:6274),自动打开浏览器。Inspector的架构是两个组件:

端口助记:6274 = M-C-P-I,6277 = M-C-P-P(T9九宫格字母映射)。

在UI里你要依次走查四件事,对应四个标签页:

  1. 连接握手:看初始化握手(capability negotiation)是否成功——服务器声明了 tools/resources/prompts 三项能力。
  2. Tools标签:列出 lookup_runbook,读它模型视角下的name、description、inputSchema。这一步最容易抓到"描述含糊"或"schema类型错"。
  3. 填表调用:填入 alarm_code=ALARM-1001 调用,看原始JSON-RPC请求/响应;再试 alarm_code=NOPE,确认返回 isError: true 而不是崩溃。
  4. Resources / Prompts标签:列出 runbook://ALARM-2002 确认MIME是 text/markdown;用 incident_triage 填参预览渲染出的消息。
  5. Notifications面板:抓日志、进度、错误通知(注意:日志通知 notifications/message 已被SEP-2577弃用,新代码请改用进度通知或自定义通知,这里只用于排查遗留服务器)。

UI探索的目的不是写测试,而是快速发现明显问题——一个描述让人困惑的工具、一个该返回JSON却返回HTML的资源。先在这里把"显而易见的坑"清掉,再去把发现自动化。

常见问题: - 浏览器没自动打开:手动访问控制台打印的 localhost:6274。 - Docker环境跑不起来UI:用官方容器,并关掉自动打开: bash docker run --rm -p 127.0.0.1:6274:6274 -p 127.0.0.1:6277:6277 \ -e HOST=0.0.0.0 -e MCP_AUTO_OPEN_ENABLED=false \ ghcr.io/modelcontextprotocol/inspector:latest


第四步:Inspector CLI模式——CI里的烟雾测试

浏览器UI没法塞进CI。Inspector为此提供了CLI模式:加 --cli 后它变成命令行客户端,连上服务器、发一条方法调用、把JSON打到stdout、用有意义的退出码退出。这是CI里最便宜的回归防线。

# 列出所有工具(绝佳烟雾测试)
npx @modelcontextprotocol/inspector --cli node build/index.js --method tools/list

# 带参数调用某个工具
npx @modelcontextprotocol/inspector --cli node build/index.js \
  --method tools/call \
  --tool-name lookup_runbook \
  --tool-arg alarm_code=ALARM-1001

# 列资源和提示词
npx @modelcontextprotocol/inspector --cli node build/index.js --method resources/list
npx @modelcontextprotocol/inspector --cli node build/index.js --method prompts/list

因为CLI输出结构化JSON,可以直接管道给 jq 做断言:

# 如果预期的 lookup_runbook 工具消失了,就让构建失败
npx @modelcontextprotocol/inspector --cli node build/index.js --method tools/list \
  | jq -e '.tools[] | select(.name == "lookup_runbook")' > /dev/null \
  || { echo "lookup_runbook 工具丢失"; exit 1; }

两种模式选型对照:

维度 UI模式 CLI模式(--cli)
最适合 探索、调试 CI烟雾测试、脚本化
输出 交互式React UI stdout上的JSON
人在回路 是 否
退出码 不适用 有,可脚本化
传输 stdio / SSE / HTTP stdio / SSE / HTTP

CLI烟雾测试便宜且能抓最致命的回归——服务器起不来、工具莫名消失、握手坏掉。但它很浅。要真正的覆盖率,必须用SDK客户端写断言级测试。

连远程HTTP服务器时,把命令换成URL:

npx @modelcontextprotocol/inspector --cli \
  --transport http --server-url http://localhost:3000/mcp --method tools/list

第五步:用SDK客户端写自动化测试(核心)

最强力的测试方式是:在你的测试进程里用一个真实的MCP客户端连上服务器,对返回结果做断言。官方 @modelcontextprotocol/sdk 导出 Client 类和各种传输实现。

先装依赖:

npm install -D vitest @modelcontextprotocol/sdk

核心套路:把编译好的服务器当子进程用stdio拉起,连一个 Client 上去,测试结束后拆掉。用一个共享的setup helper保持每个测试干净。

test/setup.ts:

import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";

export async function createTestClient() {
  const transport = new StdioClientTransport({
    command: "node",
    args: ["build/index.js"],
    env: { ...process.env, NODE_ENV: "test" },
  });
  const client = new Client(
    { name: "mcp-test-client", version: "1.0.0" },
    { capabilities: {} }
  );
  await client.connect(transport);
  return { client, transport };
}

有了helper,"服务器能起、能列出工具"的烟雾测试只需几行——它已经能挡住一整类回归(重构不小心把工具注销了或改名了):

import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { createTestClient } from "./setup";
import type { Client } from "@modelcontextprotocol/sdk/client/index.js";

let client: Client, transport: { close: () => Promise<void> };
beforeAll(async () => {
  const ctx = await createTestClient();
  client = ctx.client;
  transport = ctx.transport;
});
afterAll(async () => transport.close());

describe("工具契约", () => {
  it("列出预期工具", async () => {
    const { tools } = await client.listTools();
    expect(tools.map((t) => t.name)).toContain("lookup_runbook");
  });
});

5.1 断言工具Schema(最该自动化的一项)

工具的输入Schema是模型依赖的契约:太松则模型传垃圾,太严或写错则合法调用失败。listTools 返回的每个工具都带 inputSchema(JSON Schema),可以直接断言它的required字段、属性类型、结构:

it("lookup_runbook 的输入schema正确", async () => {
  const { tools } = await client.listTools();
  const tool = tools.find((t) => t.name === "lookup_runbook");
  expect(tool).toBeDefined();
  expect(tool?.description).toBeTruthy();

  const s = tool!.inputSchema as {
    type: string;
    properties: Record<string, { type: string }>;
    required?: string[];
  };
  expect(s.type).toBe("object");
  expect(s.properties.alarm_code.type).toBe("string");
  expect(s.required).toContain("alarm_code");
});

进阶技巧:给每个工具的schema做快照,快照diff会让任何意外的schema改动在Code Review里立刻可见。声明了 outputSchema 的结构化输出工具,还要断言成功返回的 structuredContent 能通过它校验。

5.2 分别打两类错误通道(最容易漏的测试)

describe("错误通道", () => {
  it("未知告警码 → 工具级错误(isError:true),而非崩溃", async () => {
    const res = await client.callTool({
      name: "lookup_runbook",
      arguments: { alarm_code: "NOPE" },
    });
    expect(res.isError).toBe(true);              // 业务失败:可被模型自愈
  });

  it("调用不存在的工具 → 协议级错误(抛出JSON-RPC错误)", async () => {
    await expect(
      client.callTool({ name: "ghost_tool", arguments: {} })
    ).rejects.toThrow();                          // 协议错误:-32601
  });
});

这两个测试钉死了第二步强调的"两类错误通道"。如果某次重构有人把"未知告警码"改成抛异常(错误地升级成协议错误),或把"内部故障"包装成 isError:true,测试立刻红。

5.3 测副作用、资源、提示词

describe("资源", () => {
  it("readResource 返回正确MIME和内容", async () => {
    const { contents } = await client.readResource({ uri: "runbook://ALARM-2002" });
    const c = contents[0] as { mimeType: string; text: string };
    expect(c.mimeType).toBe("text/markdown");
    expect(c.text).toContain("AMF注册成功率下降");
  });

  it("列表URI与可读URI一致(无陈旧资源)", async () => {
    const { resources } = await client.listResources();
    for (const r of resources) {
      await expect(client.readResource({ uri: r.uri })).resolves.toBeDefined();
    }
  });
});

describe("提示词", () => {
  it("incident_triage 渲染出消息", async () => {
    const { messages } = await client.getPrompt({
      name: "incident_triage",
      arguments: { alarm_code: "ALARM-1001" },
    });
    expect(messages.length).toBeGreaterThan(0);
  });
});

有副作用的工具(写文件、改库),在 callTool 之后要断言外部状态确实变了,而不仅仅是"没报错"。

运行:npm test。预期所有用例通过。

常见问题: - 测试卡住不退出:afterAll 里没调 transport.close(),子进程没被回收。 - callTool 返回的 content 类型断言报错:content 是联合类型,断言前先 as 成具体形状,别直接 res.content[0].text。 - CI里stdio连接超时:服务器启动慢时给transport加 startupTimeout,或在测试里加轮询等待。


第六步:三种传输分别测试

callTool/readResource 的逻辑相同,但连接行为不同。stdio子进程掉线和服务端HTTP连接掉线是两回事。如果服务器要支持多传输,至少各跑一轮冒烟:

# stdio(前面已做)
npx @modelcontextprotocol/inspector --cli node build/index.js --method tools/list

# streamable HTTP(先起HTTP服务器,再连URL)
npx @modelcontextprotocol/inspector --cli --transport http \
  --server-url http://localhost:3000/mcp --method tools/list

# SSE
npx @modelcontextprotocol/inspector --cli --transport sse \
  --server-url http://localhost:3000/sse --method tools/list

SDK侧同样如此:stdio用 StdioClientTransport,HTTP用 StreamableHTTPClientTransport,两者接同一个 Client。一条经验法则——业务逻辑测试跑stdio(最快、最简单);连接/重连/会话测试跑HTTP传输。


第七步:接入CI流水线

把第四步的CLI烟雾 + 第五步的Vitest套件都挂到PR流水线上。.github/workflows/mcp-tests.yml:

name: mcp-tests
on: [pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '22' }
      - run: npm ci
      - run: npm run build
      # 烟雾:服务器能起、能列工具、能列资源
      - run: npx @modelcontextprotocol/inspector --cli node build/index.js --method tools/list
      - run: npx @modelcontextprotocol/inspector --cli node build/index.js --method resources/list
      # 断言级:三原语 + 两类错误通道
      - run: npx vitest run

这样每次PR都会跑:服务器起不来→红;工具丢了→红;Schema漂移→红;错误通道被改坏→红。回归在任何Agent看到它之前就被拦下。


完整工作流

flowchart TD
    A[写MCP服务器] --> B[Inspector UI 探索<br/>发现明显问题]
    B -->|问题清零| C[Inspector CLI 烟雾测试<br/>CI 最便宜防线]
    C --> D[SDK Client + Vitest<br/>断言级自动化]
    D --> D1[工具Schema断言]
    D --> D2[两类错误通道<br/>isError vs 抛错]
    D --> D3[资源 MIME/URI]
    D --> D4[提示词渲染]
    D1 & D2 & D3 & D4 --> E[三种传输各冒烟<br/>stdio/SSE/HTTP]
    E --> F[接入 GitHub Actions]
    F -->|每次PR| G[拦截回归<br/>在 Agent 看到之前]
    G -.重构迭代.-> B

Inspector两组件与三种传输的关系:

flowchart LR
    U[浏览器 React UI<br/>MCPI :6274] -->|HTTP| P[Node 代理 MCPP :6277]
    P -->|stdio 子进程| S1[你的 MCP 服务器]
    P -->|SSE| S2[你的 MCP 服务器]
    P -->|streamable HTTP| S3[你的 MCP 服务器]
    P -->|CLI 模式 stdout JSON| CI[CI / jq 断言]

实战案例小结

本教程全程围绕的告警处置手册服务器就是一个完整可运行项目:src/index.ts 约80行覆盖三原语、test/runbook.test.ts 约60行覆盖五类断言、.github/workflows/mcp-tests.yml 接入CI。把它扩展到真实场景,只需:

验收标准:npm test 全绿、npm run smoke 退出码为0、PR流水线通过。三者满足,你就有了一个"对Agent友好且可回归"的MCP工具。


延伸阅读

训练营适用性评估

参考来源

  1. MCP Inspector — Model Context Protocol 官方文档 — Inspector UI/CLI功能、特性总览与开发工作流最佳实践
  2. MCP Server Testing: The Complete Guide with MCP Inspector in 2026 — QASkills — 三原语测试矩阵、Inspector CLI的CI用法、SDK Client自动化测试代码范式
  3. modelcontextprotocol/inspector — GitHub README — Inspector双组件架构(MCPI/MCPP)、端口6274/6277、Docker部署与mcp.json导出
  4. SEP-2577: Deprecate Roots, Sampling, and Logging — 2026-04-14 Final标准,弃用Roots/Sampling/Logging的官方依据
  5. Testing MCP Servers: The Complete Developer's Guide — agnost.ai — 测试工具生态与分层测试策略补充

[!tip] 51学通信知识星球 星主:爱卫生(微信:gprshome201101)——公众号「51学通信」创办者,19 年通信行业运维与讲师经验,著有《GPRS网络信令实例详解》《EPC原理与实践》《5G核心网原理与实践》三本纸质书(各大电商平台有售)。 星球定位:深耕 5G 核心网、IMS、NFV/AI、云原生等 ICT 领域,主打运维岗位与信令分析。 当前规模:5200 篇主题 / 990 个回答,198 元/年。 加入福利:加入星球后请加爱卫生微信,拉您进知识星球年度陪伴与答疑群——不限量回复您的各种问题,另有群内分享。 AI 加持:51学通信知识星球已作为 skill 接入 WorkBuddy,使用 deepseek-4-flash 在海量星球帖子中精准找贴、找视频、找 FAQ 和案例,做总结、找文档和 PPT 等场景全覆盖。

51学通信知识星球海报-扫码加入