首页 › tutorials › MCP服务器三层测试与Inspector全链路调试

概述

本教程聚焦 MCP 服务器开发中最被低估、却最容易在生产中咬人的一环:测试与调试。学完后你将掌握官方推荐的"三层验证模型"——协议层、服务器逻辑层、客户端集成层——并用 MCP Inspector(交互模式 + CLI 模式)、SDK 内存传输、传输级探针和 CI 快照把它们串成一条可回归的闭环。适合已经能跑通一个 MCP 服务器、却苦于"Inspector 里好用、Claude Code 里不调用"或"工具输出莫名被截断"的开发者。

前置条件


一、为什么 MCP 服务器会"静默失败"

写普通 Web 服务时,一个 Bug 往往立刻抛出堆栈。MCP 服务器不一样:它失败时通常是静默的。一个永远不被模型选中的工具、一个被客户端悄悄拒绝的 schema、一个重连后立即断开的会话、一个返回错误状态码的鉴权握手——这些都不会在你的服务器进程里产生任何堆栈。服务器照常运行,客户端表现得"这个能力不存在"。等有人反馈"我的 Agent 没用上这个工具"时,症状离根因已经隔了好几层。

这就是为什么测试必须分层。MCP 服务器实际上是三层叠加,每一层失败方式不同、测试手段也不同:

层 典型失败 主要验证手段
协议层 初始化失败、版本不匹配、声明了却不实现的能力、重连后会话死亡 MCP Inspector + 传输级探针
服务器逻辑层 schema 与实现漂移、结果形状错、非结构化错误、描述太模糊导致工具不被选中 内存传输集成测试
客户端集成层 环境与工作目录差异、scope/审批问题、输出被截断、鉴权状态 客户端侧调试 + CI 冒烟测试

记住这张表的核心理念:用能看到某一层失败的最廉价工具去攻击那一层。下面四节分别拆解。


二、第一层:协议层 —— MCP Inspector 交互式验证

Inspector 是官方开源、传输无关的交互式测试工具,住在 modelcontextprotocol/inspector 仓库,通过 npx 免安装运行。它是你拿到一个服务器后"亲眼看看它到底声明了什么、怎么响应"的第一站。官方调试文档把它列为开发者的 "first stop"(第一站)。

2.1 启动 Inspector

Inspector 接收一段服务器启动命令并代你拉起它。最常见的三种启动方式:

# 方式一:检查 npm/PyPI 上的现成包
npx -y @modelcontextprotocol/inspector npx @modelcontextprotocol/server-filesystem /Users/username/Desktop
npx @modelcontextprotocol/inspector uvx mcp-server-git --repository ~/code/myrepo

# 方式二:检查本地 TypeScript 服务器
npx @modelcontextprotocol/inspector node build/index.js

# 方式三:检查本地 Python 服务器(uv 方式)
npx @modelcontextprotocol/inspector \
  uv --directory path/to/server run package-name

默认情况下,Inspector 会在浏览器开一个本地 Web UI(端口 6274),背后挂一个小的代理进程(端口 6277)真正与你的服务器对话。代理受一个打印到控制台的鉴权 token 保护——这是有意的安全设计,因为代理能拉起本地进程,绝不能暴露到不可信网络。把 Inspector 当作纯本地开发工具。

2.2 UI 里要逐项核对什么

连上之后,Inspector 把服务器切成几个面板。有价值的习惯是:把每个面板当成针对第一节失败模式的检查清单。

官方推荐的开发循环是:先验证基本连通性与能力协商 → 改一处服务器代码 → 重新构建 → 重连 Inspector → 测试受影响的功能 → 监控消息流;并刻意用无效输入、缺失参数、并发操作来验证错误处理。

2.3 Inspector 的 CLI 模式:脚本与 CI 的桥梁

Inspector 还有一个非交互的 CLI 模式,这让它的价值远超手工探索。CLI 模式会对你的服务器跑单个方法并打印结果,因此可以从 shell 脚本或 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 add --tool-arg a=2 --tool-arg b=3

对冒烟测试最有用的方法是 tools/list、tools/call、resources/list、prompts/list。CLI 模式是把交互式调试与第五节自动化回归套件连接起来的桥:你在 UI 里用眼睛扫过的 tools/list,会变成 CI 里你 diff 的快照。


