07.07实操更新于 2026年9月17日

实操:写一个 MCP Server

用官方 SDK 暴露 2–3 个工具,并在 Host 中调试。

07.07 · 课时讲解

实操:写一个 MCP Server

注册几个带 Schema 的函数,再选一种传输方式把它们暴露出去。

1 / 12

一句话理解:用官方 SDK 写一个 MCP Server,本质就是“注册几个带 Schema 的函数,再选一种传输方式把它们暴露出去”;写好之后,任何支持 MCP 的 Host 都能直接调用。

这是 MCP 模块的实操课,也是实战项目「订单售后诊断 Agent」的核心交付物之一。你会用 MCP 官方 TypeScript SDK 写一个 只读 的订单查询 Server,暴露 3 个工具、1 个资源和 1 个提示模板,先用 MCP Inspector 调试,再接入 Claude Code,让它自己完成一次订单售后诊断。

为了专注于 MCP 本身,本课用内存里的模拟数据代替真实的订单系统。换成真实系统时,只需要把查询函数改成调用内部 API,并加上认证与权限校验。

你将做出什么

Server 的结构如下:

orders MCP Server 的结构:Claude Code 通过 stdio 启动 Server,Server 暴露 3 个工具、1 个资源和 1 个提示模板 Claude Code MCP Host stdio 启动子进程 npx tsx src/index.ts orders MCP Server · src/index.ts 工具 Tools:模型调用 get_order 订单状态、金额、客户 ID get_payment 支付流水与退款进展 search_tickets 客户的历史售后工单 资源 Resources:应用读取 售后原因代码手册 orders://sop/… 提示 Prompts:用户选择 diagnose-order 标准诊断流程
图 1 · 本课要做的 Server:只读、本地运行,三类原语各有一个示例

完成后,在 Claude Code 里问“ORD2026090142 为什么还没退款?”,它会自己调用这几个工具,给出“退款被支付渠道退回,需要换一种方式重新发起”的结论。

准备工作

本课代码基于 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 状态。
建议:联系客户确认新的收款方式,换一种退款方式重新发起。

这次诊断中工具的调用顺序如下:

Claude Code 诊断订单的预期调用链:先 get_order,再并行 get_payment 与 search_tickets,最后给出结论 用户提问 get_order 客户 ID · 原因代码 get_payment 退款失败:卡已失效 search_tickets TK-5521 仍为 open 结论 换方式重新退款 调用 MCP 工具前,Claude Code 通常会请你确认是否允许
图 2 · 预期的调用链:先拿客户 ID,再并行查支付与工单,最后给结论

再试试 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 工厂既可以通过 serveStdio 本地运行,也可以通过 createMcpHandler 部署成 HTTP 端点 buildServer() 注册工具、资源与提示 serveStdio stdin / stdout Claude Code 启动子进程连接 createMcpHandler 每个请求新建实例 HTTP 端点 127.0.0.1:3000/mcp HTTP 版本没有任何认证,只能在本机测试
图 3 · 一份注册逻辑,两种出口: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. 跑通第 1–5 步,截图保存 Inspector 与 Claude Code 的调用结果。
  2. 新增一个工具 get_shipment,返回订单的发货与配送状态变更记录,默认只返回最近 5 条,并提供参数获取更多(练习 并行调用与调用链 里讲的分页与截断提示)。
  3. 故意把 get_payment 的描述改成“查询状态”,在 Claude Code 中重问第 5 步的问题,记录 Agent 的行为变化,再改回来。
  4. 写一段 200 字的评审意见:如果把模拟数据换成真实订单系统,这个 Server 上线前还缺哪些东西(提示:认证、数据权限、审计日志、限流、超时)。

延伸阅读

下一步

MCP 解决的是 Agent 与工具之间的连接。当对方不是一个工具,而是另一个会自主工作的 Agent 时,需要什么?下一课「Agent 间协议概览」介绍 A2A。

试试:上下文、MCP、沙箱、Prompt 注入、外链