<!-- title: TS SDK造MCP：REST API秒变模型工具 -->

# 用 TypeScript SDK 从零造 MCP 服务器：把免费 REST API 封装成模型可调工具

## 概述

本教程面向已理解 MCP 基本概念、想把**真实 REST API 接入 Claude Code** 的开发者。我们将用官方 `@modelcontextprotocol/sdk`（TypeScript 高阶 API）+ Zod，从空目录起步，造一个汇率查询 MCP 服务器：接入免费无 key 的 Frankfurter API，定义 4 个带类型校验的工具，本地编译、注册进 Claude Code、用 Inspector 调试验证，最终让 Claude 在对话里直接调用汇率换算。读完你能独立把任意第三方 API 封装成模型工具。

## 前置条件

- **Node.js ≥ 18**（全局 `fetch` 可用；Node 16 也能跑 SDK，但需自带 fetch polyfill）
- 会写基本 TypeScript，理解 ESM（`import/export`）
- 已安装 Claude Code CLI，并能跑通一次对话
- 一台能访问公网 HTTPS 的机器（Frankfurter API 无需鉴权）

> 为什么选 TypeScript 而非 Python？强类型能在编译期拦下参数拼写错误，Zod schema 同时承担「给模型看的入参描述」和「运行时校验」双重职责，一份定义两处复用。

## 一、心智模型：MCP 服务器到底是什么

在动手前先建立正确的心智模型，否则后面每一步都会觉得"玄学"。

MCP 服务器本质上是一个**通过标准输入/输出（stdio）收发 JSON-RPC 消息的常驻进程**。Claude Code 作为客户端，spawn 出你的 Node 进程，往它的 stdin 写请求（比如"请调用 convert 工具，参数 amount=100 from=USD to=CNY"），你的进程处理后往 stdout 写响应。这条 stdin/stdout 通道就是协议生命线。

```mermaid
flowchart LR
    U[用户提问<br/>100美元换算人民币] --> CC[Claude Code<br/>客户端]
    CC -->|1. JSON-RPC over stdio| SRV[MCP Server<br/>Node 进程]
    SRV -->|2. HTTPS| API[Frankfurter API<br/>免费无 key]
    API -->|3. 汇率 JSON| SRV
    SRV -->|4. 工具结果| CC
    CC --> U
```

由此推出三条铁律，违反任何一条都会让服务器"假死"：

1. **stdout 是神圣的协议通道** —— 绝不能用 `console.log` 打日志。任何调试信息必须走 `console.error`（stderr），客户端会忽略 stderr。
2. **进程不能主动退出** —— 服务器要一直挂着监听 stdin，直到客户端关闭管道。`server.connect()` 内部已挂好监听，别在末尾加 `process.exit()`。
3. **错误要结构化返回** —— 抛异常客户端会收到错误，但更好的做法是返回 `{ content, isError: true }`，让模型看到错误信息后能自我纠错。

## 二、步骤 1：初始化项目骨架

```bash
mkdir currency-mcp && cd currency-mcp
npm init -y
```

编辑生成的 `package.json`，关键是加上 `"type": "module"`（用 ESM）和 `bin` 入口：

```json
{
  "name": "currency-mcp",
  "version": "1.0.0",
  "type": "module",
  "bin": { "currency-mcp": "dist/index.js" },
  "scripts": {
    "build": "tsc",
    "dev": "tsx src/index.ts",
    "start": "node dist/index.js",
    "inspect": "npx @modelcontextprotocol/inspector node dist/index.js"
  }
}
```

## 三、步骤 2：安装依赖与配置 tsconfig

```bash
npm install @modelcontextprotocol/sdk zod
npm install -D typescript tsx @types/node
npx tsc --init
```

把 `tsconfig.json` 改成下面这样。**`moduleResolution: NodeNext` 是最关键的设置** —— 它强制 ESM 语义，要求 import 路径必须带 `.js` 后缀：

```json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "dist",
    "rootDir": "src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*"]
}
```

**预期结果**：`npm run build` 能产出 `dist/index.js`，`npm run dev` 能用 tsx 直接跑 TS 源码无需编译。

**常见问题**

