<!-- title: 造MCP开发闭环：调试测试发布 -->

## 概述

本教程聚焦 MCP（Model Context Protocol）服务器开发的**工程闭环**——不是再造一个具体工具，而是把一个 MCP 服务器从「写完代码」推进到「可调试、可测试、可发布、可注册」的生产级状态。你将掌握三大核心能力：用官方 **MCP Inspector** 进行可视化与脚本化调试、用 **InMemoryTransport** 编写不依赖子进程的自动化单元测试、把服务器**打包发布到 npm** 并注册进 Claude Code。适合已写过基础 MCP 工具、但每次都在「手动复制 JSON 测试」「上线后才暴露边界 bug」中挣扎的开发者。

## 前置条件

- Node.js 22.7.5 或更高版本（Inspector 强制要求）
- 已安装 `npm`，并能 `npx` 执行远程包
- 了解 TypeScript 基础语法（async/await、类型标注）
- 本机已装 Claude Code CLI（用于最后的注册演示）
- 一个能联网的环境（Inspector 首次通过 npx 拉取）

---

## 一、为什么「闭环」比「会写工具」更重要

绝大多数 MCP 服务器的线上事故，都不是模型调用失误，而是**边界失误**——空字符串、超长输入、非预期字段、时序竞争。这些 bug 在你手动 curl 一两次时永远不出现，却在真实 Agent 高频调用下集中爆发。

一个成熟的 MCP 开发流程应该是这样的闭环：

```mermaid
flowchart LR
    A[写工具代码] --> B[Inspector UI<br/>可视化探活]
    B --> C[Inspector CLI<br/>脚本化回归]
    C --> D[InMemoryTransport<br/>自动化单测]
    D --> E[npm 打包发布]
    E --> F[注册进 Claude Code]
    F -.用户反馈/边界 case.-> A
```

每一层都在前一层的基础上把**不确定性**往下压一层：UI 验证「能不能跑」，CLI 验证「参数对不对」，单测验证「边界稳不稳」，发布验证「别人能不能用」，注册验证「Agent 真的会调吗」。本教程就沿着这条链路一步步打通。

---

## 二、准备工作：脚手架与最小服务器

先建一个干净的项目骨架。我们以一个自包含、确定性强的示例服务器 **`mcp-markdown-stats`**（Markdown 文本统计）贯穿全文——它不依赖任何外部 API，输入输出完全确定，最适合演示测试与发布。

### 2.1 初始化项目

```bash
mkdir mcp-markdown-stats && cd mcp-markdown-stats
npm init -y
npm install @modelcontextprotocol/sdk@1.29.0 zod
npm install -D typescript @types/node vitest tsx
npx tsc --init
```

把 `package.json` 关键字段改成下面这样（发布相关字段先占位，第六步再细化）：

```json
{
  "name": "mcp-markdown-stats",
  "version": "0.1.0",
  "type": "module",
  "bin": { "mcp-markdown-stats": "build/index.js" },
  "scripts": {
    "build": "tsc",
    "dev": "tsx src/index.ts",
    "inspector": "npx @modelcontextprotocol/inspector node build/index.js",
    "test": "vitest run"
  },
  "files": ["build"]
}
```

> **注意 `type: "module"`**：v1 的 SDK 用 ESM 子路径导出（`@modelcontextprotocol/sdk/server/mcp.js`），项目设成 ESM 能避免最常见的「找不到导出」报错。

`tsconfig.json` 关键项：

```json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "build",
    "rootDir": "src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src"]
}
```

### 2.2 写最小服务器

新建 `src/index.ts`：

```ts
#!/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: "mcp-markdown-stats",
  version: "0.1.0",
});

// 工具 1：基础计数
server.tool(
  "count_words",
  "统计 Markdown 文本的字符数、词数与行数",
  {
    text: z.string().describe("待统计的 Markdown 文本"),
  },
  async ({ text }) => {
    const chars = text.length;
    const words = text.trim().split(/\s+/).filter(Boolean).length;
    const lines = text.split(/\r?\n/).length;
    return {
      content: [
        {
          type: "text" as const,
          text: `字符数: ${chars}\n词数: ${words}\n行数: ${lines}`,
        },
      ],
    };
  }
);

await server.connect(new StdioServerTransport());
```

编译并自测能否启动：

```bash
npm run build
node build/index.js   # 不会有任何输出，进程不退出，挂在 stdin 上等待 JSON-RPC——这是正确的
```

**常见问题：**

