<!-- title: 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 助手的前端/全栈开发者。

## 前置条件

- **Node.js 20 或更高**（运行 `node -v` 确认）
- 会读 TypeScript 基础语法（泛型、async/await 即可）
- 已安装 Claude Code（用于最后接入）
- 无需任何 API 密钥——实战用免费的 [JSONPlaceholder](https://jsonplaceholder.typicode.com) 演示 API

## 背景：为什么这次用 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 步：初始化项目

```bash
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 框架搭起来：

```ts
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 的工具。先包一个"列出待办"：

```ts
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`：

```ts
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` 不改变执行逻辑，只给宿主行为提示。给"创建待办"打上破坏性标记：

```ts
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 调试（不接宿主先验证）

```bash
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

在项目根目录跑一条命令（`--` 后面是启动命令）：

```bash
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` 如下（可直接复制运行）：

```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 条用户"能自动串联两次工具调用。

## 工作流程图

下面是一次工具调用的完整往返（六步），理解它对调试至关重要：

```mermaid
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: 用自然语言汇总答案
```

工具注册到执行的内部流程：

```mermaid
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]
```

## 延伸阅读

- **Streamable HTTP 传输**：stdio 适合本地进程，要让一个端点服务多客户端，把同一个 `createServer` 工厂改用 HTTP 中间件包（`@modelcontextprotocol/express` / `hono` / `node`）。
- **资源（resources）与提示词（prompts）**：工具是"动作"，资源是"数据源"，提示词是"模板"。三者是 MCP 的三大原语。
- **v1 → v2 迁移**：官方提供 codemod，把 `tool()` 自动改写成 `registerTool()`，详见仓库 `docs/migration/upgrade-to-v2.md`。
- **生产化建议**：v2 稳定前用 v1.x 上生产；加 npm `bin` 字段把服务器发成 CLI；用 `@modelcontextprotocol/inspector` 做冒烟测试。
- **进阶**：OAuth 助手包（保护 HTTP 端点）、Streamable HTTP 的会话管理、工具的取消与进度通知。

## 训练营适用性评估

| 维度 | 评估 |
|------|------|
| 适合人群 | 前端/全栈/平台工程师，会 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（官方仓库）](https://github.com/modelcontextprotocol/typescript-sdk) — v2 包结构、Standard Schema、2026-07-28 规范说明
2. [Build your first server（官方教程）](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/docs/get-started/first-server.md) — registerTool、serveStdio、Inspector 的官方步骤
3. [Tools 文档](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/docs/servers/tools.md) — 结构化输出、outputSchema、annotations 的完整说明
4. [Plug into a real host](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/docs/get-started/real-host.md) — 接入 Claude Code / VS Code / Cursor 的配置
5. [Building Your Own MCP Server with TypeScript（第三方实战）](https://jangwook.net/en/blog/en/mcp-server-typescript-sdk-step-by-step-2026/) — 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学通信知识星球海报-扫码加入](https://wiki.500nian.com/images/zhixingqiu_qrcode.png)
