实操:写一个 MCP Server
用官方 SDK 暴露 2–3 个工具,并在 Host 中调试。
一句话理解:用官方 SDK 写一个 MCP Server,本质就是“注册几个带 Schema 的函数,再选一种传输方式把它们暴露出去”;写好之后,任何支持 MCP 的 Host 都能直接调用。
这是 MCP 模块的实操课,也是实战项目「订单售后诊断 Agent」的核心交付物之一。你会用 MCP 官方 TypeScript SDK 写一个 只读 的订单查询 Server,暴露 3 个工具、1 个资源和 1 个提示模板,先用 MCP Inspector 调试,再接入 Claude Code,让它自己完成一次订单售后诊断。
为了专注于 MCP 本身,本课用内存里的模拟数据代替真实的订单系统。换成真实系统时,只需要把查询函数改成调用内部 API,并加上认证与权限校验。
你将做出什么
Server 的结构如下:
完成后,在 Claude Code 里问“ORD2026090142 为什么还没退款?”,它会自己调用这几个工具,给出“退款被支付渠道退回,需要换一种方式重新发起”的结论。
准备工作
- Node.js 20 或更高版本(SDK 只提供 ES 模块)。
- 已安装 Claude Code(也可以换成 VS Code、Cursor 等其他 MCP Host)。
- 读过 MCP 架构、三类原语 与 本地与远程 MCP。
本课代码基于 MCP TypeScript SDK 的 v2 版本(npm 包 @modelcontextprotocol/server,实现 2026-07-28 版规范,同时兼容旧版客户端),写法对照其官方文档。如果你参考的旧教程里出现 @modelcontextprotocol/sdk、server.tool() 或 new StdioServerTransport(),那是 v1 的写法。
第 1 步:创建项目
mkdir orders-mcp && cd orders-mcp
npm init -y
npm pkg set type=module
npm install @modelcontextprotocol/server zod tsx
mkdir src
type=module 必不可少;tsx 让我们直接运行 TypeScript,省去编译步骤;SDK 用 Zod Schema 描述参数(SDK 基于 Standard Schema,也支持其他兼容的校验库)。
第 2 步:写 Server
新建 src/index.ts,完整代码如下:
import { McpServer } from '@modelcontextprotocol/server';
import { serveStdio } from '@modelcontextprotocol/server/stdio';
import * as z from 'zod/v4';
// ---------- 模拟数据:真实项目中替换为内部 API 调用 ----------
const ORDERS: Record<string, object> = {
ORD2026090142: {
order_id: 'ORD2026090142',
customer_id: 'C-10233',
status: 'refunding',
amount_cny: 486.0,
created_at: '2026-09-08T10:12:00+08:00',
paid_at: '2026-09-08T10:13:20+08:00',
items: [{ sku: 'SKU-7781', name: '无线降噪耳机', qty: 1, price_cny: 486.0 }],
aftersale: { code: 'REFUND_REJECTED', since: '2026-09-15' },
},
ORD2026090277: {
order_id: 'ORD2026090277',
customer_id: 'C-20641',
status: 'pending_payment',
amount_cny: 129.9,
created_at: '2026-09-16T17:50:00+08:00',
paid_at: null,
items: [{ sku: 'SKU-3320', name: '键盘手托', qty: 2, price_cny: 64.95 }],
aftersale: { code: 'PAYMENT_PENDING', since: '2026-09-16' },
},
};
const PAYMENTS: Record<string, object> = {
ORD2026090142: {
order_id: 'ORD2026090142',
payment_id: 'PAY-88321',
method: 'credit_card',
paid_amount_cny: 486.0,
paid_at: '2026-09-08T10:13:20+08:00',
refund_status: 'failed',
refund_amount_cny: 486.0,
refund_fail_reason: 'CARD_EXPIRED',
last_refund_try_at: '2026-09-15T09:30:00+08:00',
},
ORD2026090277: {
order_id: 'ORD2026090277',
payment_id: 'PAY-88410',
method: 'wallet',
paid_amount_cny: 0,
paid_at: null,
refund_status: 'none',
},
};
const TICKETS: Record<string, object[]> = {
'C-10233': [
{ ticket_id: 'TK-5521', order_id: 'ORD2026090142', type: 'refund', status: 'open', created_at: '2026-09-14T11:02:00+08:00', summary: '客户申请全额退款,退货已入库' },
{ ticket_id: 'TK-5102', order_id: 'ORD2026070033', type: 'late_delivery', status: 'closed', created_at: '2026-07-03T14:20:00+08:00', summary: '包裹延迟送达,已补偿优惠券' },
],
'C-20641': [],
};
const AFTERSALE_CODES_MD = `# 售后原因代码手册
| 代码 | 含义 | 建议处置 |
| --- | --- | --- |
| PAYMENT_PENDING | 订单尚未支付成功,不会进入发货流程 | 提醒客户完成支付,或核对支付渠道的回调 |
| REFUND_REJECTED | 退款已发起,但被支付渠道退回 | 查看 get_payment 返回的失败原因,换一种退款方式重新发起 |
| CARD_EXPIRED | 原支付银行卡已失效,退款无法原路退回 | 联系客户确认新的收款方式 |
| SIGN_DISPUTE | 客户对签收结果有异议 | 转人工客服核实 |
`;
// ---------- 工具返回的辅助函数 ----------
const OrderId = z
.string()
.regex(/^ORD\d{10}$/)
.describe('订单号,格式为 ORD 加 10 位数字,例如 ORD2026090142。用户输入含空格或小写时,先整理成这个格式。');
const CustomerId = z
.string()
.regex(/^C-\d{5}$/)
.describe('客户 ID,格式为 C- 加 5 位数字,例如 C-10233。可以从 get_order 的返回中取得。');
const ok = (data: unknown) => ({
content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }],
});
const fail = (message: string) => ({
content: [{ type: 'text' as const, text: message }],
isError: true,
});
const notFound = (id: string) =>
fail(`未找到订单 ${id}。请让用户核对订单号;本系统只保存近 90 天的订单。`);
const customerNotFound = (id: string) =>
fail(`未找到客户 ${id}。请先调用 get_order,使用返回结果里的 customer_id。`);
// ---------- Server 工厂:注册工具、资源与提示模板 ----------
function buildServer(): McpServer {
const server = new McpServer({ name: 'orders', version: '0.1.0' });
server.registerTool(
'get_order',
{
title: '查询订单',
description:
'按订单号查询一个订单的状态、金额、下单与支付时间、商品明细、客户 ID 与售后原因代码。' +
'适用于用户询问订单到哪一步了、为什么还没发货、为什么还没退款。不返回支付流水与历史售后工单,请分别使用 get_payment 与 search_tickets。',
inputSchema: z.object({ order_id: OrderId }),
annotations: { readOnlyHint: true },
},
async ({ order_id }) => (ORDERS[order_id] ? ok(ORDERS[order_id]) : notFound(order_id)),
);
server.registerTool(
'get_payment',
{
title: '查询支付与退款',
description:
'按订单号查询支付流水与退款进展,返回支付方式、实付金额、支付时间、退款状态(none 无退款、processing 处理中、succeeded 已到账、failed 失败)与失败原因代码。' +
'适用于排查退款未到账、支付未成功。原因代码的含义可以查阅资源 orders://sop/aftersale-codes。',
inputSchema: z.object({ order_id: OrderId }),
annotations: { readOnlyHint: true },
},
async ({ order_id }) => {
if (!ORDERS[order_id]) return notFound(order_id);
return ok(PAYMENTS[order_id] ?? { order_id, payment_id: null, refund_status: 'none' });
},
);
server.registerTool(
'search_tickets',
{
title: '查询历史售后工单',
description:
'按客户 ID 查询该客户最近的售后工单,返回工单号、关联订单、类型、状态与摘要。' +
'适用于判断客户是否重复申请、是否已经有人在处理。客户 ID 来自 get_order 的返回;客户存在但没有工单时返回空列表。',
inputSchema: z.object({ customer_id: CustomerId }),
annotations: { readOnlyHint: true },
},
async ({ customer_id }) => {
const tickets = TICKETS[customer_id];
return tickets ? ok({ customer_id, tickets }) : customerNotFound(customer_id);
},
);
server.registerResource(
'aftersale-codes',
'orders://sop/aftersale-codes',
{ title: '售后原因代码手册', description: '订单异常与退款失败原因代码的含义及建议处置', mimeType: 'text/markdown' },
async (uri) => ({ contents: [{ uri: uri.href, mimeType: 'text/markdown', text: AFTERSALE_CODES_MD }] }),
);
server.registerPrompt(
'diagnose-order',
{
title: '订单售后诊断',
description: '按标准流程诊断一个订单的支付、退款与售后问题,输出结论、证据与建议',
argsSchema: z.object({ order_id: z.string().describe('要诊断的订单号') }),
},
({ order_id }) => ({
messages: [
{
role: 'user' as const,
content: {
type: 'text' as const,
text:
`请诊断订单 ${order_id}:\n` +
'1. 调用 get_order 查看状态、客户 ID 与售后原因代码;\n' +
'2. 调用 get_payment,并用第 1 步的 customer_id 调用 search_tickets(这两步可以并行);\n' +
'3. 对照售后原因代码手册解释原因代码;\n' +
'4. 输出:结论(一句话)、证据(引用工具返回的字段)、建议处置。只做诊断,不要执行任何修改。',
},
},
],
}),
);
return server;
}
void serveStdio(buildServer);
console.error('orders MCP server running on stdio'); // 日志只能写 stderr
几个值得留意的设计:
- 每个工具的描述都写了 何时用、何时不用、指向哪个替代工具,这是 工具定义三要素 的做法。
- 订单号与客户 ID 的格式用 Zod 的
regex约束,SDK 会据此生成 JSON Schema,并在调用处理函数 之前 校验参数;不合法的参数会以isError: true的结果返回给模型。 - 业务错误(订单或客户不存在)同样通过
isError: true返回,并告诉模型下一步该做什么。 - 所有工具都标注了
readOnlyHint: true,Host 可以据此优化确认体验。 - 把注册逻辑放进
buildServer工厂函数,是 SDK v2 推荐的写法,之后改成 HTTP 部署也能直接复用。
第 3 步:先跑起来
npx tsx src/index.ts
预期结果:终端输出一行 orders MCP server running on stdio,然后进程保持等待,没有其他输出。这是正常的:stdio Server 在等 Host 通过 stdin 发请求。按 Ctrl+C 退出。
第 4 步:用 MCP Inspector 调试
MCP Inspector 是官方的本地调试工具,它会替你启动 Server 并以 stdio 连接:
npx @modelcontextprotocol/inspector npx tsx src/index.ts
浏览器打开 Inspector 页面后,点击 Connect,依次验证:
| 操作 | 预期结果 |
|---|---|
| Tools 标签页 → 列出工具 | 看到 3 个工具,参数 Schema 中 order_id、customer_id 带有正则与描述 |
调用 get_order,参数 ORD2026090142 |
返回订单 JSON,status 为 refunding,customer_id 为 C-10233 |
调用 get_payment,参数 ORD2026090142 |
refund_status 为 failed,refund_fail_reason 为 CARD_EXPIRED |
调用 search_tickets,参数 C-10233 |
返回 2 张工单,其中 TK-5521 状态为 open |
调用 get_order,参数 ord123 |
返回 isError: true 的输入校验错误,处理函数没有执行 |
调用 get_order,参数 ORD9999999999 |
返回 isError: true 与“未找到订单”的提示 |
| Resources 标签页 → 读取售后原因代码手册 | 返回 Markdown 表格 |
Prompts 标签页 → diagnose-order |
填入订单号后,得到展开的诊断指令消息 |
在 Inspector 里把每个工具都手动调一遍再接 Host,能省去大量排查时间:在 Inspector 里就不对的问题,接到 Host 里只会更难查。
第 5 步:接入 Claude Code
在 项目根目录 下注册 Server,-- 之后是启动命令:
claude mcp add orders -- npx tsx src/index.ts
然后在同一目录启动 Claude Code,输入 /mcp,应该能看到 orders 处于已连接状态,下面列出 3 个工具。
如果想让团队成员克隆仓库后自动获得配置,可以加上 --scope project,配置会写进项目根目录的 .mcp.json;团队成员首次使用时,Claude Code 会先请他们确认是否信任这个 Server。
测试对话:
ORD2026090142 为什么还没退款?
预期行为:Claude Code 调用 get_order 拿到客户 ID 与售后原因代码,再调用 get_payment 与 search_tickets(通常会请你确认是否允许调用这个 MCP 工具),最后给出类似这样的结论:
结论:订单 ORD2026090142 的退款已经发起,但被支付渠道退回(refund_status = failed),
原因代码 CARD_EXPIRED:原支付银行卡已失效,486 元退款在 9 月 15 日重试时仍然失败。
证据:售后工单 TK-5521 显示退货已入库,工单仍处于 open 状态。
建议:联系客户确认新的收款方式,换一种退款方式重新发起。
这次诊断中工具的调用顺序如下:
再试试 ORD2026090277 怎么了? 和 ORD9999999999 到哪一步了?,观察 Agent 如何处理未支付订单与订单不存在的情况。在支持提示模板的 Host 中,还可以尝试以斜杠命令的形式触发 diagnose-order(具体呈现方式因 Host 而异)。
排错提示
| 现象 | 可能原因 | 处理 |
|---|---|---|
/mcp 显示连接失败 |
启动命令本身报错 | 先在项目根目录手动运行 npx tsx src/index.ts,看终端报错 |
| 连接后很快断开 | 代码里有 console.log,污染了 stdout |
所有日志改成 console.error |
ERR_REQUIRE_ESM 或 import 报错 |
缺少 "type": "module" |
执行 npm pkg set type=module |
找不到 @modelcontextprotocol/server |
依赖没装,或 Host 从其他目录启动了命令 | 在项目根目录 npm install;或在注册时使用启动命令与入口文件的绝对路径 |
| 工具列表是空的或是旧的 | Host 缓存了旧的工具列表 | 修改代码后重启 Server(在 /mcp 中重新连接,或重启 Claude Code) |
| 模型不调用你的工具 | 描述与问题不匹配,或工具太多被淹没 | 在问题里点名订单号与“退款”,检查描述里的触发场景 |
| 工具返回被截断 | 返回内容过大 | 精简返回字段;Claude Code 的 MCP 输出上限可通过 MAX_MCP_OUTPUT_TOKENS 调整 |
进阶一:改成 Streamable HTTP
stdio 和 HTTP 两种部署共用同一份注册逻辑,只是出口不同:
同一个 buildServer 工厂可以直接部署成远程 Server。以下写法参考 TypeScript SDK 文档「Serve over HTTP」的 Node.js 示例,需要额外安装 @modelcontextprotocol/node:
npm install @modelcontextprotocol/node
把 src/index.ts 末尾的 serveStdio 两行替换为:
import { createServer } from 'node:http';
import { createMcpHandler } from '@modelcontextprotocol/server';
import { toNodeHandler, localhostHostValidation, localhostOriginValidation } from '@modelcontextprotocol/node';
const handler = createMcpHandler(buildServer); // 每个 HTTP 请求构建一个新实例,天然无状态
const nodeHandler = toNodeHandler(handler);
const validateHost = localhostHostValidation();
const validateOrigin = localhostOriginValidation();
createServer((req, res) => {
if (!validateHost(req, res) || !validateOrigin(req, res)) return; // 防 DNS 重绑定
void nodeHandler(req, res);
}).listen(3000, '127.0.0.1'); // 本地只绑定回环地址
console.error('orders MCP server listening on http://127.0.0.1:3000/mcp');
import 语句按惯例移到文件顶部。启动后注册到 Claude Code:
claude mcp add --transport http orders-http http://127.0.0.1:3000/mcp
注意:这个版本 没有任何认证,只能在本机测试。部署到团队环境前,必须在 handler 前面加上令牌校验,并按 MCP 的认证与安全 设计授权与数据权限。
进阶二:Python 版本
如果团队更熟悉 Python,可以用官方 Python SDK(v2 版本,包名 mcp)。它通过类型标注和文档字符串自动生成 Schema,最小骨架如下:
from mcp.server import MCPServer
mcp = MCPServer("orders")
@mcp.tool()
def get_payment(order_id: str) -> dict:
"""按订单号查询支付流水与退款进展。适用于用户询问这单付款成功了吗、为什么还没退款。"""
return {"order_id": order_id, "refund_status": "failed", "refund_fail_reason": "CARD_EXPIRED"}
if __name__ == "__main__":
mcp.run() # 默认 stdio;远程部署可用 mcp.run(transport="streamable-http")
用 uv run mcp dev server.py 打开 Inspector 调试。参数格式校验、业务错误的返回方式等细节,请以 Python SDK 官方文档为准。
练习
在 60 分钟内完成:
- 跑通第 1–5 步,截图保存 Inspector 与 Claude Code 的调用结果。
- 新增一个工具
get_shipment,返回订单的发货与配送状态变更记录,默认只返回最近 5 条,并提供参数获取更多(练习 并行调用与调用链 里讲的分页与截断提示)。 - 故意把
get_payment的描述改成“查询状态”,在 Claude Code 中重问第 5 步的问题,记录 Agent 的行为变化,再改回来。 - 写一段 200 字的评审意见:如果把模拟数据换成真实订单系统,这个 Server 上线前还缺哪些东西(提示:认证、数据权限、审计日志、限流、超时)。
延伸阅读
- MCP TypeScript SDK:官方 TypeScript SDK,含 Server 与 Client 文档。
- Build your first server(TypeScript SDK):官方入门教程,本课的 stdio 写法与 Inspector 用法以它为准。
- MCP Python SDK:官方 Python SDK 与文档入口。
- Connect Claude Code to tools via MCP:在 Claude Code 中添加、管理与排查 MCP Server。
下一步
MCP 解决的是 Agent 与工具之间的连接。当对方不是一个工具,而是另一个会自主工作的 Agent 时,需要什么?下一课「Agent 间协议概览」介绍 A2A。