本教程面向已掌握 MCP 协议基本概念、想亲手构建并交付一个可用 MCP 工具的开发者。我们将聚焦于"构建自己的 MCP 工具"中常被忽略的工程化环节——可视化调试、自动化契约测试、项目级分发。学完后你能独立把一个自研 MCP 服务器从"能跑"推进到"可交付",并接入 Claude Code 项目供团队复用。
claude --versioninitialize/tools/list/tools/call 的报文结构如果你对协议本身还不熟,建议先读官方 modelcontextprotocol.io 的 Quickstart,再回到本文。本教程刻意避开"工具/资源/提示词三原语怎么选"这种设计话题,专注交付链路。
很多人第一次写 MCP 服务器,把"创建 server"和"启动 stdio 传输"写进同一个 main()。这会让后面调试和测试都无从下手。第一条工程原则:把 server 的构造和传输的连接拆开。
// src/server.ts —— 只负责"造服务器",不碰 stdio
import { McpServer } from "@modelcontextprotocol/sdk/mcp.js";
import { z } from "zod";
import fs from "node:fs/promises";
export function createServer(): McpServer {
const server = new McpServer({ name: "md-linkcheck", version: "1.0.0" });
server.tool(
"check_url",
"检查单个 URL 的 HTTP 状态码",
{ url: z.string().url() },
async ({ url }) => {
const status = await head(url);
return { content: [{ type: "text" as const, text: `${url} -> ${status}` }] };
}
);
return server;
}
export async function head(url: string, timeoutMs = 5000): Promise<number> {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), timeoutMs);
try {
const res = await fetch(url, { method: "GET", redirect: "follow", signal: ctrl.signal });
return res.status;
} catch {
return 0; // 0 = 网络/超时
} finally {
clearTimeout(t);
}
}
// src/index.ts —— 生产入口,连接 stdio
import { StdioServerTransport } from "@modelcontextprotocol/sdk/stdio.js";
import { createServer } from "./server.js";
async function main() {
const server = createServer();
await server.connect(new StdioServerTransport());
}
main();
预期结果:npm run build 后 node ./build/index.js 启动即阻塞等待,没有任何输出。没有输出是对的——stdio 传输下,stdout 被协议独占,任何 console.log 都会污染 JSON-RPC 报文。
常见问题:如果你在 server 里写了 console.log,第一个工具调用就会报 Unexpected token 解析错误。改成 console.error(走 stderr)或用 SDK 的 server.server.sendLoggingMessage(...)。
写完代码别急着接 Claude Code,先用 MCP Inspector 单独验证。这是官方提供的交互式调试器,能在浏览器里逐个调用工具、查看原始报文。
# 指定你的服务器入口,Inspector 会作为父进程拉起它
npx @modelcontextprotocol/inspector node ./build/index.js
预期结果:终端打印两个本地端口(默认 UI 在 http://localhost:6274,代理在 6277),浏览器自动打开。界面左侧显示连接状态与 initialize 握手结果,右侧出现 Tools 标签,列出 check_url。
操作要点:
1. 顶部 Connection 区可临时改环境变量、命令参数,无需改代码重启。
2. 点 List Tools 确认工具清单与 schema 是否正确序列化。
3. 进 Tools → check_url,在参数框填 {"url": "https://example.com"} 点调用,下方返回文本内容。
4. 切到 JSON-RPC 视图,能看到完整的请求/响应报文——这是定位"工具没出现""参数校验失败"最快的手段。
常见问题:Inspector 里工具列表为空,多半是 server 在 initialize 之后才注册 tool,或某处抛了未捕获异常导致进程退出。打开终端看 Inspector 拉起 server 时的 stderr,通常一眼可见。
Inspector 是手测,CI 里需要一个能自动跑的契约测试。SDK 提供了内存传输,让你在同一进程内用真实 MCP 客户端去调你的 server,端到端覆盖 tools/list 与 tools/call。
// test/contract.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 "../src/server.js";
async function connect() {
const server = createServer();
const client = new Client({ name: "test", version: "1.0" }, { capabilities: {} });
const [clientSide, serverSide] = InMemoryTransport.createLinkedPair();
await Promise.all([client.connect(clientSide), server.connect(serverSide)]);
return client;
}
describe("md-linkcheck 契约", () => {
it("暴露 check_url 工具", async () => {
const client = await connect();
const { tools } = await client.listTools();
expect(tools.map((t) => t.name)).toContain("check_url");
});
it("check_url 返回 example.com 状态码", async () => {
const client = await connect();
const res = await client.callTool({ name: "check_url", arguments: { url: "https://example.com" } });
const text = (res.content as any[])[0].text;
expect(text).toMatch(/example\.com -> \d+/);
});
it("非法 URL 触发协议级错误", async () => {
const client = await connect();
const res = await client.callTool({ name: "check_url", arguments: { url: "not-a-url" } });
expect(res.isError).toBe(true);
});
});
package.json 关键配置:
{
"type": "module",
"scripts": { "build": "tsc", "test": "vitest run" },
"dependencies": { "@modelcontextprotocol/sdk": "^1.0.0", "zod": "^3.23.0" },
"devDependencies": { "typescript": "^5.4.0", "vitest": "^1.6.0" }
}
预期结果:npm test 三项全绿。关键价值:这三条覆盖了"工具注册正确""正常路径返回符合结构""入参校验按 schema 拦截"三个契约点。任何破坏性改动都会立刻在 CI 暴露,而不是等到接进 Claude Code 后才让 LLM 撞上。
Python 版同理:FastMCP 可用
mcp包的create_connected_server_and_client_session做进程内联调,pytest 驱动client.call_tool(...)断言。
调试、测试都过了,最后把它接到 Claude Code。项目级分发用 .mcp.json,放在仓库根目录,团队成员克隆后即可复用。
// 仓库根目录 .mcp.json
{
"mcpServers": {
"md-linkcheck": {
"command": "node",
"args": ["./mcp-servers/md-linkcheck/build/index.js"],
"env": { "USER_AGENT": "md-linkcheck/1.0" }
}
}
}
首次在含 .mcp.json 的项目里启动 Claude Code,会提示你审批(trust)这些项目级服务器——这是安全门禁,防止恶意仓库静默拉起任意进程。审批通过后即可使用。
也可以用 CLI 管理(不写文件):
# --scope project 写入 .mcp.json;--scope user 全局;--scope local 仅本机
claude mcp add md-linkcheck --scope project -- node ./mcp-servers/md-linkcheck/build/index.js
claude mcp list # 列出所有已配置
claude mcp get md-linkcheck # 查看单个配置与最近一次连接状态
进入会话后用 /mcp 命令实时查看连接状态、工具数量。连接失败会在此直接显示原因。
常见问题:
- 工具在 /mcp 里是 disconnected:八成是 args 里的相对路径或 node 不在 PATH。claude mcp get 会显示上次失败的报错。
- 本地能用、CI 报找不到:相对路径基准是 Claude Code 启动时的 cwd,确保 .mcp.json 里的路径相对仓库根稳定,或用绝对路径。
把契约测试挂进 CI,再加一条"产线入口冒烟"——直接拉起 build/index.js 发一个 initialize,确认进程不立即崩溃:
# .github/workflows/mcp.yml(节选)
- run: npm ci && npm run build && npm test
- name: smoke
run: |
node ./build/index.js < /dev/null &
PID=$!
sleep 1
kill -0 $PID && echo "server alive"
发布前对照清单逐项确认:
| 检查项 | 通过标准 |
|---|---|
| stdout 纯净 | 全程无 console.log,仅 stderr 日志 |
| 入参 schema | 用 zod 约束,非法输入返回 isError |
| 契约测试 | tools/list 与 tools/call 双覆盖 |
| Inspector 验证 | 至少手测一次正常/异常路径 |
.mcp.json 相对路径 |
仓库根克隆即可用 |
| README 含调用示例 | 给出 claude mcp add 一行命令 |
把上面的片段组合,再加一个批量检查工具,就是一个能直接用的项目:
server.tool(
"check_links",
"检查 Markdown 文件内所有外链可达性,返回每个 URL 状态码",
{
file: z.string().describe("Markdown 文件路径"),
timeout: z.number().default(5000).describe("单 URL 超时毫秒"),
},
async ({ file, timeout }) => {
const md = await fs.readFile(file, "utf8");
const urls = [...new Set([...md.matchAll(/\]\((https?:\/\/[^)\s]+)/g)].map((m) => m[1]))];
const results = await Promise.all(urls.map(async (url) => ({ url, status: await head(url, timeout) })));
const broken = results.filter((r) => r.status === 0 || r.status >= 400);
return {
content: [{
type: "text" as const,
text: `共 ${urls.length} 条外链,异常 ${broken.length} 条:\n` +
broken.map((r) => `- [${r.status}] ${r.url}`).join("\n"),
}],
};
}
);
接入后,在 Claude Code 里直接说:"用 md-linkcheck 检查 README.md 的外链",LLM 会自动调用 check_links,你不必再手写命令。
flowchart LR
A[createServer 工厂] --> B[npx inspector 手测]
B -->|工具清单/报文正常| C[vitest 契约测试]
B -->|报错| A
C -->|全绿| D[.mcp.json 项目分发]
C -->|失败| A
D --> E[/mcp 验证连接]
E --> F[CI 冒烟+发布清单]
F --> G[团队复用]
await。MCP 工具必须是异步非阻塞的。fetch)与 .mcp.json 路径基准。createServer 里按环境变量做特性开关,让生产工具集最小化,符合最小暴露原则。@modelcontextprotocol/inspectorresources 暴露检查报告.mcp.json 配置权威来源[!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 等场景全覆盖。
