首页 › tutorials › MCP调试测试:Inspector与内存传输双栈

概述

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

前置条件


一、为什么 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 消息,并能立即看到完整请求与响应。

它由两个进程组成:

2.2 三秒启动

进入你的服务器项目目录,直接:

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

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 里随手写了:

// ❌ 致命错误:这会破坏协议帧
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 输出详细传输日志

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

// 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 传输

// 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(纯逻辑):

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

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

九、延伸阅读

十、训练营适用性评估

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

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

参考来源

  1. MCP Inspector — Model Context Protocol 官方文档 — Inspector 的权威用法、UI 各标签含义与命令行参数
  2. Debugging — Model Context Protocol 官方文档 — 多层级调试工具总览,含 stderr 日志约定
  3. modelcontextprotocol/inspector — GitHub — Inspector 源码与 Client/Proxy 双组件架构说明
  4. Testing MCP Servers: Unit and Integration — Grizzly Peak Software — Node.js MCP 服务器的完整测试策略,含 In-Memory 传输实践
  5. Testing MCP tools, vitest + in-memory transport — Studio Meyer — 不 spawn 子进程的 MCP 工具测试方法与单元/集成分层
  6. Test MCP Server Automatically — RapidDev — 用 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学通信知识星球海报-扫码加入