本教程面向已经能写出"能跑起来"的MCP服务器、却不知道怎么保证它"在Agent手里不翻车"的开发者。你将学会三层递进的测试策略:用MCP Inspector做交互式探索发现明显问题,用Inspector CLI模式做CI烟雾测试,用@modelcontextprotocol/sdk 客户端 + Vitest写断言级自动化测试。覆盖三原语(工具/资源/提示词)、两类错误通道、三种传输,并接入GitHub Actions流水线。学完即可对任何自研MCP服务器建立可复现、可回归的测试体系。
^22.7.5(Inspector硬性要求),npx 可用jq(用于命令行断言),git(接入CI)时效性提醒:MCP规范在 2026-04-14 通过 SEP-2577(Final 状态)正式弃用 Roots、Sampling、Logging 三项核心特性。本教程刻意不依赖这三者——测试的是被官方长期支持的稳定特性(工具、资源、提示词、JSON-RPC错误码、进度通知)。如果你看到老教程大谈
sampling/createMessage或roots/list,那是在教已弃用特性,请谨慎。
很多人把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 | 连接中断 | 重连/干净关闭行为 |
这是新手最常踩的雷,也是测试必须分别覆盖的点:
isError: true 和可读的 content。模型看得到、能自愈(比如换个参数重试)。业务逻辑失败(告警码查不到、参数值非法)走这条。-32601 方法不存在、-32602 参数非法)。把这两条搞混(比如把业务失败抛成协议错误,或把内部故障包装成 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是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里你要依次走查四件事,对应四个标签页:
tools/resources/prompts 三项能力。lookup_runbook,读它模型视角下的name、description、inputSchema。这一步最容易抓到"描述含糊"或"schema类型错"。alarm_code=ALARM-1001 调用,看原始JSON-RPC请求/响应;再试 alarm_code=NOPE,确认返回 isError: true 而不是崩溃。runbook://ALARM-2002 确认MIME是 text/markdown;用 incident_triage 填参预览渲染出的消息。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
浏览器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
最强力的测试方式是:在你的测试进程里用一个真实的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");
});
});
工具的输入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能通过它校验。
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,测试立刻红。
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传输。
把第四步的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。把它扩展到真实场景,只需:
RUNBOOKS 换成数据库/文件后端,并新增副作用测试(查询计数、缓存失效)outputSchema 结构化输出,并断言 structuredContent 校验通过Context7/defuddle 等正式发布的MCP服务器做参照——它们都经历过这套测试范式才敢上线验收标准:npm test 全绿、npm run smoke 退出码为0、PR流水线通过。三者满足,你就有了一个"对Agent友好且可回归"的MCP工具。
modelcontextprotocol.io/docs/tools/inspector,UI/CLI完整特性modelcontextprotocol.io/docs/tools/debugging,Inspector之外更广的调试策略github.com/modelcontextprotocol/inspector,端口/Docker/配置细节modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging,弃用Roots/Sampling/Logging的官方依据,写新代码前必读github.com/modelcontextprotocol/typescript-sdk,Client/StdioClientTransport API参考[!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 等场景全覆盖。
