首页 › tutorials › 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 封装成模型工具。

前置条件

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

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

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

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

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:初始化项目骨架

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

编辑生成的 package.json,关键是加上 "type": "module"(用 ESM)和 bin 入口:

{
  "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

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

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

{
  "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 源码无需编译。

常见问题

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

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

#!/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。

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,非常适合演示。先封装一个统一的请求函数,集中处理错误:

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 而非抛异常,让模型能读到错误并重试或换参数:

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,省去手动转换。

npm run build

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

七、步骤 6:注册到 Claude Code

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

# 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(项目根目录):

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

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

常见问题

八、步骤 7:用 MCP Inspector 调试

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

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、请求封装、四个工具、传输层):

#!/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());

端到端验证流程:

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 封装成模型工具的完整闭环。

十、整体工作流总览

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 服务器,够用但不够稳。推向生产时补这几块:

延伸阅读

参考来源

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

训练营适用性评估

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