本教程带你用 MCP TypeScript SDK v2(@modelcontextprotocol/server)从零造一个 MCP 服务器,把任意 REST API 包装成 Claude Code 可调用的工具。学完你将掌握:v2 SDK 的包结构与 registerTool API、用 Zod 校验入参/出参、结构化输出(structuredContent)、工具注解(annotations)、stdio 传输,以及如何接入 Claude Code 与 MCP Inspector 调试。适合会写 JavaScript、想把公司内部 API 接入 AI 助手的前端/全栈开发者。
node -v 确认)很多 MCP 教程用的是 Python 的 FastMCP,但 2026 年 TypeScript SDK 已进入 v2 beta,并随 2026-07-28 规范发布。v2 有三个关键变化你必须知道,否则照搬旧博客会直接报错:
@modelcontextprotocol/sdk 不再是推荐入口。现在拆成 @modelcontextprotocol/server(造服务端)和 @modelcontextprotocol/client(造客户端)。server.tool(...) 被替换为 server.registerTool(name, config, handler)。说明:v2 仍处 beta,生产环境官方仍建议用 v1.x(会持续收到 bug 修复至少 6 个月)。本教程面向学习与原型,用的是 v2 主线 API。
mkdir todos-mcp && cd todos-mcp
npm init -y
npm pkg set type=module # 关键:SDK 只发 ES 模块
npm install @modelcontextprotocol/server zod tsx
mkdir src
要点:
- type=module 必须设,否则 import 语法在 Node 里跑不起来。
- tsx 让你直接运行 .ts,无需编译步骤,对原型开发极友好。
预期结果:package.json 里出现三个依赖,目录下多了 node_modules/ 和 src/。
常见问题:npm pkg set 报错?说明 npm 版本太老(< 7),手动在 package.json 加 "type": "module" 即可。
新建 src/index.ts,先写一个最小工具,把 API 框架搭起来:
import { McpServer } from '@modelcontextprotocol/server';
import { serveStdio } from '@modelcontextprotocol/server/stdio';
import * as z from 'zod/v4';
function createServer(): McpServer {
const server = new McpServer({ name: 'todos-mcp', version: '1.0.0' });
server.registerTool(
'ping',
{
description: '连通性测试,原样返回输入',
inputSchema: z.object({ msg: z.string().describe('任意文本') }),
},
async ({ msg }) => ({
content: [{ type: 'text', text: `pong: ${msg}` }],
}),
);
return server;
}
void serveStdio(createServer);
console.error('todos-mcp 已在 stdio 上运行'); // 注意:用 stderr
registerTool 的三段式是 v2 的核心心智模型:
| 参数 | 作用 |
|---|---|
| 第 1 个:工具名 | 模型据此调用,应短且语义化 |
| 第 2 个:config | description + inputSchema(Zod),可选 outputSchema、title、annotations |
| 第 3 个:handler | 异步函数,返回 { content, isError?, structuredContent? } |
你只写一份 Zod schema,SDK 会自动做三件事:① 派生出模型看到的 JSON Schema;② 在 handler 运行前校验入参;③ 推断 handler 的参数类型。.describe() 的文字会作为参数说明传给模型——这是模型理解参数的唯一文档,务必写清。
关键陷阱:stdout 是 JSON-RPC 协议通道,一旦写 console.log,宿主会把日志当损坏消息并断开连接。所有日志必须走 console.error(stderr)。
预期结果:运行 npx tsx src/index.ts,stderr 打印一行 banner,然后阻塞等待客户端。
把 ping 换成真正调 REST 的工具。先包一个"列出待办":
interface Todo { userId: number; id: number; title: string; completed: boolean; }
const API = 'https://jsonplaceholder.typicode.com';
// 放进 createServer() 内:
server.registerTool(
'list-todos',
{
description: '列出最新的若干条待办事项',
inputSchema: z.object({
limit: z.number().int().min(1).max(50).default(10).describe('返回条数,1-50'),
}),
annotations: { readOnlyHint: true }, // 只读:宿主可自动放行
},
async ({ limit }) => {
const res = await fetch(`${API}/todos?_limit=${limit}`);
if (!res.ok) {
return { content: [{ type: 'text', text: `JSONPlaceholder 错误: HTTP ${res.status}` }], isError: true };
}
const todos = (await res.json()) as Todo[];
const lines = todos.map(t => `${t.completed ? '[x]' : '[ ]'} #${t.id} ${t.title}`);
return { content: [{ type: 'text', text: lines.join('\n') }] };
},
);
错误处理遵循 MCP 约定:不要 throw,而是返回 { content, isError: true }。这样模型能读到错误信息并自动重试。传入 limit: 999 时,SDK 会在 handler 执行前就拦截,模型会看到 Input validation error ... limit: Too big。
文本 content 给人看,但模型更适合结构化数据。加 outputSchema + structuredContent:
server.registerTool(
'get-todo',
{
description: '按 id 获取单条待办',
inputSchema: z.object({ id: z.number().int().positive() }),
outputSchema: z.object({
id: z.number(),
userId: z.number(),
title: z.string(),
completed: z.boolean(),
}),
annotations: { readOnlyHint: true },
},
async ({ id }) => {
const res = await fetch(`${API}/todos/${id}`);
if (!res.ok) return { content: [{ type: 'text', text: `HTTP ${res.status}` }], isError: true };
const t = (await res.json()) as Todo;
const out = { id: t.id, userId: t.userId, title: t.title, completed: t.completed };
return {
content: [{ type: 'text', text: `#${t.id} ${t.title}\n完成: ${t.completed}` }],
structuredContent: out, // 机器可读,SDK 会在返回前用 outputSchema 校验
};
},
);
structuredContent 会在离开服务器前被 outputSchema 校验,派生的 JSON Schema 也会出现在 tools/list 里供客户端校验。文本与结构化数据同时返回,是推荐做法。
annotations 不改变执行逻辑,只给宿主行为提示。给"创建待办"打上破坏性标记:
server.registerTool(
'create-todo',
{
title: '创建待办事项', // 展示名(可中文)
description: '创建一条新待办(演示用,不会真落库)',
inputSchema: z.object({
title: z.string().min(1).describe('待办标题'),
completed: z.boolean().default(false),
userId: z.number().int().positive().default(1),
}),
outputSchema: z.object({ id: z.number(), title: z.string(), completed: z.boolean() }),
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false },
},
async ({ title, completed, userId }) => {
const res = await fetch(`${API}/todos`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, completed, userId }),
});
if (!res.ok) return { content: [{ type: 'text', text: `HTTP ${res.status}` }], isError: true };
const t = (await res.json()) as Todo;
return {
content: [{ type: 'text', text: `已创建 #${t.id}: ${t.title}` }],
structuredContent: { id: t.id, title: t.title, completed: t.completed },
};
},
);
三个最常用 hint:readOnlyHint(只读可免确认)、destructiveHint(破坏性需确认)、idempotentHint(幂等可重试)。合理标注能让 Claude Code 自动放行查询、对写入操作弹出确认。
npx @modelcontextprotocol/inspector npx tsx src/index.ts
它会起一个本地网页。点 Connect → Tools 标签 → 选 list-todos → 填 limit: 3 → 运行,就能看到返回的文本块和结构化数据。这是排查问题的第一步:在接入宿主前,先用 Inspector 确认每个工具都能正常调用。
常见问题速查:
| 现象 | 原因 | 解决 |
|---|---|---|
| 进程直接退出/崩溃 | TS 语法或 import 错误 | 先单独跑 npx tsx src/index.ts,读 stderr 报错 |
| 宿主/Inspector 连上就断 | stdout 被 console.log 污染 |
全部改 console.error |
| 工具连上但模型从不调用 | description 太模糊 | 重写 description,说明何时该用 |
| 入参被拒 | Zod 约束过严 | 检查 .min()/.max()/.positive() 是否合理 |
在项目根目录跑一条命令(-- 后面是启动命令):
claude mcp add todos -- npx tsx src/index.ts
进入 Claude Code 会话,输入 /mcp,应看到 todos 已连接,下面列出 list-todos、get-todo、create-todo 等工具。然后直接用自然语言提问:
帮我查一下最新的 5 条待办,再看看第 1 条的详情
模型会自己挑工具、自己填参数(limit: 5、id: 1),你全程无需提"工具名"。这正是 MCP 的价值:把"能力"以工具形式暴露,模型按需调用。
想让配置随项目走(团队共享)?在仓库根建
.mcp.json:json { "mcpServers": { "todos": { "command": "npx", "args": ["tsx", "src/index.ts"] } } }
把上面四步合并,完整 src/index.ts 如下(可直接复制运行):
import { McpServer } from '@modelcontextprotocol/server';
import { serveStdio } from '@modelcontextprotocol/server/stdio';
import * as z from 'zod/v4';
const API = 'https://jsonplaceholder.typicode.com';
interface Todo { userId: number; id: number; title: string; completed: boolean; }
interface User { id: number; name: string; email: string; company: { name: string }; }
function createServer(): McpServer {
const server = new McpServer({ name: 'todos-mcp', version: '1.0.0' });
server.registerTool('list-todos', {
description: '列出最新的若干条待办事项',
inputSchema: z.object({ limit: z.number().int().min(1).max(50).default(10).describe('返回条数 1-50') }),
outputSchema: z.array(z.object({ id: z.number(), title: z.string(), completed: z.boolean() })),
annotations: { readOnlyHint: true },
}, async ({ limit }) => {
const res = await fetch(`${API}/todos?_limit=${limit}`);
if (!res.ok) return { content: [{ type: 'text', text: `HTTP ${res.status}` }], isError: true };
const todos = (await res.json()) as Todo[];
const out = todos.map(t => ({ id: t.id, title: t.title, completed: t.completed }));
return {
content: [{ type: 'text', text: out.map(t => `${t.completed ? '[x]' : '[ ]'} #${t.id} ${t.title}`).join('\n') }],
structuredContent: out,
};
});
server.registerTool('get-todo', {
description: '按 id 获取单条待办',
inputSchema: z.object({ id: z.number().int().positive() }),
outputSchema: z.object({ id: z.number(), userId: z.number(), title: z.string(), completed: z.boolean() }),
annotations: { readOnlyHint: true },
}, async ({ id }) => {
const res = await fetch(`${API}/todos/${id}`);
if (!res.ok) return { content: [{ type: 'text', text: `HTTP ${res.status}` }], isError: true };
const t = (await res.json()) as Todo;
return {
content: [{ type: 'text', text: `#${t.id} ${t.title}\n完成: ${t.completed}` }],
structuredContent: { id: t.id, userId: t.userId, title: t.title, completed: t.completed },
};
});
server.registerTool('get-user', {
description: '按 id 获取用户信息',
inputSchema: z.object({ id: z.number().int().positive() }),
outputSchema: z.object({ id: z.number(), name: z.string(), email: z.string(), company: z.string() }),
annotations: { readOnlyHint: true },
}, async ({ id }) => {
const res = await fetch(`${API}/users/${id}`);
if (!res.ok) return { content: [{ type: 'text', text: `HTTP ${res.status}` }], isError: true };
const u = (await res.json()) as User;
return {
content: [{ type: 'text', text: `${u.name} <${u.email}> @${u.company.name}` }],
structuredContent: { id: u.id, name: u.name, email: u.email, company: u.company.name },
};
});
server.registerTool('create-todo', {
title: '创建待办事项',
description: '创建一条新待办(演示用,不会真落库)',
inputSchema: z.object({
title: z.string().min(1).describe('待办标题'),
completed: z.boolean().default(false),
userId: z.number().int().positive().default(1),
}),
outputSchema: z.object({ id: z.number(), title: z.string(), completed: z.boolean() }),
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false },
}, async ({ title, completed, userId }) => {
const res = await fetch(`${API}/todos`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, completed, userId }),
});
if (!res.ok) return { content: [{ type: 'text', text: `HTTP ${res.status}` }], isError: true };
const t = (await res.json()) as Todo;
return {
content: [{ type: 'text', text: `已创建 #${t.id}: ${t.title}` }],
structuredContent: { id: t.id, title: t.title, completed: t.completed },
};
});
return server;
}
void serveStdio(createServer);
console.error('todos-mcp 已在 stdio 上运行');
项目结构:
todos-mcp/
├── package.json # type=module + 三个依赖
├── .mcp.json # 可选:随仓库共享的配置
└── src/
└── index.ts # 上面的完整代码
验收:npx @modelcontextprotocol/inspector npx tsx src/index.ts 能逐个调通四个工具;claude mcp add 后用自然语言"列出 3 条待办并查第 2 条用户"能自动串联两次工具调用。
下面是一次工具调用的完整往返(六步),理解它对调试至关重要:
sequenceDiagram
participant U as 用户(Claude Code)
participant M as 模型
participant C as MCP客户端(宿主内)
participant S as todos-mcp服务器
participant A as REST API
U->>M: "查最新5条待办"
Note over C,S: 启动时已用 tools/list 同步工具清单
M->>C: 决定调用 list-todos {limit:5}
C->>S: tools/call 请求(stdio)
S->>S: Zod 校验入参(5∈1-50 ✓)
S->>A: GET /todos?_limit=5
A-->>S: JSON 数组
S->>S: outputSchema 校验结构化输出
S-->>C: content + structuredContent
C-->>M: 工具结果
M-->>U: 用自然语言汇总答案
工具注册到执行的内部流程:
flowchart TD
A[写一份 Zod inputSchema] --> B[registerTool 注册]
B --> C{客户端 tools/call}
C --> D[SDK 自动派生 JSON Schema 给模型]
C --> E[SDK 用 Zod 校验入参]
E -->|不合法| F[返回 isError=true 模型可读]
E -->|合法| G[运行 handler]
G --> H[fetch REST API]
H --> I[outputSchema 校验 structuredContent]
I --> J[返回 content + structuredContent]
createServer 工厂改用 HTTP 中间件包(@modelcontextprotocol/express / hono / node)。tool() 自动改写成 registerTool(),详见仓库 docs/migration/upgrade-to-v2.md。bin 字段把服务器发成 CLI;用 @modelcontextprotocol/inspector 做冒烟测试。| 维度 | 评估 |
|---|---|
| 适合人群 | 前端/全栈/平台工程师,会 TS,想把内部系统接入 AI |
| 训练营类型 | 企业内训(API 智能化)、开发者社区 meetup、AI 工程训练营的"集成"模块 |
| 预计教学时长 | 3–4 小时(讲解 1h + 跟练 2h + 改造自家 API 1h) |
| 难度 | 中等(核心是 registerTool 心智模型 + Zod) |
| 可拓展作业 | 学员把公司一个真实 REST API 包成 3 个工具,接入 Claude Code 演示 |
把这套"REST API → MCP 工具"的模式推广到团队,意味着任何已有的后端接口都能零改造地变成 Claude Code 的能力——这是企业落地 AI 编程助手性价比最高的一步。
[!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 等场景全覆盖。