三、第二层:服务器逻辑层 —— 内存传输集成测试

交互式测试能发现问题;自动化测试能把问题"持续发现"。一个 MCP 服务器最重要的自动化测试,是用真实客户端在内存里连上你的真实服务器——没有子进程、没有 socket——端到端演练协议,打到你的真实 handler。它能在毫秒级抓到 schema 漂移、能力不匹配、错误形状回归,并且哪里都能跑。

两个官方 SDK 都为此内置了内存传输。这是当前最干净的测试面:内存传输把客户端和服务器配对在同一个进程里,直接把读写流焊在一起,于是测试会跑完完整的请求/响应/通知机制(初始化、能力协商、schema 校验、序列化),却没有拉起进程或绑定端口的任何抖动。

3.1 TypeScript:InMemoryTransport.createLinkedPair()

TypeScript SDK 暴露的 InMemoryTransport,其静态方法 createLinkedPair() 返回一对已连接的传输——一个给客户端,一个给服务器。把 Client 连到一端、把你的服务器连到另一端,然后像普通客户端那样驱动它:

import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { z } from "zod";
import { describe, it, expect } from "vitest";

function buildServer(): McpServer {
  const server = new McpServer({ name: "calc", version: "1.0.0" });
  server.registerTool(
    "add",
    {
      description: "Add two integers and return the sum.",
      inputSchema: { a: z.number(), b: z.number() },
    },
    async ({ a, b }) => ({ content: [{ type: "text", text: String(a + b) }] }),
  );
  return server;
}

describe("calc server", () => {
  it("lists the add tool with the declared schema", async () => {
    const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
    const server = buildServer();
    const client = new Client({ name: "test", version: "1.0.0" });

    await Promise.all([
      server.connect(serverTransport),
      client.connect(clientTransport),
    ]);

    const { tools } = await client.listTools();
    expect(tools.map((t) => t.name)).toContain("add");

    const result = await client.callTool({ name: "add", arguments: { a: 2, b: 3 } });
    expect(result.content[0].text).toBe("5");
    await client.close();
  });
});

版本固定很重要:TypeScript SDK 正在重构为多包布局,InMemoryTransport(以及高层 server 类)的导入路径在旧的单包线与新的拆分包之间不同——新的拆分包会从 client/server 包里重新导出该传输用于进程内测试。固定你的测试目标 SDK 版本,并让导入路径与之匹配;不要跨大版本边界复制导入字符串而不检查。

3.2 Python:内存测试 Client

Python SDK 同样提供进程内测试路径:在你的 server 对象外构造一个 Client,SDK 在内存传输上把两者焊在一起,无需子进程:

import pytest
from mcp import Client
from mcp.server import MCPServer  # 高层 server(SDK 2.x 线)

app = MCPServer("calc")

@app.tool()
def add(a: int, b: int) -> int:
    """Add two integers and return the sum."""
    return a + b

@pytest.mark.asyncio
async def test_add_tool_is_listed_and_callable():
    async with Client(app) as client:
        tools = await client.list_tools()
        assert "add" in [t.name for t in tools.tools]

        result = await client.call_tool("add", {"a": 2, "b": 3})
        assert result.content[0].text == "5"

FastMCP / MCPServer 改名:高层 Python server 类正在改名。长期使用的名字是 FastMCP(从 mcp.server.fastmcp 导入),当前源码树引入 MCPServer(从 mcp.server 导入)作为后继。它们是同一个高层 server,不是两个产品。固定你的 SDK 版本,用那个版本发布哪个名字即可;旧版稳定线用 from mcp.server.fastmcp import FastMCP,新版用 from mcp.server import MCPServer。

3.3 该断言什么

内存测试最值钱的地方,是断言那些真正会漂移的东西:


四、第三层:传输层 —— stdio 纯净性与 Streamable HTTP 契约

当前规范定义两个标准传输:stdio 与 Streamable HTTP。(Streamable HTTP 取代了 2024-11-05 版本里旧的 HTTP+SSE 传输;如果你还在测旧的双端点风格,你测的是已废弃的传输,应计划迁移。)第三节内存测试刻意绕过了传输,这正是你需要一套单独检查来锻炼传输本身的原因。传输失败最隐蔽——它们往往只在交互式测试复现不了的条件下出现:流中途断开、本该恢复的会话没恢复、往 stdout 写了一行杂散日志污染了 stdio 帧。

