本教程面向已熟悉命令行和 TypeScript 基础的开发者,目标是带你从零构建一个生产可用的本地 MCP 服务器,并完整接入 Claude Code 的三套作用域(local / project / user)。内容覆盖官方 TypeScript 高层 McpServer API、Zod 输入校验、stdio 传输工程化细节、结构化输出、MCP Inspector 调试,并前瞻性地讲解 2026 年规范(SEP-2577 弃用 Roots/Sampling/Logging、2026-07-28 无状态候选版)带来的迁移影响。学完后你将能独立交付一个团队可复用、随仓库提交、面向未来不踩坑的 MCP 工具。
node -v 可用tsconfig.json、ESM 模块)claude --version)不需要你此前写过 MCP 服务器——这正是本教程的起点。
MCP(Model Context Protocol)是 Anthropic 主导的开放协议,让 AI 客户端(如 Claude Code)以标准方式连接外部工具与数据。一个 MCP 服务器对外暴露三类"原语(primitive)":
| 原语 | 角色 | 类比 |
|---|---|---|
| Tool | 模型可主动调用的函数 | REST 里的 POST 接口 |
| Resource | 模型可读取的数据源(含 URI 模板) | REST 里的 GET 接口 |
| Prompt | 预制的、可参数化的提示词模板 | 函数库里的"快捷指令" |
客户端与服务器之间通过"传输层(transport)"通信。本地工具用 stdio(标准输入输出),远程服务用 Streamable HTTP。本教程聚焦 stdio——这也是 Claude Code 接入本地工具的标准方式。
flowchart LR
CC[Claude Code 客户端] -- JSON-RPC over stdio --> TRP[StdioServerTransport]
TRP <--> SRV[McpServer]
SRV --> T[Tools]
SRV --> R[Resources]
SRV --> P[Prompts]
T --> FS[(本地文件 / Git / 命令)]
R --> FS
记住一条铁律:stdio 模式下,stdout 是协议通道,绝不能往 stdout 打印日志——否则协议帧被污染,握手直接失败。所有日志走 stderr。
新建项目目录并初始化:
mkdir codestats-mcp && cd codestats-mcp
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node tsx tsup
npx tsc --init
调整 package.json 关键字段(这是让它能被 node 与 npx 正确驱动的核心):
{
"name": "codestats-mcp",
"version": "0.1.0",
"type": "module",
"bin": { "codestats-mcp": "./dist/index.js" },
"main": "./dist/index.js",
"scripts": {
"build": "tsup src/index.ts --format esm --dts",
"dev": "tsx src/index.ts",
"start": "node dist/index.js"
}
}
tsconfig.json 建议开启严格模式与 NodeNext 模块解析:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"outDir": "dist",
"skipLibCheck": true
},
"include": ["src"]
}
预期结果:npm run dev 能启动且不报错(此时还没有逻辑,进程会等待 stdin)。常见问题:若报 ERR_UNKNOWN_FILE_EXTENSION,几乎都是忘了 "type": "module" 或 moduleResolution 没设成 NodeNext。
创建 src/index.ts。高层 McpServer 把协议细节封装掉了,你只需声明"工具名 + Zod schema + 处理函数":
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { readdir, stat } from "node:fs/promises";
import { join, extname } from "node:path";
const server = new McpServer({
name: "codestats-mcp",
version: "0.1.0",
});
// 工具:统计某目录下指定扩展名的文件数量
server.tool(
"count_files", // 工具名
"Count files of a given extension in a directory", // 描述(模型据此决定是否调用)
{
directory: z.string().describe("Absolute or relative directory path"),
extension: z.string().default(".ts").describe("File extension, e.g. .ts"),
},
async ({ directory, extension }) => {
const entries = await readdir(directory, { withFileTypes: true });
const matched = entries
.filter((e) => e.isFile() && extname(e.name) === extension)
.map((e) => e.name);
return {
content: [
{
type: "text",
text: `Found ${matched.length} ${extension} files:\n${matched.join("\n")}`,
},
],
};
}
);
const transport = new StdioServerTransport();
await server.connect(transport);
要点拆解:
server.tool 的第二个参数是给模型看的自然语言说明,写得越具体、越强调副作用与返回结构,模型调用准确率越高。z.string()、.default()、.describe() 会自动转成 JSON Schema 下发给客户端,模型据此生成参数;校验失败由 SDK 拦截,不会进入你的处理函数。{ content: ContentItem[], isError?: boolean }。ContentItem 通常是 { type: "text", text }。预期结果:npm run build && node dist/index.js 启动后静默等待——因为 stdio 服务器靠 stdin 喂 JSON-RPC 消息,直接 node 跑看不到交互,这就是下一步要引入 Inspector 的原因。
为了让服务器"三原语齐全",再加一个资源和一个提示词。资源适合"模型按需读取"的数据,提示词适合"固化最佳问法"的场景:
import { ReadResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
// 资源:读取单个文件内容,使用 URI 模板
server.resource(
"file",
new ReadResourceTemplate("file://{path}", ({ path }) => ({ path })),
async (uri, { path }) => {
const { readFile } = await import("node:fs/promises");
const text = await readFile(path, "utf8");
return {
contents: [
{ uri: uri.href, mimeType: "text/plain", text },
],
};
}
);
// 提示词:生成"代码库摘要"的预设问法
server.prompt(
"summarize_codebase",
{ directory: z.string() },
async ({ directory }) => ({
messages: [
{
role: "user",
content: {
type: "text",
text: `请分析目录 ${directory} 的代码结构,先列出主要模块,再给出 3 条重构建议。`,
},
},
],
})
);
何时用 Tool 而非 Resource? 经验法则:有副作用(写文件、发请求、改状态)→ Tool;纯读取、幂等、可缓存 → Resource;需要"封装一套提问套路"给非技术用户 → Prompt。三者并非互斥,但混用时要克制——原语越多,模型的选择空间越大,误触发概率也越高。
工具返回的不仅是文本。SDK 支持多种 content 类型,善用它们能显著降低模型的理解成本:
// 1. 图片输出(例如把统计结果画成图后回传)
import { readFileSync } from "node:fs";
server.tool("logo_b64", "Return a PNG as base64 image", {}, async () => ({
content: [
{
type: "image",
data: readFileSync("./logo.png").toString("base64"),
mimeType: "image/png",
},
],
}));
// 2. 内嵌资源(把文件作为"可引用资源"返回,而非纯文本)
server.tool(
"list_files_paged",
"List files with cursor-based pagination",
{
directory: z.string(),
cursor: z.string().optional().describe("Opaque cursor from previous page"),
pageSize: z.number().int().min(1).max(100).default(20),
},
async ({ directory, cursor, pageSize }) => {
const all = (await readdir(directory)).sort();
const startIdx = cursor ? all.indexOf(cursor) + 1 : 0;
const page = all.slice(startIdx, startIdx + pageSize);
const nextCursor =
startIdx + pageSize < all.length ? page[page.length - 1] : undefined;
return {
content: [
{
type: "text",
text: JSON.stringify(
{ files: page, nextCursor },
null,
2
),
},
],
};
}
);
设计要点:
nextCursor 原样回传即可。前面强调过 stdout 不能写日志。把日志、错误处理规范化:
import { setTimeout as sleep } from "node:timers/promises";
const log = (...args: unknown[]) =>
process.stderr.write(`[codestats] ${args.join(" ")}\n`);
// 统一的错误返回:用 isError 标记失败,而非抛异常
async function safe(fn: () => Promise<string>) {
try {
return { content: [{ type: "text", text: await fn() }] };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
log("ERROR:", msg);
return {
isError: true,
content: [
{
type: "text",
text: `Tool failed: ${msg}. Check the directory path and permissions.`,
},
],
};
}
}
// 改造后的 count_files,复用 safe
server.tool(
"count_files_safe",
"Count files of a given extension (robust version)",
{
directory: z.string(),
extension: z.string().default(".ts"),
},
async ({ directory, extension }) =>
safe(async () => {
const entries = await readdir(directory, { withFileTypes: true });
const n = entries.filter(
(e) => e.isFile() && extname(e.name) === extension
).length;
return `Found ${n} ${extension} files in ${directory}`;
})
);
process.on("uncaughtException", (e) => log("uncaught:", e.message));
process.on("unhandledRejection", (e) => log("unhandled:", String(e)));
为什么用 isError: true 而不是抛异常? 抛异常会让 JSON-RPC 层返回一个内部错误,模型看到的是"工具崩溃",往往无法自我修复;而 isError: true 把失败信息当作正常返回内容,模型读到后能据此重试或换路径。这是把"鲁棒性"做进协议层的关键习惯。
package.json 里已配置了 bin 字段和 tsup。构建后给入口加上 shebang,让产物能直接当命令跑:
npm run build
确认 dist/index.js 顶部有 #!/usr/bin/env node(tsup 的 --banner 可注入;或在 src/index.ts 顶部手写一行 #!/usr/bin/env node,构建时会保留)。验证:
./dist/index.js # 应静默等待,按 Ctrl+C 退出
这一步的意义在于:Claude Code 的 MCP 配置里,command 要么指向解释器+脚本,要么指向这个可直接执行的二进制。能直接执行意味着团队同学 npm i -g 后无需关心路径。
Claude Code 把 MCP 配置存在三个作用域,理解它们的差异是团队协作的基础:
| 作用域 | 存储位置 | 适用场景 | 是否随仓库 |
|---|---|---|---|
| local | ~/.claude.json(当前项目路径下) |
只在这台机器、这个项目用 | 否 |
| project | 项目根 .mcp.json |
团队共享,提交到 Git | ✅ 是 |
| user | ~/.claude.json(全局) |
个人跨项目通用工具 | 否 |
flowchart TD
A[claude mcp add] --> L{--scope}
L -->|local 默认| LC[写入 ~/.claude.json 项目段]
L -->|project| PC[写入 ./.mcp.json 可提交]
L -->|user| UC[写入 ~/.claude.json 全局段]
PC -->|git commit & push| TEAM[团队成员拉取即生效]
UC --> ALL[你所有项目可用]
推荐做法:本项目专用工具用 project 作用域,让 .mcp.json 随仓库走:
claude mcp add codestats --scope project -- node ./dist/index.js
这会生成项目根的 .mcp.json:
{
"mcpServers": {
"codestats": {
"command": "node",
"args": ["./dist/index.js"]
}
}
}
需要传环境变量或参数时:
claude mcp add codestats \
--scope project \
-e CODESTATS_ROOT=/srv/repos \
-- node ./dist/index.js --verbose
对应的 .mcp.json 会带上 env 字段。注意:.mcp.json 里若含密钥(如 API Key),务必加入 .gitignore,改用 CI 注入或 user 作用域存放。
验证接入——这是最容易被忽略却最关键的一步:
claude mcp list # 列出所有已注册服务器
claude mcp get codestats # 查看某服务器的详细配置与连接状态
claude mcp get 会显示服务器的 ✓ Connected 状态及其暴露的 tools/resources/prompts 数量。若显示连接失败,回到第六步查 stderr 日志。进入 Claude Code 后输入 /mcp 也能实时查看每个服务器的健康度。
常见问题与解决:
command not found: node:Claude Code 启动子进程时不一定加载你的 shell 配置。解决方法是写绝对路径 /usr/local/bin/node,或用 npx、或把二进制软链到 PATH 标准位置。2>/dev/null 隔离法逐步定位,或在该依赖里关闭其 stdout 输出。.mcp.json 里的相对路径是"相对于项目根"还是"相对于启动目录"在不同客户端版本有差异。生产环境一律用绝对路径或 ${workspaceFolder} 类变量最稳。Claude Code 不利于"逐工具调试"——每次都要进会话、让模型去调。开发期强烈建议用官方 MCP Inspector,它是一个可视化测试台:
npx @modelcontextprotocol/inspector node ./dist/index.js
浏览器打开后,你能:
典型排错路径:"模型没调用我的工具" → 先在 Inspector 里手动调通 → 确认返回结构合法 → 再回到 Claude Code 检查工具描述是否足够让模型识别触发场景。绝大多数"工具不触发"问题,根因是描述写得太抽象(例如只写 count files 而不写"何时该用、返回什么")。
MCP 在 2026 年经历了一次大手术,开发新服务器时必须知晓,否则会建在即将弃用的地基上:
对本教程的实践含义很明确:
codestats 全程无状态、靠参数传目录、日志走 stderr、不用 Sampling——天然符合新规范。@modelcontextprotocol/sdk 锁定到具体 minor 版本,并在 CI 里跑一次 Inspector 冒烟测试,以防 SDK 升级引入 breaking change。把前述片段整合成一个完整可运行项目。目录结构:
codestats-mcp/
├── package.json
├── tsconfig.json
├── .mcp.json # project 作用域配置(提交 Git)
└── src/
└── index.ts # 入口:tools + resource + prompt
src/index.ts 完整版(合并第一到第六步的代码,去掉重复 import)。能力清单:
count_files_safe — 按扩展名统计文件,含错误兜底list_files_paged — 游标分页列文件file://{path} — 读取任意文件资源summarize_codebase — 代码库摘要提示词跑通流程:
npm install && npm run build
claude mcp add codestats --scope project -- node ./dist/index.js
claude mcp get codestats # 看到 ✓ Connected, tools: 2, resources: 1, prompts: 1
在 Claude Code 会话里测试真实调用:
> 统计一下 src 目录下有多少个 .ts 文件
(Claude 自动调用 count_files_safe,返回数量与文件名)
> 帮我看看 src/index.ts 的内容,然后用 summarize_codebase 的思路给重构建议
(Claude 先读取 file://src/index.ts 资源,再应用提示词框架)
把 .mcp.json 提交到仓库后,团队成员 git pull 即获得相同的工具能力——这是 MCP"团队级工具复用"的核心价值。
flowchart TD
A[npm init + 安装 SDK] --> B[写 McpServer tool/resource/prompt]
B --> C[tsup 构建 + shebang]
C --> D{MCP Inspector 手动验证}
D -->|失败| B
D -->|通过| E[claude mcp add --scope]
E --> F[claude mcp get 验证连接]
F --> G{Connected?}
G -->|否| H[查 stderr 日志/路径/stdout 污染]
H --> D
G -->|是| I[会话内 /mcp + 真实调用测试]
I --> J[提交 .mcp.json 团队共享]
J --> K[CI 跑 Inspector 冒烟测试]
McpServer 高层 API 与 Zod 用法基准.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 等场景全覆盖。