- *报错 `Cannot find module ... mcp.js`*：没设 NodeNext，或 import 时漏了 `.js` 后缀。SDK 的子路径入口是 `@modelcontextprotocol/sdk/server/mcp.js`，源码里也必须这么写。
- *`tsx` 与 `tsc` 行为不一致*：tsx 对后缀更宽容，但生产用 tsc 编译，所以一律按 NodeNext 严格写。

## 四、步骤 3：写出最小可运行服务器

先造一个能跑通"hello"的服务器，验证整条链路，再接 API。创建 `src/index.ts`：

```typescript
#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";

const server = new McpServer({
  name: "currency-mcp",
  version: "1.0.0",
});

// 高阶 API：server.tool(名字, 描述, Zod schema, 处理函数)
server.tool(
  "ping",
  "健康检查，原样回显输入文本",
  { text: z.string().describe("要回显的内容") },
  async ({ text }) => {
    return { content: [{ type: "text" as const, text: `pong: ${text}` }] };
  }
);

const transport = new StdioServerTransport();
await server.connect(transport);
```

`McpServer` 是 SDK 的高阶封装，帮你处理 JSON-RPC 握手、能力协商、工具分发。`server.tool()` 一行注册一个工具：第一参是工具名（模型用它决定调谁），第二参是**给模型看的自然语言描述**（写得好模型才会在正确场景触发），第三参是 Zod schema（既生成 JSON Schema 喂给模型，又在运行时校验入参），第四参是异步处理函数。

处理函数返回值的 `content` 是数组，每项可以是 `text`/`image`/`resource`。`type: "text" as const` 是为了让 TS 推断出字面量类型而非 `string`。

```bash
npm run build
echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | node dist/index.js
```

**预期结果**：终端打印一段 JSON-RPC 响应，`result.tools` 里能看到 `ping`。看到乱码般的初始化握手是正常的 —— 那是协议在自报家门。

## 五、步骤 4：接入真实 REST API（Frankfurter）

Frankfurter（`https://api.frankfurter.app`）是开源免费、欧洲央行数据源、**无需 API key** 的汇率 API，非常适合演示。先封装一个统一的请求函数，集中处理错误：

```typescript
const API_BASE = process.env.API_BASE ?? "https://api.frankfurter.app";

async function fetchJson<T>(path: string): Promise<T> {
  const res = await fetch(`${API_BASE}${path}`);
  if (!res.ok) {
    throw new Error(`API ${res.status}: ${await res.text()}`);
  }
  return res.json() as Promise<T>;
}
```

把 `API_BASE` 做成环境变量，方便将来切到自建代理或测试桩。这是生产级 MCP 服务器的标配 —— 配置外置，不硬编码。

## 六、步骤 5：定义四个工具 + 结构化错误处理

现在把 `ping` 替换成真实工具。每个工具都套 `try/catch`，失败时返回 `isError: true` 而非抛异常，让模型能读到错误并重试或换参数：