4.1 测试 stdio

对 stdio,服务器从 stdin 读换行分隔的 JSON-RPC 消息、写出到 stdout。两条规则主导测试:

  1. 消息按换行分隔,且不得内嵌换行:意外被美化打印成多行的消息会破坏帧。
  2. 服务器绝不能往 stdout 写任何非合法 MCP 消息的东西:一条散落的 print()、一个默认日志打到 stdout 的库、一行没删的调试输出——任何一个都会污染 JSON-RPC 流,客户端带着令人困惑的解析错误断开。日志属于 stderr。官方调试文档强调:stdio 服务器写到 stderr 的所有日志会被宿主应用自动捕获,而 Streamable HTTP 客户端不捕获 stderr,必须用规范的 notifications/message 日志通知或标准 HTTP 工具(curl、浏览器 DevTools)。

最值得自动化的两个定向检查:

# stdout 纯净冒烟检查:stdout 每一行都必须是合法 JSON
printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"probe","version":"0"}}}' \
  | node build/index.js \
  | while IFS= read -r line; do
      printf '%s' "$line" | python3 -c 'import sys,json; json.loads(sys.stdin.read())' \
        || { echo "NON-JSON ON STDOUT: $line" >&2; exit 1; }
    done

4.2 测试 Streamable HTTP

Streamable HTTP 更丰富,要验证的更多。服务器暴露单个 MCP 端点(如 https://example.com/mcp),同时支持 POST 与 GET:

几条 curl 探针让这些行为在测试里变得具体:

ENDPOINT="https://example.com/mcp"

# 1. 初始化,从响应头捕获分配到的会话 id
SID=$(curl -s -D - -o /dev/null -X POST "$ENDPOINT" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -H "MCP-Protocol-Version: 2025-11-25" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"probe","version":"0"}}}' \
  | awk -F': ' 'tolower($1)=="mcp-session-id"{print $2}' | tr -d '\r')

# 2. 缺会话 id 的非初始化请求应被拒(HTTP 400)
curl -s -o /dev/null -w "no-session -> %{http_code}\n" -X POST "$ENDPOINT" \
  -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \
  -H "MCP-Protocol-Version: 2025-11-25" \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'

# 3. 带会话 id 的同一请求应成功
curl -s -o /dev/null -w "with-session -> %{http_code}\n" -X POST "$ENDPOINT" \
  -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \
  -H "MCP-Protocol-Version: 2025-11-25" -H "MCP-Session-Id: ${SID}" \
  -d '{"jsonrpc":"2.0","id":3,"method":"tools/list"}'

# 4. 显式会话拆除是对同一端点的 DELETE
curl -s -o /dev/null -w "delete -> %{http_code}\n" -X DELETE "$ENDPOINT" \
  -H "MCP-Protocol-Version: 2025-11-25" -H "MCP-Session-Id: ${SID}"

这些探针不能替代用真实 SDK 客户端走 Streamable HTTP(那才是最忠实的传输测试),但它们钉死了手写 HTTP 层最容易搞错的状态码契约。


五、实战案例:calc 服务器的完整测试闭环

下面把前四节串成一个可运行的最小工程。我们构建一个暴露 add 与 divide(含除零错误路径)的 calc 服务器,给它配内存集成测试、stdout 纯净检查、CI 快照与冒烟调用。

目录结构:

calc-mcp/
├── package.json
├── tsconfig.json
├── src/index.ts
├── test/calc.test.ts
├── tools.snapshot.json      # 受版本控制的工具清单快照
└── .github/workflows/regression.yml

src/index.ts —— 服务器本体,刻意展示两个要点:错误路径返回结构化结果,日志一律走 stderr:

#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";

const server = new McpServer({ name: "calc", version: "1.0.0" });

server.registerTool(
  "add",
  { description: "Add two integers and return the sum.", inputSchema: { a: z.number(), b: z.number() } },
  async ({ a, b }) => ({ content: [{ type: "text", text: String(a + b) }] }),
);

