<!-- title: MCP调试测试：Inspector与内存传输双栈 -->

## 概述

本教程面向已经能写出一个 MCP（Model Context Protocol）服务器、却被"它到底跑没跑对"卡住的开发者。学完你将掌握一套**双栈调试测试体系**：用官方 **MCP Inspector** 做交互式的人工验证，用 **In-Memory 传输 + Vitest** 做可回归的自动化测试，并把二者接入 CI。适合写过至少一个 MCP 工具、熟悉 TypeScript/Node.js 的工程师。

## 前置条件

- 已安装 Node.js 20+ 与 `npx`
- 能独立跑起一个基于 `@modelcontextprotocol/sdk` 的 stdio 服务器
- 了解 JSON-RPC 的基本概念（`id` / `method` / `result` / `error`）
- 本地装好 Vitest（`npm i -D vitest`）

---

## 一、为什么 MCP 服务器不能"靠跑通就行"

很多开发者第一次写 MCP 服务器时的心态是：在 Claude Code 或 Cursor 里 `claude mcp add` 一下，能被模型调用、能返回结果，就算完工。这个心智模型有一个致命缺陷——**你把验证权交给了大模型**。

模型对工具的调用是概率性的：同一句话，它这次传 `{ city: "北京" }`，下次可能传 `{ city: "Beijing" }`，再下次可能压根不调你的工具。当工具行为出错时，你无法区分三种完全不同的故障源：

1. **协议层故障**——服务器根本没握手成功，`tools/list` 返回空
2. **工具层故障**——服务器收到了请求，但你的 handler 抛了异常
3. **模型层故障**——服务器完全正确，只是模型这次没用对参数

把这三层混在一起调试，等于在迷宫里蒙眼找出口。正确的工程做法是把它们拆开：**协议与工具层的正确性，必须用确定性的测试保障，而非交给模型**。这正是 MCP Inspector 与 In-Memory 传输要解决的核心问题。

## 二、第一栈：MCP Inspector 交互式调试

### 2.1 它到底是什么

MCP Inspector 是 modelcontextprotocol 官方维护的可视化调试工具。它本质上是一个"会自己启动你的服务器、然后用 UI 暴露所有 JSON-RPC 调用"的客户端。你在浏览器里点按钮，等同于手动发出一条条 `initialize` / `tools/list` / `tools/call` 消息，并能立即看到完整请求与响应。

它由两个进程组成：

- **Inspector UI**：本地 Web 界面，默认 `http://localhost:6274`
- **Inspector Proxy**：桥接你的服务器进程，默认 `localhost:6277`

### 2.2 三秒启动

进入你的服务器项目目录，直接：

```bash
npx @modelcontextprotocol/inspector node dist/server.js
```

这条命令做了三件事：拉起 UI、拉起 Proxy、用 `node dist/server.js` 启动你的服务器并连上。打开 `http://localhost:6274`，左侧能看到 **Connected** 状态和服务器声明的 `serverInfo`。

如果你的服务器还需要参数（比如脚本路径或环境变量），Inspector 的命令行完全等价于你在 `claude mcp add` 时写的 `command` + `args`：

```bash
# 带环境变量
npx @modelcontextprotocol/inspector \
  -e API_KEY=sk-xxx \
  -e DEBUG=1 \
  node dist/server.js --port 8080
```

### 2.3 标准调试四步法

在 UI 里依次走完这四步，能覆盖 80% 的服务器问题：

1. **Connection 标签**：确认 `Connected`，记下 `serverInfo.name` 和 `protocolVersion`。如果这里就报错，说明 `initialize` 握手失败，问题在传输层或启动命令。
2. **Tools 标签 → List Tools**：点击后右侧应展开所有已注册工具及其 JSON Schema。**这一步是排查"模型看不到我的工具"的第一现场**——如果这里没有你的工具，说明 `server.tool(...)` 注册有问题，跟模型无关。
3. **Tools 标签 → Call Tool**：在某个工具的输入框里手填 JSON 参数，点 Call。右侧显示完整的 `result`（含 `content` 数组）或 `isError`。这步验证的是 handler 逻辑本身。
4. **Notifications / Resources / Prompts**：按需检查你声明了但没人用的那几个原语。

### 2.4 那个让所有人栽跟头的 stderr 坑

stdio 传输下，你的服务器进程有两条输出通道：`stdout` 和 `stderr`。**MCP 协议规定，JSON-RPC 消息只走 stdout**。这意味着如果你在 handler 里随手写了：

```typescript
// ❌ 致命错误：这会破坏协议帧
console.log("processing city:", city);
```

`console.log` 写到 stdout 的那行文本，会被客户端当成一条 JSON-RPC 消息去解析，直接触发解析错误，整个连接可能静默崩溃——而 Inspector 里你会看到"连接断了但没有任何报错"。

**正确做法**：服务器内部所有日志一律走 stderr。

```typescript
// ✅ 正确：日志走 stderr，不影响协议
console.error("[geo] processing city:", city);
```

在 Inspector 里，stderr 输出会被单独捕获并显示在 **Server Logs** 面板，与 JSON-RPC 流互不干扰。如果你想看到更细的 SDK 级别日志，设置环境变量：

```bash
DEBUG=1            # 通用调试开关
MCP_DEBUG=1        # 部分 SDK 输出详细传输日志
```

### 2.5 Inspector 的边界

Inspector 是人工验证工具，**它不能进 CI，也不能回归**。每次改动后你得手动点一遍——这在工具数量增多后不可持续。这就是为什么我们需要第二栈。

