首页 › tutorials › TS造MCP:把REST API变工具

概述

本教程带你用 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 助手的前端/全栈开发者。

前置条件

背景:为什么这次用 TypeScript SDK v2

很多 MCP 教程用的是 Python 的 FastMCP,但 2026 年 TypeScript SDK 已进入 v2 beta,并随 2026-07-28 规范发布。v2 有三个关键变化你必须知道,否则照搬旧博客会直接报错:

  1. 包被拆分了:旧的 @modelcontextprotocol/sdk 不再是推荐入口。现在拆成 @modelcontextprotocol/server(造服务端)和 @modelcontextprotocol/client(造客户端)。
  2. API 改名了:旧的 server.tool(...) 被替换为 server.registerTool(name, config, handler)。
  3. Schema 标准化:工具入参/出参用 Standard Schema,可接 Zod v4、Valibot、ArkType 任一库——本教程用 Zod v4。

说明:v2 仍处 beta,生产环境官方仍建议用 v1.x(会持续收到 bug 修复至少 6 个月)。本教程面向学习与原型,用的是 v2 主线 API。

第 1 步:初始化项目

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" 即可。

第 2 步:注册第一个工具(理解 registerTool 三段式)

新建 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,然后阻塞等待客户端。

第 3 步:把 REST API 包成工具(fetch + 错误处理)

把 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。

第 4 步:结构化输出(让模型拿到机器可读数据)

文本 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 里供客户端校验。文本与结构化数据同时返回,是推荐做法。

第 5 步:工具注解(告诉宿主哪些操作危险)

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 自动放行查询、对写入操作弹出确认。

第 6 步:用 MCP Inspector 调试(不接宿主先验证)

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() 是否合理

第 7 步:接入 Claude Code

在项目根目录跑一条命令(-- 后面是启动命令):

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"] } } }

实战案例:todos-mcp 完整项目

把上面四步合并,完整 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]

延伸阅读

训练营适用性评估

维度 评估
适合人群 前端/全栈/平台工程师,会 TS,想把内部系统接入 AI
训练营类型 企业内训(API 智能化)、开发者社区 meetup、AI 工程训练营的"集成"模块
预计教学时长 3–4 小时(讲解 1h + 跟练 2h + 改造自家 API 1h)
难度 中等(核心是 registerTool 心智模型 + Zod)
可拓展作业 学员把公司一个真实 REST API 包成 3 个工具,接入 Claude Code 演示

把这套"REST API → MCP 工具"的模式推广到团队,意味着任何已有的后端接口都能零改造地变成 Claude Code 的能力——这是企业落地 AI 编程助手性价比最高的一步。

参考来源

  1. MCP TypeScript SDK(官方仓库) — v2 包结构、Standard Schema、2026-07-28 规范说明
  2. Build your first server(官方教程) — registerTool、serveStdio、Inspector 的官方步骤
  3. Tools 文档 — 结构化输出、outputSchema、annotations 的完整说明
  4. Plug into a real host — 接入 Claude Code / VS Code / Cursor 的配置
  5. Building Your Own MCP Server with TypeScript(第三方实战) — 2026 年 TypeScript MCP 开发实战参考

[!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学通信知识星球海报-扫码加入