<!-- title: MCP服务器三层测试与Inspector全链路调试 -->

## 概述

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

## 前置条件

- 已经手写过至少一个 MCP 服务器（stdio 或 Streamable HTTP 均可），熟悉 `tools/list`、`tools/call` 等基本方法。
- Node.js 20+（用于 TypeScript SDK 与 Inspector），或 Python 3.10+（用于 `pip install mcp`）。
- 一个可用的 Claude Code 安装（用于第三层客户端集成调试），以及任意 git 托管平台的仓库（用于 CI 流水线）。
- 对 JSON-RPC 2.0 有基本概念：知道 `id`、`method`、`params`、`result`、`error` 各是什么。

---

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

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

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

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

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

---

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

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

### 2.1 启动 Inspector

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

```bash
# 方式一：检查 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 把服务器切成几个面板。有价值的习惯是：**把每个面板当成针对第一节失败模式的检查清单**。

- **Server connection pane（连接面板）**：选择传输方式（stdio / SSE / Streamable HTTP）、设置命令行参数、注入环境变量。这里用来**精确复刻真实客户端的启动环境**——也是"Inspector 里好用、Claude Code 里不行"最常见的分水岭。你还能把验证过的配置导出为单条 server 条目或完整的 servers 文件，方便转录到客户端配置。
- **Tools tab（工具面板）**：列出工具、阅读每个工具的输入 schema 与描述、用自定义输入调用并查看执行结果。这里确认**客户端真正收到的 schema**，排除 schema 漂移和可发现性失败。如果某个工具"不被模型调用"，第一个问题就是：它的描述在这里看到的样子，到底有没有告诉模型何时该用它？
- **Resources tab（资源面板）**：列出资源、检查元数据（MIME 类型、描述）与内容；在服务器支持时演练订阅/更新通知。
- **Prompts tab（提示词面板）**：列出提示词模板、检查参数、用样例值渲染，看生成出来的消息长什么样。
- **Notifications pane（通知面板）**：展示服务器发出的所有日志与通知。这是交互式会话期间你观察服务器自身结构化日志的窗口。

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

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

Inspector 还有一个**非交互的 CLI 模式**，这让它的价值远超手工探索。CLI 模式会对你的服务器跑单个方法并打印结果，因此可以从 shell 脚本或 CI 任务里调用：

```bash
# 非交互地列出服务器暴露的工具
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` 连到一端、把你的服务器连到另一端，然后像普通客户端那样驱动它：

```typescript
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 在内存传输上把两者焊在一起，无需子进程：

```python
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 该断言什么

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

- **工具/资源/提示词清单**：从 `tools/list` 断言确切的名称集合。这一条断言能抓住意外的增删，并（如第五节所示）变成可长期 diff 的快照。
- **声明的 schema**：断言每个工具的输入 schema 包含 handler 实际读取的字段，且 required/optional 标记正确。这是 schema 漂移的解药——schema 说可选、handler 却无条件解引用，或 schema 叫 `userId`、handler 读 `user_id`，是运行时才会爆的雷。
- **快乐路径与错误路径**：用合法参数调用并断言结果形状；再用应该失败的参数调用，断言你拿到的是**结构化**错误结果，而不是逃逸成通用协议错误的异常。结构化错误能告诉模型和你"哪里错了"，而一个 `-32603 Internal error` 谁也告诉不了。
- **能力诚实**：如果你的服务器声明了 `resources` 或 `prompts` 能力，断言 `resources/list` / `prompts/list` 真的能返回——声明了却不实现的能力是潜伏的 Bug，直到客户端第一次用到才暴露。

---

## 四、第三层：传输层 —— 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 纯净检查**：拉起服务器，发一条 `initialize`，断言服务器写到 stdout 的**每一行**都能解析为 JSON。任何非 JSON 行都是污染 Bug。

```bash
# 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
```

- **环境检查**：客户端拉起的 stdio 服务器只继承有限的、平台相关的环境变量子集，工作目录可能未定义（macOS 上可能就是 `/`）。测试应当像客户端那样拉起服务器——带显式 `env` 和绝对路径——这样"我在 shell 里跑得好"就不会掩盖"客户端拉起时失败"。官方文档反复强调配置里要用绝对路径，而不是 `./data` 这种相对路径。

### 4.2 测试 Streamable HTTP

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

- **协议版本头**：初始化后，客户端**必须**在每个后续请求带上 `MCP-Protocol-Version` 头（如 `2025-11-25`）。值非法/不支持时服务器**必须**返回 `400`；请求**完全不带**该头时，服务器**应当**为向后兼容假定 `2025-03-26`——这个微妙默认值极易出错，值得显式测试。
- **会话管理**：服务器**可以**在承载 `initialize` 结果的响应上返回 `MCP-Session-Id` 头来分配会话（ID 只能由可见 ASCII 字符组成）；一旦分配，客户端**必须**在后续每个请求回显它。要求会话的服务器对缺头的非初始化请求**应当**返回 `400`，对携带已终止会话 ID 的请求**必须**返回 `404`；`404` 时客户端**必须**用新的 `initialize` 开新会话。这三个状态码行为（缺头 400、已终止 404、404 后重新初始化）正是自动化测试该钉死的。
- **内容协商与流**：`POST` 时客户端**必须**带列出 `application/json` 与 `text/event-stream` 的 `Accept` 头；服务器**必须**用单个 JSON 对象（`application/json`）或 SSE 流（`text/event-stream`）之一回应。客户端发出 `GET`（`Accept: text/event-stream`）打开服务器发起的流；不提供的服务器返回 `405 Method Not Allowed`。

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

```bash
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`：

```typescript
#!/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、快乐路径与错误路径：