| 现象 | 原因 | 解决 |
|------|------|------|
| 启动即退出，无报错 | `server.connect` 没 `await` 或 transport 未连 | 确认最后一行是 `await server.connect(new StdioServerTransport())` |
| 报 `Cannot find module .../server/mcp.js` | 项目是 CJS 但 SDK 子路径是 ESM | `package.json` 加 `"type": "module"` |
| `z.string is not a function` | zod 版本不匹配 | 锁定 `zod@3`，本教程不用 zod v4 |

---

## 三、Inspector UI：可视化探活

光看进程「不退出」无法判断工具到底能不能用。MCP Inspector 是官方调试器，由两部分组成：**MCPI**（React 前端，默认端口 6274）和 **MCPP**（Node 代理，默认端口 6277，负责桥接 stdio/SSE/Streamable HTTP 三种传输）。这两个端口号来自电话 T9 键盘上 M-C-P-I 和 M-C-P-P 的数字映射，是个记忆小窍门。

### 3.1 一行命令启动

```bash
npx @modelcontextprotocol/inspector node build/index.js
```

首次启动会打印一个**会话令牌**：

```
🔑 Session token: 3a1c267fad21f7150b7d624c160b7f09b0b8c4f623c7107bbf13378f051538d4
🔗 Open inspector with token pre-filled:
   http://localhost:6274/?MCP_PROXY_AUTH_TOKEN=3a1c...
```

浏览器会自动带令牌打开。**务必保留默认鉴权**——代理拥有 spawn 本地进程的权限，关闭鉴权会让你的机器暴露在浏览器侧攻击之下（详见后文 CVE 提示）。

### 3.2 在 UI 里做什么

进入界面后的标准动作：

1. 左侧确认 `Connected` 状态变绿；
2. 点 **Tools** → 看到 `count_words`，点开它；
3. 在参数表单里填 `text` = `# 你好世界 hello`，点 **Run Tool**；
4. 右侧返回区出现 `字符数: 16 / 词数: 3 / 行数: 1`；
5. 底部 **Notifications** 面板会实时显示所有 JSON-RPC 报文，**这是排查静默失败的金矿**。

UI 模式最大的价值是**让协议可见**：当工具「调了但没结果」时，你能立刻在报文流里看到是请求没发出、还是服务端返回了错误对象、还是客户端超时取消了。

### 3.3 导出 mcp.json

调通后，侧栏有 **Server Entry** 和 **Servers File** 两个按钮，一键把当前配置复制成 Claude Code / Cursor 能直接吃的 `mcp.json`：

```json
{
  "mcpServers": {
    "markdown-stats": {
      "command": "node",
      "args": ["build/index.js"],
      "env": {}
    }
  }
}
```

**常见问题：**

- **端口被占用**：`CLIENT_PORT=8080 SERVER_PORT=9000 npx @modelcontextprotocol/inspector node build/index.js`。
- **工具调用卡住 5 分钟才超时**：默认 `MCP_SERVER_REQUEST_TIMEOUT=300000`（5 分钟）。长任务记得在服务端发 progress 通知，否则 Inspector 会按总超时 `MCP_REQUEST_MAX_TOTAL_TIMEOUT=60000` 取消。
- **连远程 SSE/HTTP 服务器**：UI 顶部切传输类型，填 URL；远程鉴权可在侧栏填 Bearer Token，还能改 Header 名。

---

## 四、Inspector CLI：脚本化回归

UI 适合探索，但每次改代码都要手点表单，无法纳入 CI。Inspector 的 **CLI 模式**解决了这个问题——它在命令行里直接发起 JSON-RPC 调用，输出机器可读的 JSON。

### 4.1 列出工具

```bash
npx @modelcontextprotocol/inspector --cli node build/index.js --method tools/list
```

预期输出（截断）：

```json
{
  "tools": [
    {
      "name": "count_words",
      "description": "统计 Markdown 文本的字符数、词数与行数",
      "inputSchema": { "type": "object", "properties": { "text": { "type": "string" } }, "required": ["text"] }
    }
  ]
}
```

### 4.2 调用工具

```bash
# 简单字符串参数
npx @modelcontextprotocol/inspector --cli node build/index.js \
  --method tools/call --tool-name count_words --tool-arg text="hello world foo"

# JSON 复杂参数
npx @modelcontextprotocol/inspector --cli node build/index.js \
  --method tools/call --tool-name reading_time \
  --tool-arg text="一段长文本..." \
  --tool-arg 'options={"wpm": 300, "lang": "zh"}'
```

### 4.3 把它变成回归脚本

在 `package.json` 里加：

