本教程面向已经能写出一个 MCP(Model Context Protocol)服务器、却被"它到底跑没跑对"卡住的开发者。学完你将掌握一套双栈调试测试体系:用官方 MCP Inspector 做交互式的人工验证,用 In-Memory 传输 + Vitest 做可回归的自动化测试,并把二者接入 CI。适合写过至少一个 MCP 工具、熟悉 TypeScript/Node.js 的工程师。
npx@modelcontextprotocol/sdk 的 stdio 服务器id / method / result / error)npm i -D vitest)很多开发者第一次写 MCP 服务器时的心态是:在 Claude Code 或 Cursor 里 claude mcp add 一下,能被模型调用、能返回结果,就算完工。这个心智模型有一个致命缺陷——你把验证权交给了大模型。
模型对工具的调用是概率性的:同一句话,它这次传 { city: "北京" },下次可能传 { city: "Beijing" },再下次可能压根不调你的工具。当工具行为出错时,你无法区分三种完全不同的故障源:
tools/list 返回空把这三层混在一起调试,等于在迷宫里蒙眼找出口。正确的工程做法是把它们拆开:协议与工具层的正确性,必须用确定性的测试保障,而非交给模型。这正是 MCP Inspector 与 In-Memory 传输要解决的核心问题。
MCP Inspector 是 modelcontextprotocol 官方维护的可视化调试工具。它本质上是一个"会自己启动你的服务器、然后用 UI 暴露所有 JSON-RPC 调用"的客户端。你在浏览器里点按钮,等同于手动发出一条条 initialize / tools/list / tools/call 消息,并能立即看到完整请求与响应。
它由两个进程组成:
http://localhost:6274localhost:6277进入你的服务器项目目录,直接:
npx @modelcontextprotocol/inspector node dist/server.js
这条命令做了三件事:拉起 UI、拉起 Proxy、用 node dist/server.js 启动你的服务器并连上。打开 http://localhost:6274,左侧能看到 Connected 状态和服务器声明的 serverInfo。
如果你的服务器还需要参数(比如脚本路径或环境变量),Inspector 的命令行完全等价于你在 claude mcp add 时写的 command + args:
# 带环境变量
npx @modelcontextprotocol/inspector \
-e API_KEY=sk-xxx \
-e DEBUG=1 \
node dist/server.js --port 8080
在 UI 里依次走完这四步,能覆盖 80% 的服务器问题:
Connected,记下 serverInfo.name 和 protocolVersion。如果这里就报错,说明 initialize 握手失败,问题在传输层或启动命令。server.tool(...) 注册有问题,跟模型无关。result(含 content 数组)或 isError。这步验证的是 handler 逻辑本身。stdio 传输下,你的服务器进程有两条输出通道:stdout 和 stderr。MCP 协议规定,JSON-RPC 消息只走 stdout。这意味着如果你在 handler 里随手写了:
// ❌ 致命错误:这会破坏协议帧
console.log("processing city:", city);
console.log 写到 stdout 的那行文本,会被客户端当成一条 JSON-RPC 消息去解析,直接触发解析错误,整个连接可能静默崩溃——而 Inspector 里你会看到"连接断了但没有任何报错"。
正确做法:服务器内部所有日志一律走 stderr。
// ✅ 正确:日志走 stderr,不影响协议
console.error("[geo] processing city:", city);
在 Inspector 里,stderr 输出会被单独捕获并显示在 Server Logs 面板,与 JSON-RPC 流互不干扰。如果你想看到更细的 SDK 级别日志,设置环境变量:
DEBUG=1 # 通用调试开关
MCP_DEBUG=1 # 部分 SDK 输出详细传输日志
Inspector 是人工验证工具,它不能进 CI,也不能回归。每次改动后你得手动点一遍——这在工具数量增多后不可持续。这就是为什么我们需要第二栈。
借鉴 danielvaughan 在其 MCP 测试系列中提出的金字塔,把 MCP 服务器测试分三层,由快到慢、由廉价到昂贵:
| 层级 | 隔离范围 | 速度 | 价值 |
|---|---|---|---|
| 单元测试 | 直接调用 handler 函数,跳过传输 | 极快(毫秒) | 验证业务逻辑 |
| 集成测试 | 用 In-Memory 传输,走完整 SDK,不 spawn 子进程 | 快(十毫秒) | 验证协议帧、Schema、注册 |
| 端到端测试 | spawn 真实服务器子进程 | 慢(百毫秒~秒) | 验证打包、入口、环境 |
经验法则:70% 单元 + 25% 集成 + 5% E2E。集成测试是 MCP 专属的金矿层——它在不付进程代价的前提下,覆盖了"协议帧是否正确组装"这一最易出错的环节。
这是整个体系里最有价值、却最少人知道的能力。MCP SDK 提供了 InMemoryTransport,它能在同一个 Node 进程里用一对内存管道把"客户端"和"服务器"对接起来,完全不经过 stdio、不启动子进程,却走的是真实完整的 SDK 协议栈。
my-geo-server/
├── src/
│ ├── server.ts # MCP 服务器定义
│ └── handlers.ts # 工具处理逻辑(纯函数,便于单测)
├── tests/
│ ├── handlers.unit.test.ts # 单元层
│ └── server.integration.test.ts # 集成层(In-Memory)
└── vitest.config.ts
把 server.ts 和 handlers.ts 拆开,是单元测试能直接 import handler 的前提。
// tests/handlers.unit.test.ts
import { describe, it, expect } from "vitest";
import { geocode } from "../src/handlers";
describe("geocode handler", () => {
it("returns structured content for a valid city", async () => {
const result = await geocode({ city: "北京" });
expect(result.lat).toBeCloseTo(39.9042, 1);
expect(result.lon).toBeCloseTo(116.4074, 1);
});
it("throws on empty city", async () => {
await expect(geocode({ city: "" })).rejects.toThrow("city is required");
});
});
这一层不打 SDK,纯逻辑,跑起来飞快,能在每次保存时即时反馈。
// tests/server.integration.test.ts
import { describe, it, expect } from "vitest";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { createGeoServer } from "../src/server";
describe("geo MCP server over in-memory transport", () => {
// 抽出"建立一对连接"的样板,每个用例复用
async function connect() {
const server = createGeoServer();
const client = new Client({ name: "test-client", version: "1.0.0" });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
server.connect(serverTransport),
]);
return { client, server };
}
it("lists the geocode tool with a correct schema", async () => {
const { client } = await connect();
const { tools } = await client.listTools();
const geo = tools.find((t) => t.name === "geocode");
expect(geo).toBeDefined();
expect(geo!.inputSchema.required).toContain("city");
});
it("calls geocode and returns text content", async () => {
const { client } = await connect();
const res = await client.callTool({
name: "geocode",
arguments: { city: "上海" },
});
expect(res.isError).toBeFalsy();
expect(res.content[0].type).toBe("text");
expect(res.content[0].text).toContain("上海");
});
it("reports isError when the tool throws", async () => {
const { client } = await connect();
const res = await client.callTool({
name: "geocode",
arguments: { city: "" },
});
expect(res.isError).toBe(true);
});
});
关键点:InMemoryTransport.createLinkedPair() 返回一对已绑定的传输对象,分别交给 client 和 server。Promise.all 并行连接是必须的——单边 connect 会死锁等待握手。这套测试覆盖了真实 SDK 从"参数序列化 → JSON-RPC 帧 → 传输 → 反序列化 → handler 调用 → 响应封箱"的全链路,却没有启动任何子进程。
很多人第一反应是用 mock 模拟 Server。这会让你恰好跳过被测的协议层——mock 返回什么,测试就通过什么,等于在测 mock 自己。In-Memory 传输的精髓在于它不 mock SDK,只 mock 网络,保留了所有真实代码路径。
下面把前面的片段拼成一个完整可运行的项目。
src/handlers.ts(纯逻辑):
const DB: Record<string, { lat: number; lon: number }> = {
"北京": { lat: 39.9042, lon: 116.4074 },
"上海": { lat: 31.2304, lon: 121.4737 },
"广州": { lat: 23.1291, lon: 113.2644 },
};
export interface GeoInput { city: string }
export async function geocode({ city }: GeoInput) {
if (!city) throw new Error("city is required");
const hit = DB[city];
if (!hit) throw new Error(`unknown city: ${city}`);
// 日志走 stderr,绝不用 console.log
console.error(`[geo] resolved ${city} -> ${hit.lat},${hit.lon}`);
return hit;
}
src/server.ts(MCP 装配):
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { geocode } from "./handlers.js";
export function createGeoServer() {
const server = new McpServer({ name: "geo-server", version: "1.0.0" });
server.tool(
"geocode",
"Resolve a Chinese city name to lat/lon coordinates",
{ city: z.string().describe("Chinese city name, e.g. 北京") },
async ({ city }) => {
try {
const { lat, lon } = await geocode({ city });
return {
content: [{ type: "text", text: `${city}: ${lat}, ${lon}` }],
};
} catch (e) {
// handler 抛错时必须返回 isError,否则会被当成正常结果
return {
isError: true,
content: [{ type: "text", text: (e as Error).message }],
};
}
}
);
return server;
}
注意 server.ts 里对 handler 异常的兜底:SDK 不会自动把 throw 转成 isError,必须显式处理,否则错误会被吞掉,客户端收到一个看似成功的空响应——这是 MCP 服务器最隐蔽的 bug 之一,恰好被第 4.3 节第三个测试用例守住了。
运行:
npx vitest run
预期 6 个用例全绿。至此你拥有了一个"改完代码一秒知道有没有破坏协议"的反馈环。
CI 最大的价值是卡住"它在我本地能跑"的幻觉。在 GitHub Actions 里加一步即可:
# .github/workflows/mcp-ci.yml
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci
- run: npm run build
- run: npx vitest run # 单元 + 集成
# 端到端:用一个最小脚本 spawn 子进程并握手
- run: node tests/e2e.smoke.mjs
E2E 冒烟脚本(tests/e2e.smoke.mjs)用 StdioClientTransport 拉起打包后的产物,验证"打包入口确实能被外部以 stdio 方式启动并握手",这是 In-Memory 测试无法替代的最后一公里。
flowchart TD
A[改动 handler/server] --> B{本地快速验证}
B --> C[单元测试 vitest<br/>直接打 handler]
C -->|逻辑正确| D[集成测试 vitest<br/>In-Memory 传输]
D -->|协议正确| E[人工调试 MCP Inspector<br/>UI 手动 Call Tool]
E -->|体验正确| F[CI 自动化<br/>单元+集成+E2E]
F -->|全绿| G[claude mcp add<br/>交给模型]
C -->|失败| A
D -->|失败| A
E -->|发现问题| A
F -->|失败| A
注意箭头方向:模型调用是最后一站,不是第一站。任何一层失败都回到代码修改,而不是反复在对话框里换说法祈祷模型这次能用对工具。
Q:Inspector 里显示服务器 Connected,但 tools/list 为空。
A:90% 是 server.tool(...) 的注册代码在某个异步分支里没执行,或 Zod schema 写错导致注册时抛异常。打开 stderr 日志面板看启动期报错。
Q:In-Memory 测试里 client.connect 永远不 resolve。
A:你没把 client.connect 和 server.connect 放进 Promise.all,单边连接在等一个永远不会来的握手。两边必须并行。
Q:工具在 Inspector 里正常,但模型从不调用。
A:先确认 Inspector 的 tools/list 里 schema 是否完整可读。若 schema 正确,问题在模型层——检查 description 是否足够明确,再考虑这是模型行为而非服务器 bug。
Q:CI 里集成测试比本地慢很多。 A:检查是否误把每个用例都 spawn 了真实子进程。集成测试应全程 In-Memory,只有 E2E 冒烟那一步才 spawn。
Q:handler 返回的 isError 在客户端读不到。
A:你可能在 catch 里只 return content 而漏了 isError: true。SDK 不会替你推断,必须显式标记。
modelcontextprotocol.io)——Inspector、Debugging、SDK API 的权威来源@modelcontextprotocol/sdk/inMemory.js)——理解内存管道如何成对绑定| 训练营类型 | 适用性 | 建议时长 |
|---|---|---|
| MCP 服务器开发进阶营 | 高度匹配 | 1 天(含动手 lab) |
| 全栈 AI 工程营 | 中度匹配 | 半天(聚焦集成测试部分) |
| DevOps / 质量保障营 | 中度匹配 | 半天(聚焦 CI 与金字塔) |
| 零基础入门营 | 不匹配 | —— |
前置依赖:学员应已完成"用 TS SDK 写出第一个 MCP 服务器"的环节。本教程定位为该环节之后的质量保障补全,建议作为 MCP 模块的第 2 天内容,与前一天的"构建服务器"形成"先造、再证"的闭环。动手 lab 建议:给定一个故意藏了 stderr 污染和吞异常 bug 的服务器,让学员用本教程的 Inspector 四步法 + 三层测试定位并修复它,预计 2~3 小时。
[!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 等场景全覆盖。