```typescript
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`** —— 四道检查、最廉价的先跑、快速失败：

```yaml
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`），线上客户端横跨多个修订，这个矩阵能防止"今天和我客户端能跑、明天别人更新就崩"。

---

## 六、工作流程图

```mermaid
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 提供足够多的诊断手段来快速定位：

- **`claude mcp list`** 显示每个已配置服务器及状态指示；**`claude mcp get <name>`** 显示单个服务器详情（含项目级服务器是否仍待审批）。
- **会话内 `/mcp` 面板**是最快的健康检查：显示每个服务器、连接状态、暴露的工具数、是否已为当前项目审批、远程服务器的鉴权状态。一个"连上了但工具数为零"或卡在"待审批"的服务器，在这里几秒就能诊断。
- **`claude --debug mcp`** 启动时打开 MCP 调试输出，暴露服务器的 stderr——这正是 stdio 服务器**应当**发诊断的地方。`--debug` 接受类别过滤：`claude --debug "api,mcp"` 收窄到你关心的子系统；`--debug-file <path>` 把日志写到文件。
- **`claude --safe-mode`** 关闭所有自定义项（含 MCP 服务器）启动——确认一个 MCP 服务器是否是更广泛问题元凶的最干净方式：症状在安全模式下消失，就说明是扩展的责任。

几个环境变量影响 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` 会让它显形。

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

---

## 八、常见坑与修复

- **stdio 服务器日志打到 stdout**：症状是客户端立刻断开或报 JSON 解析错误。原因是 `print()`/默认日志/残留调试行往 stdout 写了非协议字节污染帧。修复：日志全走 stderr（或用规范的 `notifications/message`），并把 stdout 纯净检查加进 CI。
- **schema 与实现漂移**：症状是工具在调用时报错，尽管 `tools/list` 看着正常。原因是声明 schema 与 handler 背离（重命名字段、可选标成必填、类型不符）。修复：第三节那种让 handler 对着声明 schema 跑的内存集成测试，并断言快乐与错误两条路径。
- **"Inspector 里能用、Claude Code 里不行"**：几乎总是环境差异——客户端用不同的工作目录、更窄的环境变量、解析不了的相对路径拉起服务器。修复：在 Inspector 连接面板里复刻客户端的精确启动环境（设同样的 args/env、用绝对路径），再用 `claude --debug mcp` 读客户端视角下的 stderr。
- **没有协议版本固定**：一个客户端好用、另一个客户端就崩，或客户端更新后崩。修复：在 `initialize` 里正确协商版本、HTTP 初始化后每个请求带版本头、跑第五节的协议版本矩阵（含不带头的回退情形）。
- **服务器启动太慢**：客户端间歇性报连接失败。原因是服务器在完成 `initialize` 前干了重活（下载数据、打开上游连接），超出客户端启动超时。修复：把重活推迟到初始化完成后、调高启动超时（Claude Code 的 `MCP_TIMEOUT`），并加一条断言握手在有限时间内完成的测试。
- **声明了却不实现的能力**：客户端调用某方法拿到通用错误。修复：集成套件里的"能力诚实"断言。
- **把"过 Inspector"等同于"安全"**：功能正确 ≠ 安全可信。正确性测试回答"它能不能用"，不回答"它值不值得信任"。恶意或被入侵的服务器在协议层可以表现得完美无瑕——本教程只覆盖正确性，安全审查是另一套工具。

