本教程面向已理解 MCP 基本概念、想把真实 REST API 接入 Claude Code 的开发者。我们将用官方 @modelcontextprotocol/sdk(TypeScript 高阶 API)+ Zod,从空目录起步,造一个汇率查询 MCP 服务器:接入免费无 key 的 Frankfurter API,定义 4 个带类型校验的工具,本地编译、注册进 Claude Code、用 Inspector 调试验证,最终让 Claude 在对话里直接调用汇率换算。读完你能独立把任意第三方 API 封装成模型工具。
fetch 可用;Node 16 也能跑 SDK,但需自带 fetch polyfill)import/export)为什么选 TypeScript 而非 Python?强类型能在编译期拦下参数拼写错误,Zod schema 同时承担「给模型看的入参描述」和「运行时校验」双重职责,一份定义两处复用。
在动手前先建立正确的心智模型,否则后面每一步都会觉得"玄学"。
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
由此推出三条铁律,违反任何一条都会让服务器"假死":
console.log 打日志。任何调试信息必须走 console.error(stderr),客户端会忽略 stderr。server.connect() 内部已挂好监听,别在末尾加 process.exit()。{ content, isError: true },让模型看到错误信息后能自我纠错。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"
}
}
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 源码无需编译。
常见问题
Cannot find module ... mcp.js:没设 NodeNext,或 import 时漏了 .js 后缀。SDK 的子路径入口是 @modelcontextprotocol/sdk/server/mcp.js,源码里也必须这么写。tsx 与 tsc 行为不一致:tsx 对后缀更宽容,但生产用 tsc 编译,所以一律按 NodeNext 严格写。先造一个能跑通"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。看到乱码般的初始化握手是正常的 —— 那是协议在自报家门。
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 服务器的标配 —— 配置外置,不硬编码。
现在把 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
编译无错即通过。常见问题
z.string().length(3) 拦不住小写 usd? Zod 默认大小写敏感。若想宽松,加 .transform(s => s.toUpperCase()) 归一化。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 个工具。
常见问题
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。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 服务器,够用但不够稳。推向生产时补这几块:
AbortController 给 fetch 设 5 秒超时,避免 API 卡死拖垮整个客户端会话。/latest 结果做内存缓存(TTL 几小时),既省请求又加速。package.json 的 bin 字段,发布到 npm 后用户 npx currency-mcp 即可启动,无需克隆源码。StreamableHTTPServerTransport),配合鉴权。https://ts.sdk.modelcontextprotocol.io/https://github.com/modelcontextprotocol/typescript-sdkclaude mcp 命令与 .mcp.json 配置说明:在 Claude Code 内输入 /mcp 查看实时状态,或查官方 MCP 文档页https://zod.devhttps://modelcontextprotocol.io/moduleResolution: NodeNext、stderr 日志等生产实践要点McpServer 高阶 API、StdioServerTransport、Quick Start 示例[!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 等场景全覆盖。