```json
"scripts": {
  "smoke": "npx @modelcontextprotocol/inspector --cli node build/index.js --method tools/call --tool-name count_words --tool-arg text=hello"
}
```

每次 `npm run build && npm run smoke` 就是一次「烟雾测试」——能在 2 秒内告诉你工具是否还活着。配合 `--config mcp.json --server default-server` 还能一次测多个服务器。

**UI 模式 vs CLI 模式怎么选：**

| 场景 | 用 UI | 用 CLI |
|------|:---:|:---:|
| 开发期交互式调试 | ✅ | |
| CI/CD 自动化 | | ✅ |
| 给 AI 编码助手做反馈环 | | ✅ |
| 学习 MCP 协议结构 | ✅ | |
| 批量跑一批 prompt | | ✅ |

---

## 五、InMemoryTransport：真正的自动化单测

CLI 回归能抓「工具死了」，但抓不了「边界算错了」。比如 `count_words("")` 空字符串会返回 `词数: 0` 还是 `1`？`reading_time` 在 `wpm=0` 时会不会除零崩溃？这些只能靠单元测试。

传统做法是 `spawn` 一个子进程跑服务器、再用客户端连——慢、重、难调试。SDK 提供了 **`InMemoryTransport`**：一对内存中的「虚拟传输」，让 Client 和 Server 在**同一进程内**通过内存队列互发 JSON-RPC，毫秒级完成一次调用。

### 5.1 核心原理

```mermaid
flowchart LR
    subgraph 同一进程
      C[Client] -->|callTool| CT[ClientTransport]
      CT <-->|内存队列<br/>直接函数调用| ST[ServerTransport]
      ST --> S[McpServer]
    end
```

`InMemoryTransport.createLinkedPair()` 返回一对已经互相绑定的传输：一个交给 Client，一个交给 Server。两者之间不发网络包，只是直接调用对方的 `onmessage` 回调。

### 5.2 为可测试性重构代码

把工具逻辑从「注册 + 连接」里拆出来。新建 `src/server.ts`：

```ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";

export function createServer() {
  const server = new McpServer({ name: "mcp-markdown-stats", version: "0.1.0" });

  server.tool(
    "count_words",
    "统计 Markdown 文本的字符数、词数与行数",
    { text: z.string() },
    async ({ text }) => {
      const trimmed = text.trim();
      const chars = text.length;
      const words = trimmed.length === 0 ? 0 : trimmed.split(/\s+/).length;
      const lines = text.split(/\r?\n/).length;
      return { content: [{ type: "text" as const, text: `字符数: ${chars}\n词数: ${words}\n行数: ${lines}` }] };
    }
  );

  server.tool(
    "reading_time",
    "估算阅读时长（分钟），可配置每分钟字数",
    {
      text: z.string(),
      wpm: z.number().int().positive().default(250).describe("每分钟阅读词数"),
    },
    async ({ text, wpm }) => {
      const words = text.trim().split(/\s+/).filter(Boolean).length;
      const minutes = Math.max(1, Math.round(words / wpm));
      return { content: [{ type: "text" as const, text: `约 ${minutes} 分钟（${words} 词 / ${wpm} wpm）` }] };
    }
  );

  server.tool(
    "extract_outline",
    "从 Markdown 提取标题大纲",
    { text: z.string() },
    async ({ text }) => {
      const headings = text
        .split(/\r?\n/)
        .map((l) => l.match(/^(#{1,6})\s+(.*)$/))
        .filter(Boolean)
        .map((m) => `${"  ".repeat(m![1].length - 1)}- ${m![2]}`);
      return { content: [{ type: "text" as const, text: headings.join("\n") || "（无标题）" }] }];
    }
  );

  return server;
}
```

`src/index.ts` 退化为入口：

```ts
#!/usr/bin/env node
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { createServer } from "./server.js";

await createServer().connect(new StdioServerTransport());
```

### 5.3 写测试

新建 `src/server.test.ts`：