---

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

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

- **结构化日志在规范里**：声明了 `logging` 能力的服务器，可以通过 `notifications/message` 向客户端发日志，每条带一个 RFC 5424 severity 级别（`debug` 到 `emergency` 共八级）、可选 logger 名称和任意结构化数据；客户端可用 `logging/setLevel` 请求按严重级过滤。这在**所有传输**上都是正确的诊断通道，对 Streamable HTTP 尤为重要（客户端不捕获其 stderr）。
- **相关性标识**：JSON-RPC 的 `id`（会话内唯一、不得为 null）让你把请求、响应和处理期间发出的通知缝在一起；Streamable HTTP 的 `MCP-Session-Id` 让你重建一整段会话的轨迹（stdio 没有会话 id，它的"会话"就是进程的生命期）。`_meta` 字段可挂你自己的关联元数据。
- **追踪：存在的与不存在的**：分布式追踪（OpenTelemetry spans、W3C trace-context 经 `traceparent` 传播）**不是**已批准规范的一部分——它是一份开放提案加上第三方生态工具。把可移植的可观测性建立在规范的**结构化日志 + 请求/会话 id 关联**上，把 OTel 追溯当作刻意选用的附加项，而不是你能假设客户端会遵守的协议特性。

---

## 延伸阅读

- **MCP 规范 2025-11-25**：Lifecycle、Transports、Authorization、Logging utility 四章是本教程所有断言的权威来源。
- **`modelcontextprotocol/inspector`** GitHub 仓库：UI 与 CLI 模式的最新行为，导出配置给 Claude Code / Cursor 的按钮。
- **MCP Debugging 官方指南**：`modelcontextprotocol.io/docs/tools/debugging`，把 Inspector 放进整体调试工具箱的视角，含 Claude Desktop 的配置与日志位置。
- **Claude Code MCP 文档**：`claude mcp`、`/mcp`、三种 scope（local/project/user，local 优先级最高）。
- **MCP Tool Poisoning Defense**：功能正确 ≠ 安全可信，本教程只覆盖正确性，安全审查是另一套工具。

---

## 训练营适用性评估

**适合的训练营类型：** 面向后端/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 官方文档](https://modelcontextprotocol.io/docs/tools/inspector) — Inspector 的安装、基本用法（npm/PyPI/本地三种启动方式）、各面板（Server connection/Tools/Resources/Prompts/Notifications）能力与官方推荐的开发循环。
2. [Debugging — Model Context Protocol 官方文档](https://modelcontextprotocol.io/docs/tools/debugging) — 调试工具箱总览（Inspector / 服务器日志 / 客户端开发工具），stdio 的 stderr 捕获规则、`notifications/message` 八级日志、工作目录与绝对路径、`-32602` 能力协商错误、Claude Desktop 配置与日志位置。
3. [MCP Server Testing and Debugging Guide — hidekazu-konishi.com](https://hidekazu-konishi.com/entry/mcp_server_testing_and_debugging_guide.html) — 三层验证模型、`InMemoryTransport` 与内存 Client 集成测试、stdio/Streamable HTTP 传输检查、curl 状态码探针、CI 快照与协议版本矩阵的工程化深度指南（2026-06-14 更新，锚定规范 `2025-11-25`）。
4. [modelcontextprotocol/inspector — GitHub](https://github.com/modelcontextprotocol/inspector) — Inspector 可视化测试工具源码，支持 UI 与 CLI（`--cli`）双模式、端口 6274/6277 代理架构、导出服务器启动配置给 Claude Code 等客户端。
5. [Using MCP Inspector to Test Tools, Prompts, and Resources — dev.to](https://dev.to/om_shree_0709/using-mcp-inspector-to-test-tools-prompts-and-resources-3okg) — 用 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学通信知识星球海报-扫码加入](https://wiki.500nian.com/images/zhixingqiu_qrcode.png)
