首页 › tutorials › 造MCP开发闭环:调试测试发布

概述

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

前置条件


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

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

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

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 初始化项目

mkdir mcp-markdown-stats && cd mcp-markdown-stats
npm init -y
npm install @modelcontextprotocol/[email protected] zod
npm install -D typescript @types/node vitest tsx
npx tsc --init

把 package.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 关键项:

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

2.2 写最小服务器

新建 src/index.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());

编译并自测能否启动:

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 一行命令启动

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:

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

常见问题:


四、Inspector CLI:脚本化回归

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

4.1 列出工具

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

预期输出(截断):

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

4.2 调用工具

# 简单字符串参数
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 里加:

"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 核心原理

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:

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 退化为入口:

#!/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:

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();
  });
});

运行:

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。

常见问题:


六、打包发布到 npm

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

6.1 补全 package.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 发布

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

发布后,任何人都能:

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

七、注册进 Claude Code

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

方式一:命令行(推荐用于本地开发)

# 用绝对路径指向本地构建产物
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 自动加载:

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

验证注册成功:

claude mcp list
# markdown-stats  ✓ connected

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


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

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

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) 自己生成强令牌。

十、延伸阅读


训练营适用性评估

维度 评估
适合训练营类型 中级开发者训练营、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 — UI/CLI 双模式命令、鉴权机制、CVE-2025-49596 安全警告的权威来源
  2. MCP Inspector 官方文档 — Inspector 在调试工具包中的定位与配置项说明
  3. TypeScript SDK 官方文档站 — McpServer、StdioServerTransport、InMemoryTransport 的 API 与 server-quickstart 示例
  4. The MCP Server Testing Guide: How to Test Before You Ship — 三层测试法(handler 单测 / InMemoryTransport 集成 / 安全测试)的工程实践
  5. MCP Server Testing: The Complete Guide with MCP Inspector in 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学通信知识星球海报-扫码加入