server.registerTool(
  "divide",
  { description: "Divide a by b. Returns an error result when b is 0.",
    inputSchema: { a: z.number(), b: z.number() } },
  async ({ a, b }) => {
    if (b === 0) {
      return { isError: true, content: [{ type: "text", text: "division by zero" }] };
    }
    return { content: [{ type: "text", text: String(a / b) }] };
  },
);

// 关键:诊断走 stderr,绝不污染 stdout 的 JSON-RPC 帧
process.stderr.write("[calc] server starting\n");
await server.connect(new StdioServerTransport());

test/calc.test.ts —— 内存集成测试,断言清单、schema、快乐路径与错误路径:

import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { z } from "zod";
import { describe, it, expect } from "vitest";

function buildServer(): McpServer {
  const server = new McpServer({ name: "calc", version: "1.0.0" });
  server.registerTool("add",
    { description: "Add two integers and return the sum.", inputSchema: { a: z.number(), b: z.number() } },
    async ({ a, b }) => ({ content: [{ type: "text", text: String(a + b) }] }));
  server.registerTool("divide",
    { description: "Divide a by b. Returns an error result when b is 0.",
      inputSchema: { a: z.number(), b: z.number() } },
    async ({ a, b }) => b === 0
      ? { isError: true, content: [{ type: "text", text: "division by zero" }] }
      : { content: [{ type: "text", text: String(a / b) }] });
  return server;
}

async function harness() {
  const [c, s] = InMemoryTransport.createLinkedPair();
  const server = buildServer();
  const client = new Client({ name: "test", version: "1.0.0" });
  await Promise.all([server.connect(s), client.connect(c)]);
  return client;
}

describe("calc", () => {
  it("exposes exactly add and divide", async () => {
    const client = await harness();
    const { tools } = await client.listTools();
    expect(tools.map(t => t.name).sort()).toEqual(["add", "divide"]);
  });

  it("add returns the sum", async () => {
    const client = await harness();
    const r = await client.callTool({ name: "add", arguments: { a: 2, b: 3 } });
    expect(r.content[0].text).toBe("5");
  });

  it("divide by zero returns a STRUCTURED error, not an exception", async () => {
    const client = await harness();
    const r = await client.callTool({ name: "divide", arguments: { a: 1, b: 0 } });
    expect(r.isError).toBe(true);
    expect(r.content[0].text).toBe("division by zero");
  });
});

.github/workflows/regression.yml —— 四道检查、最廉价的先跑、快速失败:

name: mcp-server-regression
on: [push, 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

      # 1. 内存集成测试(最快,最先失败)
      - name: Unit and integration tests
        run: npm test

      # 2. 工具清单快照:公开工具表面变了就失败
      - name: Snapshot tools/list
        run: |
          npx @modelcontextprotocol/inspector --cli node build/index.js \
            --method tools/list > tools.actual.json
          diff -u tools.snapshot.json tools.actual.json

      # 3. 针对构建产物的客户端集成冒烟
      - name: Smoke test a tool call
        run: |
          npx @modelcontextprotocol/inspector --cli node build/index.js \
            --method tools/call --tool-name add --tool-arg a=2 --tool-arg b=3

把 tools.snapshot.json 纳入版本控制,像对待任何契约变更一样 review 它的 diff。随时间推移,这一个文件会成为你拥有的最有价值的回归产物:它是你服务器的公开形状,对它的每一次改动都是某人有意做出的决定。进阶地,再叠加一个协议版本矩阵——对你打算支持的每个 protocolVersion 跑同一套冒烟,并断言服务器正确协商、对不支持的版本返回正确错误。规范按日期版本化(当前 2025-11-25,之前是 2025-06-18、2025-03-26、2024-11-05),线上客户端横跨多个修订,这个矩阵能防止"今天和我客户端能跑、明天别人更新就崩"。


六、工作流程图

flowchart TD
    A[手写 MCP 服务器] --> B{第1层 协议层<br/>MCP Inspector}
    B -->|交互 UI| C[核对声明的能力/版本/握手]
    B -->|CLI 模式| D[tools/list → 快照]
    C --> E{第2层 服务器逻辑层<br/>内存传输集成测试}
    D --> E
    E --> F[断言清单+schema<br/>快乐路径+错误路径]
    F --> G{第3层 传输层}
    G -->|stdio| H[stdout 纯净检查<br/>日志走 stderr]
    G -->|Streamable HTTP| I[会话/版本头 curl 探针]
    H --> J[客户端集成冒烟<br/>Inspector CLI 对构建产物]
    I --> J
    J --> K[CI 回归管线<br/>快照 diff + 协议版本矩阵]
    K --> L[客户端侧调试<br/>claude mcp list / --debug mcp]
    L --> M[稳定可回归的服务器]
    M -.每天.-> B

七、从客户端侧调试:"Inspector 里能用,Claude Code 里不行"

当一台服务器过了所有交互式与自动化测试、却仍在真实客户端里表现异常时,问题已移到客户端集成层。Claude Code 提供足够多的诊断手段来快速定位:

几个环境变量影响 MCP 行为,在服务器"能跑但超时"时值得知道:MCP_TIMEOUT 控制等待服务器启动多久、MCP_TOOL_TIMEOUT 控制单个工具执行多久、MAX_MCP_OUTPUT_TOKENS 限制工具返回多少输出后才被截断。一个返回大 payload 的工具可能被静默截断——"工具结果看起来被切断"这类反馈,调大限额或精简输出即可解决。

Claude Code 的 MCP 服务器配置在三种 scope 下——local(本项目,仅你)、project(本项目,通过签入的 .mcp.json 共享)、user(你的所有项目)——优先级 local 高于 project 高于 user。大量"服务器没加载"的怪事其实是 scope 或优先级问题,claude mcp list 会让它显形。

此外,改了服务器代码必须彻底退出并重开客户端(仅关窗口不够),改了配置必须重启客户端。"没生效"常常只是"没重启"。


八、常见坑与修复


九、日志与可观测:能用什么、不能假设什么

好的可观测性把"静默失败"变成"可读失败"。MCP 给你真实的、规范定义的日志原语:


延伸阅读


训练营适用性评估

适合的训练营类型: 面向后端/AI 工程师的"MCP 工具开发进阶营",或"AI Agent 基础设施训练营"。学员必须已具备 MCP 服务器编写能力(即"构建自己的 MCP 工具"主线教程的产出),本教程定位为"交付质量与工程化"模块。

预计教学时长: 主讲 + 跟练约 3.5–4 小时。其中三层模型概念讲解 30 分钟、Inspector 交互与 CLI 演练 45 分钟、内存集成测试编写 60 分钟、传输层探针 45 分钟、CI 管线搭建 30 分钟。若加入 Streamable HTTP 鉴权章节,需额外 45 分钟。

前置衔接: 建议排在"Claude Code MCP 服务器开发——构建自己的 MCP 工具"主线教程之后、远程部署教程之前,作为质量门禁。

产出物: 每位学员交付一个带内存测试、stdout 纯净检查、CI 快照与冒烟调用的 calc 服务器仓库,以及一份本机 claude --debug mcp 抓取的诊断日志。


参考来源

  1. MCP Inspector — Model Context Protocol 官方文档 — Inspector 的安装、基本用法(npm/PyPI/本地三种启动方式)、各面板(Server connection/Tools/Resources/Prompts/Notifications)能力与官方推荐的开发循环。
  2. Debugging — Model Context Protocol 官方文档 — 调试工具箱总览(Inspector / 服务器日志 / 客户端开发工具),stdio 的 stderr 捕获规则、notifications/message 八级日志、工作目录与绝对路径、-32602 能力协商错误、Claude Desktop 配置与日志位置。
  3. MCP Server Testing and Debugging Guide — hidekazu-konishi.com — 三层验证模型、InMemoryTransport 与内存 Client 集成测试、stdio/Streamable HTTP 传输检查、curl 状态码探针、CI 快照与协议版本矩阵的工程化深度指南(2026-06-14 更新,锚定规范 2025-11-25)。
  4. modelcontextprotocol/inspector — GitHub — Inspector 可视化测试工具源码,支持 UI 与 CLI(--cli)双模式、端口 6274/6277 代理架构、导出服务器启动配置给 Claude Code 等客户端。
  5. Using MCP Inspector to Test Tools, Prompts, and Resources — dev.to — 用 Inspector 逐面板测试工具、提示词与资源的实操教程,提供清晰的 UI 交互指引。

[!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学通信知识星球海报-扫码加入