本教程聚焦 MCP(Model Context Protocol)服务器开发的工程闭环——不是再造一个具体工具,而是把一个 MCP 服务器从「写完代码」推进到「可调试、可测试、可发布、可注册」的生产级状态。你将掌握三大核心能力:用官方 MCP Inspector 进行可视化与脚本化调试、用 InMemoryTransport 编写不依赖子进程的自动化单元测试、把服务器打包发布到 npm 并注册进 Claude Code。适合已写过基础 MCP 工具、但每次都在「手动复制 JSON 测试」「上线后才暴露边界 bug」中挣扎的开发者。
npm,并能 npx 执行远程包绝大多数 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,输入输出完全确定,最适合演示测试与发布。
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"]
}
新建 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 |
光看进程「不退出」无法判断工具到底能不能用。MCP Inspector 是官方调试器,由两部分组成:MCPI(React 前端,默认端口 6274)和 MCPP(Node 代理,默认端口 6277,负责桥接 stdio/SSE/Streamable HTTP 三种传输)。这两个端口号来自电话 T9 键盘上 M-C-P-I 和 M-C-P-P 的数字映射,是个记忆小窍门。
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 提示)。
进入界面后的标准动作:
Connected 状态变绿;count_words,点开它;text = # 你好世界 hello,点 Run Tool;字符数: 16 / 词数: 3 / 行数: 1;UI 模式最大的价值是让协议可见:当工具「调了但没结果」时,你能立刻在报文流里看到是请求没发出、还是服务端返回了错误对象、还是客户端超时取消了。
调通后,侧栏有 Server Entry 和 Servers File 两个按钮,一键把当前配置复制成 Claude Code / Cursor 能直接吃的 mcp.json:
{
"mcpServers": {
"markdown-stats": {
"command": "node",
"args": ["build/index.js"],
"env": {}
}
}
}
常见问题:
CLIENT_PORT=8080 SERVER_PORT=9000 npx @modelcontextprotocol/inspector node build/index.js。MCP_SERVER_REQUEST_TIMEOUT=300000(5 分钟)。长任务记得在服务端发 progress 通知,否则 Inspector 会按总超时 MCP_REQUEST_MAX_TOTAL_TIMEOUT=60000 取消。UI 适合探索,但每次改代码都要手点表单,无法纳入 CI。Inspector 的 CLI 模式解决了这个问题——它在命令行里直接发起 JSON-RPC 调用,输出机器可读的 JSON。
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"] }
}
]
}
# 简单字符串参数
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"}'
在 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 | ✅ |
CLI 回归能抓「工具死了」,但抓不了「边界算错了」。比如 count_words("") 空字符串会返回 词数: 0 还是 1?reading_time 在 wpm=0 时会不会除零崩溃?这些只能靠单元测试。
传统做法是 spawn 一个子进程跑服务器、再用客户端连——慢、重、难调试。SDK 提供了 InMemoryTransport:一对内存中的「虚拟传输」,让 Client 和 Server 在同一进程内通过内存队列互发 JSON-RPC,毫秒级完成一次调用。
flowchart LR
subgraph 同一进程
C[Client] -->|callTool| CT[ClientTransport]
CT <-->|内存队列<br/>直接函数调用| ST[ServerTransport]
ST --> S[McpServer]
end
InMemoryTransport.createLinkedPair() 返回一对已经互相绑定的传输:一个交给 Client,一个交给 Server。两者之间不发网络包,只是直接调用对方的 onmessage 回调。
把工具逻辑从「注册 + 连接」里拆出来。新建 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());
新建 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。
常见问题:
afterEach 里 await client.close(),或依赖进程退出。isError: true 的 content 而非 reject。若遇此情况,改用 expect(r.isError).toBe(true) 断言。测试绿了,就该让别人 npx 一行就能用。
{
"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"
}
}
三个关键点:
bin 字段:npm install -g 后会生成全局命令 mcp-markdown-stats,Claude Code 等客户端就能直接 command: "mcp-markdown-stats"。#!/usr/bin/env node:让 build/index.js 被当可执行文件运行,已在 src/index.ts 顶部加好。prepublishOnly:发布前强制 build + test,防止把没编译或红灯的代码推上 npm。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
最后一步,让 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 次后崩溃。
官方 README 用最高级别警告强调:不要设置 DANGEROUSLY_OMIT_AUTH=true。关闭代理鉴权后,攻击者无需你暴露公网,仅通过你浏览器访问一个恶意网页或恶意广告,就能远程命令你的机器执行任意进程(CVE-2025-49596)。
三条铁律:
localhost,不要为图方便 HOST=0.0.0.0;MCP_PROXY_AUTH_TOKEN=$(openssl rand -hex 32) 自己生成强令牌。modelcontextprotocol.io/docs/tools/debugging —— Inspector 在整体调试工具包中的定位ts.sdk.modelcontextprotocol.io —— tools/resources/prompts、HTTP 与 stdio、OAuth、迁移指南github.com/modelcontextprotocol/inspector —— 源码与最新 CLI 参数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 鉴权误关、测试进程不退出 |
McpServer、StdioServerTransport、InMemoryTransport 的 API 与 server-quickstart 示例本文是「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 等场景全覆盖。