```typescript
server.tool(
  "list-currencies",
  "列出 Frankfurter 支持的所有货币代码及中文名（如 USD 美元、CNY 人民币）",
  {},
  async () => {
    try {
      const data = await fetchJson<Record<string, string>>(`/currencies`);
      const lines = Object.entries(data).map(([code, name]) => `- ${code}: ${name}`);
      return { content: [{ type: "text" as const, text: lines.join("\n") }] };
    } catch (e) {
      return { content: [{ type: "text" as const, text: `错误：${(e as Error).message}` }], isError: true };
    }
  }
);

server.tool(
  "latest-rates",
  "获取某基准货币对目标货币的最新汇率",
  {
    base: z.string().length(3).describe("基准货币代码，如 USD"),
    target: z.string().length(3).describe("目标货币代码，如 CNY"),
  },
  async ({ base, target }) => {
    try {
      const data = await fetchJson<{ rates: Record<string, number>; date: string }>(
        `/latest?from=${base}&to=${target}`
      );
      const rate = data.rates[target];
      return {
        content: [{ type: "text" as const, text: `1 ${base} = ${rate} ${target}（更新于 ${data.date}）` }],
      };
    } catch (e) {
      return { content: [{ type: "text" as const, text: `错误：${(e as Error).message}` }], isError: true };
    }
  }
);

server.tool(
  "convert",
  "把指定金额从源货币换算为目标货币",
  {
    amount: z.number().positive().describe("换算金额，必须为正数"),
    from: z.string().length(3).describe("源货币代码"),
    to: z.string().length(3).describe("目标货币代码"),
  },
  async ({ amount, from, to }) => {
    try {
      const data = await fetchJson<{ rates: Record<string, number>; date: string }>(
        `/latest?from=${from}&to=${to}`
      );
      const rate = data.rates[to];
      const result = (amount * rate).toFixed(2);
      return {
        content: [{
          type: "text" as const,
          text: `${amount} ${from} ≈ ${result} ${to}（汇率 ${rate}，${data.date}）`,
        }],
      };
    } catch (e) {
      return { content: [{ type: "text" as const, text: `错误：${(e as Error).message}` }], isError: true };
    }
  }
);

server.tool(
  "historical-rates",
  "查询指定历史日期的汇率",
  {
    date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe("日期 YYYY-MM-DD"),
    base: z.string().length(3).describe("基准货币代码"),
    target: z.string().length(3).describe("目标货币代码"),
  },
  async ({ date, base, target }) => {
    try {
      const data = await fetchJson<{ rates: Record<string, number>; date: string }>(
        `/${date}?from=${base}&to=${target}`
      );
      return {
        content: [{ type: "text" as const, text: `${data.date}: 1 ${base} = ${data.rates[target]} ${target}` }],
      };
    } catch (e) {
      return { content: [{ type: "text" as const, text: `错误：${(e as Error).message}` }], isError: true };
    }
  }
);
```

注意几个设计细节：Zod 的 `.describe()` 文本会变成模型看到的参数说明，写得越精准，模型填参越准（比如 `必须为正数`、`YYYY-MM-DD`）；`.length(3)` 和正则在运行时拦截 `US` 这种错误代码；金额用 `z.number()` 而非 string，省去手动转换。

```bash
npm run build
```

编译无错即通过。**常见问题**

- *`z.string().length(3)` 拦不住小写 `usd`？* Zod 默认大小写敏感。若想宽松，加 `.transform(s => s.toUpperCase())` 归一化。
- *模型从不触发某工具？* 99% 是工具描述写得太模糊。描述要说清"做什么、何时该用、返回什么"，像写给新同事的操作手册。

## 七、步骤 6：注册到 Claude Code

Claude Code 通过 `claude mcp add` 注册服务器。**最坑的一点：必须用编译产物的绝对路径**，相对路径会让 Claude 在别的 cwd 找不到文件。

```bash
# user 作用域：对你账号下所有项目生效
claude mcp add currency-mcp -s user -- node /绝对路径/currency-mcp/dist/index.js

# 传环境变量（如自定义 API base）
claude mcp add currency-mcp -s user -e API_BASE=https://api.frankfurter.app -- node /绝对路径/dist/index.js

# 验证
claude mcp list
claude mcp get currency-mcp
```

`--` 之后是启动命令本身。Claude 会以 `node <路径>` spawn 进程。三种作用域：`local`（仅当前项目，默认）、`project`（写入 `.mcp.json`，随仓库共享给团队）、`user`（你的全局）。

**团队共享用 `.mcp.json`**（项目根目录）：

```json
{
  "mcpServers": {
    "currency-mcp": {
      "command": "node",
      "args": ["${workspaceFolder}/dist/index.js"]
    }
  }
}
```

注册后在 Claude Code 里运行 `/mcp`，应看到 `currency-mcp` 状态为 `connected`，并列出 4 个工具。

**常见问题**

- *状态显示 `failed` 或一直 `connecting`*：八成是路径不对，或 `console.log` 污染了 stdout。先单独 `node dist/index.js` 跑一下，确认无启动报错。
- *`/mcp` 里工具数量不对*：改完代码忘了 `npm run build`。开发期可用 `tsx` 直接跑：`claude mcp add currency-mcp -s user -- npx tsx /路径/src/index.ts`。

## 八、步骤 7：用 MCP Inspector 调试

Inspector 是官方的可视化调试器，能在不启动 Claude 的情况下逐个调用工具、看原始 JSON-RPC 报文，是排查问题的利器：