```ts
import { describe, it, expect } from "vitest";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { createServer } from "./server.js";

async function makeClient() {
  const server = createServer();
  const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
  await server.connect(serverTransport);
  const client = new Client({ name: "test", version: "0.0.0" });
  await client.connect(clientTransport);
  return client;
}

describe("count_words", () => {
  it("正常文本", async () => {
    const client = await makeClient();
    const r: any = await client.callTool({ name: "count_words", arguments: { text: "hello world foo" } });
    expect(r.content[0].text).toContain("词数: 3");
  });

  it("空字符串边界——词数应为 0 而非 1", async () => {
    const client = await makeClient();
    const r: any = await client.callTool({ name: "count_words", arguments: { text: "" } });
    expect(r.content[0].text).toContain("词数: 0");
  });

  it("缺必填参数应被 schema 拦截", async () => {
    const client = await makeClient();
    await expect(
      client.callTool({ name: "count_words", arguments: {} })
    ).rejects.toThrow();
  });
});

describe("reading_time", () => {
  it("wpm 默认 250", async () => {
    const client = await makeClient();
    const r: any = await client.callTool({ name: "reading_time", arguments: { text: "a ".repeat(500) } });
    expect(r.content[0].text).toContain("分钟");
  });

  it("wpm=0 应被 schema 拒绝（避免除零）", async () => {
    const client = await makeClient();
    await expect(
      client.callTool({ name: "reading_time", arguments: { text: "x", wpm: 0 } })
    ).rejects.toThrow();
  });
});
```

运行：

```bash
npm test
```

预期输出：

```
 ✓ count_words > 正常文本
 ✓ count_words > 空字符串边界——词数应为 0 而非 1
 ✓ count_words > 缺必填参数应被 schema 拦截
 ✓ reading_time > wpm 默认 250
 ✓ reading_time > wpm=0 应被 schema 拒绝（避免除零）
 Test Files  1 passed (1)
      Tests  5 passed (5)
```

这 5 个用例里，**第 2、3、5 个就是边界 case**——正是线上最容易炸的地方。注意 `InMemoryTransport` 官方注释明确说明它「专用于测试与开发」，生产环境的进程内连接请走 `StreamableHTTPClientTransport` 对本地 URL。

**常见问题：**

- **测试卡住不退出**：每个用例都新建了 server/client，Vitest 默认不自动关闭。可在 `afterEach` 里 `await client.close()`，或依赖进程退出。
- **schema 报错没抛异常而是返回错误工具结果**：高版本 SDK 对非法输入会返回 `isError: true` 的 content 而非 reject。若遇此情况，改用 `expect(r.isError).toBe(true)` 断言。

---

## 六、打包发布到 npm

测试绿了，就该让别人 `npx` 一行就能用。

### 6.1 补全 package.json

```json
{
  "name": "mcp-markdown-stats",
  "version": "0.1.0",
  "description": "Markdown 文本统计 MCP 服务器：字数、阅读时长、标题大纲",
  "type": "module",
  "license": "MIT",
  "bin": { "mcp-markdown-stats": "build/index.js" },
  "files": ["build"],
  "scripts": {
    "build": "tsc",
    "prepublishOnly": "npm run build && npm test",
    "test": "vitest run"
  },
  "dependencies": {
    "@modelcontextprotocol/sdk": "^1.29.0",
    "zod": "^3.23.0"
  }
}
```

三个关键点：

1. **`bin` 字段**：`npm install -g` 后会生成全局命令 `mcp-markdown-stats`，Claude Code 等客户端就能直接 `command: "mcp-markdown-stats"`。
2. **入口文件首行的 shebang** `#!/usr/bin/env node`：让 `build/index.js` 被当可执行文件运行，已在 `src/index.ts` 顶部加好。
3. **`prepublishOnly`**：发布前强制 build + test，防止把没编译或红灯的代码推上 npm。

### 6.2 发布

```bash
npm version 0.1.0      # 确认版本号
npm pack               # 先本地打包，检查 .tgz 里只有 build/ 和 package.json
npm login
npm publish --access public
```

发布后，任何人都能：

```bash
npx mcp-markdown-stats          # 直接跑
npx @modelcontextprotocol/inspector --cli npx mcp-markdown-stats --method tools/list
```

---

## 七、注册进 Claude Code

最后一步，让 Agent 真的能调到它。两种方式任选：

**方式一：命令行（推荐用于本地开发）**

```bash
# 用绝对路径指向本地构建产物
claude mcp add markdown-stats -- node /abs/path/mcp-markdown-stats/build/index.js

# 或直接用发布到 npm 的包
claude mcp add markdown-stats -- npx -y mcp-markdown-stats
```

**方式二：项目级 `.mcp.json`（团队共享）**

在项目根目录放 `.mcp.json`，团队成员 clone 后 Claude Code 自动加载：

```json
{
  "mcpServers": {
    "markdown-stats": {
      "command": "npx",
      "args": ["-y", "mcp-markdown-stats"]
    }
  }
}
```

验证注册成功：

```bash
claude mcp list
# markdown-stats  ✓ connected
```

之后在 Claude Code 里直接说「统计这段 Markdown 的阅读时长」，Agent 就会自动调用 `reading_time` 工具。

---