## 三、第二栈：测试金字塔

借鉴 danielvaughan 在其 MCP 测试系列中提出的金字塔，把 MCP 服务器测试分三层，由快到慢、由廉价到昂贵：

| 层级 | 隔离范围 | 速度 | 价值 |
|------|----------|------|------|
| **单元测试** | 直接调用 handler 函数，跳过传输 | 极快（毫秒） | 验证业务逻辑 |
| **集成测试** | 用 In-Memory 传输，走完整 SDK，不 spawn 子进程 | 快（十毫秒） | 验证协议帧、Schema、注册 |
| **端到端测试** | spawn 真实服务器子进程 | 慢（百毫秒~秒） | 验证打包、入口、环境 |

经验法则：**70% 单元 + 25% 集成 + 5% E2E**。集成测试是 MCP 专属的金矿层——它在不付进程代价的前提下，覆盖了"协议帧是否正确组装"这一最易出错的环节。

## 四、In-Memory 传输集成测试实战

这是整个体系里最有价值、却最少人知道的能力。MCP SDK 提供了 `InMemoryTransport`，它能在同一个 Node 进程里用一对内存管道把"客户端"和"服务器"对接起来，**完全不经过 stdio、不启动子进程**，却走的是真实完整的 SDK 协议栈。

### 4.1 项目结构

```
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 的前提。

### 4.2 单元层：直接打 handler

```typescript
// 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，纯逻辑，跑起来飞快，能在每次保存时即时反馈。

### 4.3 集成层：In-Memory 传输

```typescript
// 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 调用 → 响应封箱"的全链路，却没有启动任何子进程。

### 4.4 为什么不用 mock 传输

很多人第一反应是用 mock 模拟 `Server`。这会让你恰好跳过被测的协议层——mock 返回什么，测试就通过什么，等于在测 mock 自己。In-Memory 传输的精髓在于它**不 mock SDK，只 mock 网络**，保留了所有真实代码路径。

## 五、完整实战：从零测试一个地理编码服务器

下面把前面的片段拼成一个完整可运行的项目。

**src/handlers.ts**（纯逻辑）：

```typescript
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 装配）：

```typescript
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 节第三个测试用例守住了。

运行：

```bash
npx vitest run
```

预期 6 个用例全绿。至此你拥有了一个"改完代码一秒知道有没有破坏协议"的反馈环。

## 六、把测试接到 CI

CI 最大的价值是**卡住"它在我本地能跑"的幻觉**。在 GitHub Actions 里加一步即可：

```yaml
# .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 测试无法替代的最后一公里。

## 七、整体工作流

```mermaid
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 不会替你推断，必须显式标记。

## 九、延伸阅读

- **Model Context Protocol 官方文档**（`modelcontextprotocol.io`）——Inspector、Debugging、SDK API 的权威来源
- **modelcontextprotocol/inspector**（GitHub）——Inspector 源码，理解 Proxy 与 UI 的进程边界
- **InMemoryTransport 源码**（`@modelcontextprotocol/sdk/inMemory.js`）——理解内存管道如何成对绑定
- **Vitest 官方文档**——测试框架本身的断言、mock、覆盖率配置

## 十、训练营适用性评估

| 训练营类型 | 适用性 | 建议时长 |
|-----------|--------|---------|
| MCP 服务器开发进阶营 | 高度匹配 | 1 天（含动手 lab） |
| 全栈 AI 工程营 | 中度匹配 | 半天（聚焦集成测试部分） |
| DevOps / 质量保障营 | 中度匹配 | 半天（聚焦 CI 与金字塔） |
| 零基础入门营 | 不匹配 | —— |

**前置依赖**：学员应已完成"用 TS SDK 写出第一个 MCP 服务器"的环节。本教程定位为该环节之后的**质量保障补全**，建议作为 MCP 模块的第 2 天内容，与前一天的"构建服务器"形成"先造、再证"的闭环。动手 lab 建议：给定一个故意藏了 stderr 污染和吞异常 bug 的服务器，让学员用本教程的 Inspector 四步法 + 三层测试定位并修复它，预计 2~3 小时。

## 参考来源

1. [MCP Inspector — Model Context Protocol 官方文档](https://modelcontextprotocol.io/docs/tools/inspector) — Inspector 的权威用法、UI 各标签含义与命令行参数
2. [Debugging — Model Context Protocol 官方文档](https://modelcontextprotocol.io/docs/tools/debugging) — 多层级调试工具总览，含 stderr 日志约定
3. [modelcontextprotocol/inspector — GitHub](https://github.com/modelcontextprotocol/inspector) — Inspector 源码与 Client/Proxy 双组件架构说明
4. [Testing MCP Servers: Unit and Integration — Grizzly Peak Software](https://www.grizzlypeaksoftware.com/library/testing-mcp-servers-unit-and-integration-8d4qt3ob) — Node.js MCP 服务器的完整测试策略，含 In-Memory 传输实践
5. [Testing MCP tools, vitest + in-memory transport — Studio Meyer](https://studiomeyer.academy/recipes/6.4-testing-mcp-tools) — 不 spawn 子进程的 MCP 工具测试方法与单元/集成分层
6. [Test MCP Server Automatically — RapidDev](https://www.rapidevelopers.com/mcp-tutorial/how-to-test-mcp-server-with-automated-tests) — 用 Vitest 与 MCP Client SDK 校验 JSON-RPC 响应的自动化测试教程

> [!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学通信知识星球海报-扫码加入](https://wiki.500nian.com/images/zhixingqiu_qrcode.png)