```bash
npm run inspect
# 等价于 npx @modelcontextprotocol/inspector node dist/index.js
```

它会打开一个本地 Web 界面。在左侧 Tools 列表点 `convert`，右侧填入 `amount: 100`、`from: USD`、`to: CNY`，点 Run，下方应返回 `100 USD ≈ xxx CNY`。切到 "Notifications" / 原始报文页能看到完整的 JSON-RPC 往来，定位"模型到底收到了什么 schema"特别有用。

## 九、实战案例：完整汇率服务器

把前面所有片段拼起来，完整可运行的项目结构如下：

```
currency-mcp/
├── package.json
├── tsconfig.json
└── src/
    └── index.ts
```

`src/index.ts` 完整版（合并 shebang、请求封装、四个工具、传输层）：

```typescript
#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";

const API_BASE = process.env.API_BASE ?? "https://api.frankfurter.app";

async function fetchJson<T>(path: string): Promise<T> {
  const res = await fetch(`${API_BASE}${path}`);
  if (!res.ok) throw new Error(`API ${res.status}: ${await res.text()}`);
  return res.json() as Promise<T>;
}

const server = new McpServer({ name: "currency-mcp", version: "1.0.0" });

const err = (m: string) => ({ content: [{ type: "text" as const, text: `错误：${m}` }], isError: true });

server.tool("list-currencies", "列出 Frankfurter 支持的所有货币代码及名称", {}, async () => {
  try {
    const data = await fetchJson<Record<string, string>>(`/currencies`);
    const lines = Object.entries(data).map(([c, n]) => `- ${c}: ${n}`);
    return { content: [{ type: "text" as const, text: lines.join("\n") }] };
  } catch (e) { return err((e as Error).message); }
});

server.tool("latest-rates", "获取基准货币对目标货币的最新汇率",
  { base: z.string().length(3), target: z.string().length(3) },
  async ({ base, target }) => {
    try {
      const d = await fetchJson<{ rates: Record<string, number>; date: string }>(`/latest?from=${base}&to=${target}`);
      return { content: [{ type: "text" as const, text: `1 ${base} = ${d.rates[target]} ${target}（${d.date}）` }] };
    } catch (e) { return err((e as Error).message); }
  });

server.tool("convert", "把指定金额从源货币换算为目标货币",
  { amount: z.number().positive(), from: z.string().length(3), to: z.string().length(3) },
  async ({ amount, from, to }) => {
    try {
      const d = await fetchJson<{ rates: Record<string, number>; date: string }>(`/latest?from=${from}&to=${to}`);
      const r = d.rates[to];
      return { content: [{ type: "text" as const, text: `${amount} ${from} ≈ ${(amount * r).toFixed(2)} ${to}（汇率 ${r}，${d.date}）` }] };
    } catch (e) { return err((e as Error).message); }
  });

server.tool("historical-rates", "查询指定历史日期的汇率",
  { date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), base: z.string().length(3), target: z.string().length(3) },
  async ({ date, base, target }) => {
    try {
      const d = await fetchJson<{ rates: Record<string, number>; date: string }>(`/${date}?from=${base}&to=${target}`);
      return { content: [{ type: "text" as const, text: `${d.date}: 1 ${base} = ${d.rates[target]} ${target}` }] };
    } catch (e) { return err((e as Error).message); }
  });

await server.connect(new StdioServerTransport());
```

端到端验证流程：

```bash
npm install && npm run build              # 1. 编译
npm run inspect                            # 2. Inspector 里手动调用 convert，确认返回
claude mcp add currency-mcp -s user -- node "$(pwd)/dist/index.js"   # 3. 注册
claude                                     # 4. 进对话
> /mcp                                     # 5. 看到 connected + 4 工具
> 帮我把 250 欧元换算成日元，并告诉我上周今天的汇率
```

最后一句自然语言提问，Claude 会自动判断该调 `convert`（欧元→日元）和 `historical-rates`（上周今天），分别填参、调用、汇总回答。这就是把 REST API 封装成模型工具的完整闭环。

## 十、整体工作流总览

