首页 › tutorials › 把现有API封装为MCP工具

把现有 API 封装为 MCP 工具:从端点映射到 Claude Code 接入全流程

概述

本教程面向已有 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"封装,无需任何外部密钥即可跑通。

前置条件

为什么单独讲"封装 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 加一句"为什么失败、接下来该怎么办"的文本。

这五条原则贯穿后面的全部代码。

第二步:准备一个待封装的 REST API

为了让教程无需任何外部密钥就能跑通,我们先用 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 服务器工程

回到 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 },
    };
  }
);

这里有四个必须强调的细节:

第五步:读取工具 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。

第七步:用 Inspector 全链路调试

不要直接接 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 面板里,依次测:

  1. list-alarms 不带参数 → 应返回 5 条,nextCursor 为 null(因为 limit 默认 20 大于总数 5)。
  2. list-alarms 带 severity=critical, limit=1 → 返回 1 条,nextCursor="1";再用 cursor="1" 翻页拿到第二条。
  3. get-alarm 带 id=A-1001 → 返回完整详情;带 id=NOPE → 返回 isError: true 且文本提示去查列表。
  4. acknowledge-alarm 带 id=A-1002, note=已派单 → 返回 acked: true。

在 Inspector 的 "Structured Content" 区域能看到结构化输出被正确识别,说明 outputSchema 配对了。这一步全绿,才进入下一步。

第八步:接入 Claude Code(三个作用域)

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,直接对话:

常见问题与解决方案

工具在 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} 引用本机环境变量,不要明文提交。

v1 → v2 迁移要点(2026-07-28 后)

今天(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 生成调用代码再由人执行。

延伸阅读

训练营适用性评估

参考来源

  1. Model Context Protocol TypeScript SDK — 官方仓库 — registerTool、StdioServerTransport、inputSchema/outputSchema、structuredContent 与 isError 的权威用法,以及 v1.x/v2 包结构说明
  2. MCP Server Guide(v1.x 文档) — 工具注册、结构化输出、错误处理、工具变更通知的官方示例
  3. Build MCP Server Wrapping an API — RapidDev 教程 — REST 端点到工具的映射、分页透传、鉴权转发等封装实践参考
  4. MCP Developer Guide 2026 — LushBinary — 2026 年 MCP 架构、TypeScript/Python 实现与安全实践的综合性开发指南
  5. MCP 官方文档与规范 — Tools/Resources/Prompts 三原语定义、传输协议与 2026-07-28 新规范发布说明

[!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 等场景全覆盖。

51学通信知识星球海报-扫码加入