本教程面向已有 REST API 想快速让 Claude 使用的开发者。你将掌握"把一组 HTTP 端点封装成生产级 MCP 工具"的完整工程化方法:端点到工具的映射原则、用 Zod 做输入校验与结构化输出(outputSchema + structuredContent)、把分页游标显式暴露为工具参数、把 HTTP 错误干净地映射成 isError,最后用 Inspector 全链路调试并接入 Claude Code 的三个作用域。全程基于 2026-06-18 仍为生产推荐的 TypeScript SDK v1.x,并给出 v2(计划 2026-07-28 稳定)的迁移要点。实战案例是一个"网元告警 API"封装,无需任何外部密钥即可跑通。
fetch 和原生 http 模块)async/await 与 JSON为什么单独讲"封装 API"?因为这件事和"从零写一个新工具"的难点完全不同。新工具的逻辑由你掌控,而封装 API 时,真正的工程难点在于把别人设计好的 HTTP 语义翻译成 LLM 友好的工具语义——这正是本章的主线。
REST API 和 MCP 工具是两种不同的契约,先想清楚映射,再动手写代码,能省掉后面 80% 的返工。核心原则有五条:
1. 一个端点 = 一个工具,除非它在做两件事。 GET /alarms 就是一个 list-alarms 工具,不要图省事把 GET /alarms 和 GET /alarms/:id 塞进同一个带 mode 参数的工具。LLM 选工具靠的是工具名和描述的语义清晰度,多用途工具会显著降低调用准确率。
2. 把查询参数变成工具的输入字段,但只保留 LLM 真正会用的。 REST 端点常有十几个查询参数(分页、排序、字段过滤、追踪 ID……),其中大部分是给前端或网关用的。工具输入只保留 LLM 推理时会用到的那几个,其余的(如调用方追踪 ID)由服务器内部固定,不暴露给模型。
3. 分页不要藏起来。 永远不要在工具里"自动循环翻完所有页"——这会烧掉大量 token、拖慢响应,还可能踩到 API 的限流。正确做法是把分页变成工具参数:输入里给 limit 和 cursor,输出里给 nextCursor,让 LLM 自己决定要不要翻下一页。
4. 读操作返回结构化数据,写操作返回人类可读的确认。 列表、详情这类读工具同时返回 structuredContent(机器可读的完整数据)和一段 content 文本(给 LLM 的简短摘要)。而 acknowledge 这类变更工具,重点是把"成功/失败 + 后果"用文本讲清楚。
5. HTTP 错码必须翻译成 isError: true。 永远不要让工具返回 200 + 一段 "error" 文本——LLM 会以为成功了。404、401、超时、5xx 都要捕获,转成 isError: true 加一句"为什么失败、接下来该怎么办"的文本。
这五条原则贯穿后面的全部代码。
为了让教程无需任何外部密钥就能跑通,我们先用 Node 内置 http 模块写一个零依赖的"网元告警 API"作为被封装对象。它模拟了一个典型的内部运维接口:列表支持按严重等级过滤和游标分页,详情按 id 查,确认操作会改变状态。
新建目录 mcp-wrap/,在 mcp-wrap/mock-api/server.js 写入:
// mock-api/server.js — 零依赖的网元告警 REST API
const http = require('http');
const alarms = [
{ id: 'A-1001', ne: 'gNB-North-07', severity: 'critical', text: 'S1 链路中断', raisedAt: '2026-06-18T08:12:00Z', acked: false },
{ id: 'A-1002', ne: 'AMF-East-02', severity: 'major', text: 'CPU 使用率 > 90%', raisedAt: '2026-06-18T08:30:00Z', acked: false },
{ id: 'A-1003', ne: 'UPF-Core-01', severity: 'minor', text: '会话数接近阈值', raisedAt: '2026-06-18T09:01:00Z', acked: true },
{ id: 'A-1004', ne: 'SMF-West-03', severity: 'critical', text: 'PFCP 心跳超时', raisedAt: '2026-06-18T09:15:00Z', acked: false },
{ id: 'A-1005', ne: 'gNB-South-11', severity: 'major', text: '光功率异常', raisedAt: '2026-06-18T09:22:00Z', acked: false },
];
function send(res, status, body) {
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(body));
}
const server = http.createServer((req, res) => {
const url = new URL(req.url, 'http://localhost');
// GET /alarms?severity=&limit=&cursor=
if (req.method === 'GET' && url.pathname === '/alarms') {
const sev = url.searchParams.get('severity');
const limit = Math.min(parseInt(url.searchParams.get('limit') || '20', 10), 100);
const cursor = parseInt(url.searchParams.get('cursor') || '0', 10);
let filtered = alarms.filter(a => !sev || a.severity === sev);
const slice = filtered.slice(cursor, cursor + limit);
const nextCursor = cursor + limit < filtered.length ? String(cursor + limit) : null;
return send(res, 200, { items: slice, nextCursor, total: filtered.length });
}
// GET /alarms/:id
const m = url.pathname.match(/^\/alarms\/([^/]+)$/);
if (req.method === 'GET' && m) {
const a = alarms.find(x => x.id === m[1]);
return a ? send(res, 200, a) : send(res, 404, { error: 'alarm not found' });
}
// POST /alarms/:id/acknowledge body { note }
const am = url.pathname.match(/^\/alarms\/([^/]+)\/acknowledge$/);
if (req.method === 'POST' && am) {
const a = alarms.find(x => x.id === am[1]);
if (!a) return send(res, 404, { error: 'alarm not found' });
let body = '';
req.on('data', c => body += c);
req.on('end', () => {
const { note = '' } = body ? JSON.parse(body) : {};
a.acked = true; a.ackNote = note; a.ackedAt = new Date().toISOString();
return send(res, 200, a);
});
return;
}
send(res, 404, { error: 'no route' });
});
server.listen(3001, () => console.log('alarm API on http://localhost:3001'));
启动并自测:
node mock-api/server.js
# 另开终端:
curl -s 'http://localhost:3001/alarms?severity=critical&limit=1'
# 预期: {"items":[{...A-1001...}],"nextCursor":"1","total":2}
看到 nextCursor 字段说明分页契约工作正常——这是后面封装成工具的关键。
回到 mcp-wrap/,初始化工程:
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node
npx tsc --init
把 package.json 的 type 改为 "module",并把 tsconfig.json 关键项设为:
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "Bundler",
"outDir": "dist",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src"]
}
在 package.json 的 scripts 里加:
"build": "tsc",
"start": "node dist/server.js"
工程骨架就绪。注意:这里用的是 @modelcontextprotocol/sdk(v1.x 单包)。到 2026-07-28 v2 正式发布后,它会拆成 @modelcontextprotocol/server 和 @modelcontextprotocol/client 两个包——迁移要点见文末。
list-alarms(含分页与结构化输出)新建 src/server.ts,先搭骨架并实现列表工具:
// src/server.ts
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
const BASE = process.env.API_BASE_URL || 'http://localhost:3001';
async function httpGet(path: string) {
const res = await fetch(`${BASE}${path}`);
const body = await res.json().catch(() => ({}));
return { ok: res.ok, status: res.status, body };
}
const server = new McpServer({ name: 'alarm-mcp', version: '1.0.0' });
server.registerTool(
'list-alarms',
{
description:
'列出网元告警。可按 severity 过滤,分页获取。' +
'若返回的 nextCursor 不为 null,表示还有更多,可用该值作为下一次的 cursor 继续翻页。',
inputSchema: {
severity: z.enum(['critical', 'major', 'minor']).optional()
.describe('按严重等级过滤;不传则返回全部'),
limit: z.number().int().min(1).max(100).optional()
.describe('单页数量,默认 20,最大 100'),
cursor: z.string().optional().describe('上一页返回的 nextCursor'),
},
outputSchema: {
alarms: z.array(z.object({
id: z.string(), ne: z.string(), severity: z.string(),
text: z.string(), acked: z.boolean(),
})),
nextCursor: z.string().nullable(),
total: z.number(),
},
},
async ({ severity, limit = 20, cursor }) => {
const params = new URLSearchParams();
if (severity) params.set('severity', severity);
params.set('limit', String(limit));
if (cursor) params.set('cursor', cursor);
const { ok, status, body } = await httpGet(`/alarms?${params}`);
if (!ok) {
return {
content: [{ type: 'text', text: `查询失败 (HTTP ${status}):${JSON.stringify(body)}` }],
isError: true,
};
}
// 给 LLM 的简短摘要 + 给程序的完整结构化数据
const summary = body.items.map((a: any) =>
`[${a.id}] ${a.severity} ${a.ne} — ${a.text}${a.acked ? ' (已确认)' : ''}`).join('\n');
const hint = body.nextCursor
? `\n\n还有更多,可用 cursor="${body.nextCursor}" 翻下一页。`
: '\n\n已是最后一页。';
return {
content: [{ type: 'text', text: `共 ${body.total} 条,本页 ${body.items.length} 条:\n${summary}${hint}` }],
structuredContent: { alarms: body.items, nextCursor: body.nextCursor, total: body.total },
};
}
);
这里有四个必须强调的细节:
inputSchema 传的是 Zod 原始形状对象({ severity: z.enum(...) }),而不是 z.object({...})。这是 v1.x registerTool 最容易踩的坑——传成 z.object 会导致校验报错。.describe()。这些描述会和工具描述一起进 JSON Schema,直接影响 LLM 选参的准确率,写得越具体越好。outputSchema 和 structuredContent 成对出现:前者声明结构(让客户端和未来的 MCP 版本能解析),后者是实际数据。这对"工具输出要被下游程序消费"的场景至关重要。limit/cursor 是输入,nextCursor 是输出,且 description 里明说"还有更多请翻页"。这是封装 API 时的核心纪律。get-alarm继续在同一个文件里追加详情工具:
server.registerTool(
'get-alarm',
{
description: '按 id 获取单个网元告警的完整详情。',
inputSchema: { id: z.string().describe('告警 ID,如 A-1001') },
outputSchema: {
id: z.string(), ne: z.string(), severity: z.string(),
text: z.string(), raisedAt: z.string(), acked: z.boolean(),
},
},
async ({ id }) => {
const { ok, status, body } = await httpGet(`/alarms/${encodeURIComponent(id)}`);
if (!ok) {
// 404 映射成 isError,并告诉 LLM 怎么办
return {
content: [{ type: 'text',
text: status === 404
? `告警 ${id} 不存在。请先用 list-alarms 核对正确的 id。`
: `获取失败 (HTTP ${status}):${JSON.stringify(body)}` }],
isError: true,
};
}
return {
content: [{ type: 'text', text: JSON.stringify(body, null, 2) }],
structuredContent: body,
};
}
);
注意 404 这一支:不只是返回错误,还主动给出"下一步建议"(去 list-alarms 核对 id)。LLM 拿到这种带恢复路径的错误文本,往往会自动重试而不是卡住。
acknowledge-alarm(含错误映射)变更类工具要同时处理鉴权、找不到资源、幂等等情况。这里演示完整的错误映射:
async function httpPost(path: string, payload: any) {
const res = await fetch(`${BASE}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const body = await res.json().catch(() => ({}));
return { ok: res.ok, status: res.status, body };
}
server.registerTool(
'acknowledge-alarm',
{
description: '确认(派单/消警)一条告警。这是写操作,会改变告警状态。',
inputSchema: {
id: z.string().describe('要确认的告警 ID'),
note: z.string().optional().describe('可选的处理备注'),
},
outputSchema: {
id: z.string(), acked: z.boolean(), ackedAt: z.string().nullable(),
},
},
async ({ id, note }) => {
const { ok, status, body } = await httpPost(
`/alarms/${encodeURIComponent(id)}/acknowledge`, { note });
if (!ok) {
return {
content: [{ type: 'text',
text: status === 404
? `告警 ${id} 不存在,无法确认。`
: `确认失败 (HTTP ${status}):${JSON.stringify(body)}` }],
isError: true,
};
}
return {
content: [{ type: 'text',
text: `已确认告警 ${body.id}(${body.ne})。${note ? '备注:' + note : ''}` }],
structuredContent: { id: body.id, acked: body.acked, ackedAt: body.ackedAt ?? null },
};
}
);
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
}
main();
编译:
npm run build
预期 dist/server.js 生成且无 TS 报错。如果报 "Cannot find module ... server/mcp.js",确认依赖装好、moduleResolution 设为 Bundler。
不要直接接 Claude Code 就开始盲调——先用 MCP Inspector 单独验证工具本身是否正确。Inspector 是官方的交互式调试器:
npx @modelcontextprotocol/inspector node dist/server.js
打开浏览器后,注意两点:第一,你的 mock API 必须先启动(另开终端 node mock-api/server.js),因为 MCP 服务器默认连 http://localhost:3001;第二,如果 API 地址不是默认的,在 Inspector 的 Environment 里设 API_BASE_URL=http://你的地址。
在 Inspector 的 Tools 面板里,依次测:
list-alarms 不带参数 → 应返回 5 条,nextCursor 为 null(因为 limit 默认 20 大于总数 5)。list-alarms 带 severity=critical, limit=1 → 返回 1 条,nextCursor="1";再用 cursor="1" 翻页拿到第二条。get-alarm 带 id=A-1001 → 返回完整详情;带 id=NOPE → 返回 isError: true 且文本提示去查列表。acknowledge-alarm 带 id=A-1002, note=已派单 → 返回 acked: true。在 Inspector 的 "Structured Content" 区域能看到结构化输出被正确识别,说明 outputSchema 配对了。这一步全绿,才进入下一步。
Claude Code 给 MCP 服务器提供了三个作用域,封装好的工具放哪一档,决定了谁能用:
# 1) local(默认):只对当前项目当前机器的你可见
claude mcp add alarm-mcp -- node /绝对路径/mcp-wrap/dist/server.js
# 2) user:你在这台机器上所有项目都能用
claude mcp add alarm-mcp -s user -- node /绝对路径/mcp-wrap/dist/server.js
# 3) project:随仓库走,团队共享 —— 写进 .mcp.json 并提交
对于团队场景,推荐用 project 作用域。在仓库根目录建 .mcp.json:
{
"mcpServers": {
"alarm-mcp": {
"command": "node",
"args": ["./mcp-wrap/dist/server.js"],
"env": { "API_BASE_URL": "http://localhost:3001" }
}
}
}
注意 env 字段——这里把 API_BASE_URL 显式声明出来,团队成员或不同环境(测试/生产)只改这一个值即可切后端,不必动代码。提交 .mcp.json 后,队友在项目里首次启动 Claude Code 会看到"是否信任并启用此项目的 MCP 服务器",确认即生效。
验证接入:
claude mcp list # 应看到 alarm-mcp 及其工具数
claude mcp get alarm-mcp # 查看命令、参数、env、状态
claude mcp get 显示 ✔ Connected 才算成功。如果显示连接失败,看下面常见问题。
确保 mock API 和 MCP 服务器都就绪,进入项目目录启动 claude,直接对话:
list-alarms(severity=critical),读到 2 条。list-alarms 或用上一轮的 id,再 acknowledge-alarm(id=A-1001, note=...),返回确认成功。get-alarm,拿到 isError 提示后,自动改去 list-alarms 核对——这正是我们在错误文本里写"恢复路径"的回报。工具在 Inspector 里正常,但 claude mcp get 报连接失败。 九成是 stdout 被污染。MCP 协议靠 stdin/stdout 传 JSON-RPC,你的服务器绝不能往 stdout 写任何 console.log。日志一律走 console.error(stderr),Claude Code 会把它写到日志文件。另外确认命令里用的是绝对路径,claude 进程的工作目录可能和你想的不一样。
Inspector 里工具列表为空。 检查 registerTool 的第三参数(inputSchema)传的是不是原始形状对象 { k: z.x() } 而非 z.object({...});写操作的工具如果忘了导出 main()/connect,进程会立刻退出。
LLM 翻页时丢了 cursor。 在 description 和返回文本里都强调"还有更多,请用 nextCursor 翻页"(本教程已这么做)。光靠 structuredContent 里的字段,模型未必会主动继续。
HTTP 401/超时被当成成功。 一律捕获并在 !ok 分支返回 isError: true。对超时,给 fetch 包一层超时(如 AbortSignal.timeout(5000))后同样转成 isError,并提示"后端无响应,稍后再试"。
想在生产环境用真实 API,要不要鉴权? 不要把 token 写死在代码里。通过 env 传入(ALARM_API_TOKEN),在服务器里 process.env 读取并加到请求头;.mcp.json 里的敏感值用 ${VAR} 引用本机环境变量,不要明文提交。
今天(2026-06-18)v1.x 仍是生产推荐版本,但 v2 已在 main 分支预演,稳定版计划 2026-07-28 随新规范发布。迁移时注意三处变化:包从 @modelcontextprotocol/sdk 拆为 @modelcontextprotocol/server(建服务)和 @modelcontextprotocol/client(建客户端);校验库从绑定 Zod 改为支持 Standard Schema(Zod v4 / Valibot / ArkType 任选),import * as z from 'zod/v4';registerTool 仍是主 API,但老的 server.tool(...) 便捷重载可能被移除。本教程的 registerTool + 原始形状 schema + structuredContent 写法在 v2 下基本兼容,迁移成本主要在 import 路径和包名。v2 稳定前继续用 v1.x 是稳妥选择。
flowchart TD
A[REST API 端点] --> B{端点→工具映射}
B --> |GET /alarms| T1[list-alarms 工具<br/>分页入参 + structuredContent]
B --> |GET /alarms/:id| T2[get-alarm 工具<br/>404→isError+恢复提示]
B --> |POST acknowledge| T3[acknowledge-alarm 工具<br/>写操作+错误映射]
T1 & T2 & T3 --> S[McpServer<br/>@modelcontextprotocol/sdk v1.x]
S --> TR[StdioServerTransport]
TR --> I[Inspector 全链路调试<br/>单工具验证绿]
I --> CC[Claude Code 三作用域<br/>local/user/project .mcp.json]
CC --> ENV[env: API_BASE_URL<br/>切后端不改代码]
ENV --> U[团队共享 / 测试生产切换]
把前九步连起来,就是一个可演示的端到端案例。仓库结构如下:
mcp-wrap/
├── mock-api/server.js # 被封装的零依赖 REST API
├── src/server.ts # MCP 服务器(三个工具)
├── dist/ # tsc 产物
├── .mcp.json # project 作用域团队共享配置
└── package.json
一次完整的值守演示流程:
# 终端1:起后端
node mock-api/server.js
# 终端2:构建 MCP 服务器
cd mcp-wrap && npm run build
# 终端3:在项目里用 Claude 值守
claude
> 列出所有未确认的 critical 告警,逐条确认并备注"已派单"
最后一句会被 Claude 拆成"先 list-alarms(severity=critical) 拿列表 → 过滤 acked=false → 对每条 acknowledge-alarm"的多步工具调用。你能在对话里实时看到每一次工具调用和结构化返回,整个过程不需要你写任何胶水代码——这正是把 API 封装成 MCP 工具的价值:让 LLM 直接驱动你的运维接口,而不是让 LLM 生成调用代码再由人执行。
github.com/modelcontextprotocol/typescript-sdk(v1.x 文档在 v1.x 分支,v2 预览在 main)modelcontextprotocol.io(Tools、Resources、Prompts 三原语及 Streamable HTTP 传输)docs.claude.com/en/docs/claude-code/mcpgithub.com/modelcontextprotocol/inspectorzod.devseverity 过滤换成 ne(按网元)过滤;增加一个批量确认工具并设计其错误处理;用 Express 中间件给 mock API 加一层 Token 鉴权,再在 MCP 服务器里通过 env 传入。registerTool、StdioServerTransport、inputSchema/outputSchema、structuredContent 与 isError 的权威用法,以及 v1.x/v2 包结构说明[!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 等场景全覆盖。