## 八、实战案例：完整工作流速查

把上述步骤串成一个开发者的日常动作，下面这张图就是你要背下来的「改一个工具」的标准动作链：

```mermaid
flowchart TD
    Start([改了 server.ts]) --> Build[npm run build]
    Build --> Test{npm test 全绿?}
    Test -->|否| Fix[回去修代码/补边界用例]
    Fix --> Build
    Test -->|是| CLI[npm run smoke<br/>Inspector CLI 烟雾测试]
    CLI --> UI{要看报文?}
    UI -->|是| Insp[npm run inspector<br/>UI 查 JSON-RPC 流]
    UI -->|否| Pub
    Insp --> Pub[npm version patch && npm publish]
    Pub --> Reg[claude mcp add 更新注册]
    Reg --> Done([Agent 可用])
```

**一个真实边界 bug 的排查复盘**：某次给 `reading_time` 加了「按中文每字算一个词」的特性，UI 手测正常，但单测里 `wpm=0` 的用例红了——才发现新逻辑里有个 `words / wpm` 没做零保护。这就是闭环的价值：**边界 bug 在 CI 阶段被拦下，而不是在用户 Agent 调用 500 次后崩溃**。

---

## 九、安全红线：别关 Inspector 鉴权

官方 README 用最高级别警告强调：**不要设置 `DANGEROUSLY_OMIT_AUTH=true`**。关闭代理鉴权后，攻击者无需你暴露公网，仅通过你浏览器访问一个恶意网页或恶意广告，就能远程命令你的机器执行任意进程（CVE-2025-49596）。

三条铁律：

1. 永远保留默认的会话令牌鉴权；
2. 代理默认只绑定 `localhost`，不要为图方便 `HOST=0.0.0.0`；
3. 信任网络环境下确需远程访问时，用 `MCP_PROXY_AUTH_TOKEN=$(openssl rand -hex 32)` 自己生成强令牌。

---

## 十、延伸阅读

- **官方调试指南**：`modelcontextprotocol.io/docs/tools/debugging` —— Inspector 在整体调试工具包中的定位
- **TS SDK 文档站**：`ts.sdk.modelcontextprotocol.io` —— tools/resources/prompts、HTTP 与 stdio、OAuth、迁移指南
- **Inspector 仓库**：`github.com/modelcontextprotocol/inspector` —— 源码与最新 CLI 参数
- **InMemoryTransport 源码**：`packages/core-internal/src/util/inMemory.ts` —— 读 30 行就能彻底理解内存传输

---

## 训练营适用性评估

| 维度 | 评估 |
|------|------|
| 适合训练营类型 | 中级开发者训练营、AI 工程化实战营、Agent 工具链课程（第 2–3 天） |
| 前置门槛 | 已能独立写出最小 MCP 服务器（建议先做 Round 2 Skill 或基础 MCP 入门） |
| 建议教学时长 | 4 学时：UI 调试 1h + CLI 与 InMemoryTransport 测试 1.5h + 发布注册 1h + 实战复盘 0.5h |
| 学员产出 | 一个发布到 npm、带 5+ 单测、注册进 Claude Code 的个人 MCP 服务器 |
| 易踩坑点 | zod 版本、ESM/CJS 混用、Inspector 鉴权误关、测试进程不退出 |

---

## 参考来源

1. [MCP Inspector 官方仓库 README](https://github.com/modelcontextprotocol/inspector) — UI/CLI 双模式命令、鉴权机制、CVE-2025-49596 安全警告的权威来源
2. [MCP Inspector 官方文档](https://modelcontextprotocol.io/docs/tools/inspector) — Inspector 在调试工具包中的定位与配置项说明
3. [TypeScript SDK 官方文档站](https://ts.sdk.modelcontextprotocol.io) — `McpServer`、`StdioServerTransport`、`InMemoryTransport` 的 API 与 server-quickstart 示例
4. [The MCP Server Testing Guide: How to Test Before You Ship](https://dev.to/whoffagents/the-mcp-server-testing-guide-how-to-test-before-you-ship-4agb) — 三层测试法（handler 单测 / InMemoryTransport 集成 / 安全测试）的工程实践
5. [MCP Server Testing: The Complete Guide with MCP Inspector in 2026](https://qaskills.sh/blog/mcp-server-testing-guide-2026) — Inspector 与自动化 TS 测试结合的最新实践汇总

---

*本文是「Claude Code 深度使用」系列第 3 轮，主题为 MCP 服务器开发工程闭环。下一轮将进入 Agent 模式与自动化（-p 模式、hooks、批量任务）。*

> [!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)