```text
flowchart TD
    A[npm init + 装依赖<br/>SDK + zod] --> B[tsconfig 设 NodeNext]
    B --> C[写 server.tool<br/>Zod schema + handler]
    C --> D[npm run build<br/>tsc 编译到 dist]
    D --> E{Inspector 手动调用<br/>逐个工具验证}
    E -->|失败| F[查报错: 路径? stdout 污染? schema?]
    F --> C
    E -->|成功| G[claude mcp add<br/>绝对路径注册]
    G --> H[/mcp 确认 connected]
    H --> I[对话里自然语言触发<br/>模型自动选工具填参]
```

## 十一、进阶：从能跑到生产级

本案例是单文件 stdio 服务器，够用但不够稳。推向生产时补这几块：

- **请求超时与重试**：用 `AbortController` 给 `fetch` 设 5 秒超时，避免 API 卡死拖垮整个客户端会话。
- **限流与缓存**：汇率一天更新一次，对 `/latest` 结果做内存缓存（TTL 几小时），既省请求又加速。
- **打包成可执行 bin**：靠 `package.json` 的 `bin` 字段，发布到 npm 后用户 `npx currency-mcp` 即可启动，无需克隆源码。
- **切换传输层**：stdio 适合本地；要远程共享就改用 Streamable HTTP 传输（SDK 提供 `StreamableHTTPServerTransport`），配合鉴权。
- **可观测性**：把请求耗时、错误率打到 stderr 或接 OpenTelemetry，定位"模型为什么没触发我的工具"。

## 延伸阅读

- MCP TypeScript SDK 官方文档与示例：`https://ts.sdk.modelcontextprotocol.io/`
- 官方 SDK 仓库（含完整 server/client 示例）：`https://github.com/modelcontextprotocol/typescript-sdk`
- Claude Code 接入 MCP 的 `claude mcp` 命令与 `.mcp.json` 配置说明：在 Claude Code 内输入 `/mcp` 查看实时状态，或查官方 `MCP` 文档页
- Zod 文档（schema 进阶用法：联合类型、refine、transform）：`https://zod.dev`
- MCP Inspector 与协议规范：`https://modelcontextprotocol.io/`

## 参考来源

1. [Build MCP Server with TypeScript: Complete Tutorial (2026)](https://mcpize.com/blog/mcp-server-typescript) — 强调 Zod 类型校验、`moduleResolution: NodeNext`、stderr 日志等生产实践要点
2. [MCP TypeScript SDK 官方文档](https://ts.sdk.modelcontextprotocol.io/) — `McpServer` 高阶 API、`StdioServerTransport`、Quick Start 示例
3. [modelcontextprotocol/typescript-sdk (GitHub)](https://github.com/modelcontextprotocol/typescript-sdk) — 官方 SDK 源码与可运行 server/client 示例
4. [Build an MCP Server with TypeScript: 2026 Tutorial (DEV Community)](https://dev.to/jangwook_kim_e31e7291ad98/build-an-mcp-server-with-typescript-2026-tutorial-1ipk) — MCP 生态规模（月下载 9700 万、1 万+公开服务器）与从零搭建步骤
5. [Add Custom Tools to TypeScript MCP Servers (MCPcat)](https://mcpcat.io/guides/adding-custom-tools-mcp-server-typescript/) — Zod 校验、错误处理、工具注册细节

## 训练营适用性评估

- **适合类型**：MCP 生态开发训练营、AI 工程实战营、TypeScript 后端进阶营；不建议零基础营（需 ESM + TS 前置）。
- **教学时长**：约 3 小时。其中项目搭建与 tsconfig 踩坑 30 分钟、四个工具编写与 Zod 1 小时、Inspector 调试与 Claude 注册 45 分钟、端到端联调与答疑 45 分钟。
- **动手配比**：讲解 30% / 编码 50% / 调试 20%，强动手导向。
- **可扩展作业**：让学员选一个自己喜欢的免费 API（天气、地震、星座、笑话）照搬本模式封装 3 个工具并注册到自己的 Claude Code，作为结业作品。
- **风险点**：网络访问 Frankfurter API 在部分内网受限，讲师需提前准备本地 mock 服务兜底；Node 版本低于 18 的学员需先升级。

> [!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)
