首页 › tutorials › 用TS官方SDK从零造MCP工具

概述

本教程带你用 Anthropic 官方的 TypeScript SDK,从空白目录开始,完整造出一个可在 Claude Code 里直接调用的 MCP 服务器。学完你将掌握:MCP 的「宿主—客户端—服务器」架构与 stdio 传输、用 Zod 定义工具入参契约、server.tool() 高层 API、错误回包规范、MCP Inspector 调试闭环,以及 claude mcp add 的三种作用域。适合已写过 Node.js/TypeScript、想给 AI 接入「私有数据或私有动作」的后端与全栈工程师,全程零 API Key、可离线跑通。

前置条件


一、先想清楚:MCP 到底解决了什么

很多人对 MCP 的第一印象是「又一个插件系统」。这个理解只对了一半。真正的痛点是:每个 AI 客户端都在重复造同一套集成——接 GitHub 要写一遍,接数据库要写一遍,换了客户端还得重写。MCP(Model Context Protocol)用一份开放的 JSON-RPC 协议,把「AI 想用的上下文」标准化成了三类原语:

整个生态的角色分工如下:

flowchart LR
    subgraph Host["宿主 Host(如 Claude Code)"]
        Client["MCP Client<br/>每个服务器一个"]
    end
    Server["MCP Server<br/>你的进程"]
    Client <-->|"JSON-RPC 2.0"| Server
    Server --> T["Tools"]
    Server --> R["Resources"]
    Server --> P["Prompts"]
    T --> World[("文件系统 / 数据库 / API")]
    R --> World

关键认知:宿主和服务器是两个独立进程,靠一条「传输通道」通信。本地最常用的是 stdio(标准输入输出)——宿主把请求写到你的进程 stdin,你把响应写到 stdout。远程则用 Streamable HTTP。记住一句口诀:stdout 是协议通道,stderr 是日志通道,两者绝不能混。这条规则后面会救你的命。

二、十分钟搭起项目骨架

新建一个目录,初始化 TypeScript 工程:

mkdir format-forge-mcp && cd format-forge-mcp
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node tsx
npx tsc --init

@modelcontextprotocol/sdk 是官方 SDK。注意截至 2026 年,它的 v2 分支处于 beta,API 还在演进;本教程用的是稳定且广泛兼容的高层 McpServer API,v1/v2 都能跑。

把 tsconfig.json 关键项调好(模块用 NodeNext,否则子路径导入会报错):

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

在 package.json 里补上脚本和 bin 入口:

{
  "type": "module",
  "bin": { "format-forge": "dist/index.js" },
  "scripts": {
    "build": "tsc",
    "dev": "tsx src/index.ts",
    "start": "node dist/index.js"
  }
}

新建 src/index.ts,写一个能启动、会报上自己名字的最小服务器:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";

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

await server.connect(new StdioServerTransport());

跑一下 npm run dev,它会「卡住」不输出——这是正常的,因为它正等着 stdin 上来 JSON-RPC 请求。Ctrl+C 退出即可。能卡住不死,说明骨架通了。

三、定义第一个工具:用 Zod 立契约

MCP 工具的灵魂是入参契约。SDK 用 Zod schema 生成 JSON Schema 暴露给模型,模型据此决定怎么填参数。一个写得好的 schema,能让模型「看一眼就知道该传什么」。

加一个 format_json 工具——校验并美化 JSON:

import { z } from "zod";

server.tool(
  "format_json",                                    // ① 工具名
  "Validate and pretty-print a JSON string. Use when you have minified or pasted JSON that may be malformed.",  // ② 给模型看的描述
  {
    input: z.string().min(1).describe("The raw JSON string to format"),
    indent: z
      .number().int().min(0).max(8)
      .default(2)
      .describe("Number of indentation spaces, 0 for compact"),
  },
  async ({ input, indent }) => {                    // ③ 处理函数
    try {
      const parsed = JSON.parse(input);
      return {
        content: [
          { type: "text", text: JSON.stringify(parsed, null, indent) },
        ],
      };
    } catch (err) {
      return {
        isError: true,
        content: [
          { type: "text", text: `Invalid JSON: ${(err as Error).message}` },
        ],
      };
    }
  }
);

四个要点逐条解释:

  1. 工具名要动词起头、用蛇形。format_json 远比 formatter 或 json 好——后者模型不知道该不该调。
  2. 描述写清楚「何时该用」。模型依据这段文字做工具选择决策,写「Use when…」比写功能列表有效得多。
  3. 每个字段都要 .describe()。Zod 会把这些描述编进 JSON Schema,模型的命中率高度依赖它。
  4. 成功与失败都返回 content 数组。失败时加 isError: true,告诉客户端「这是工具业务错误,不是协议崩溃」。

四、扩展工具:三条设计原则

第一个工具跑通后,照葫芦画瓢再加两个。但加之前先记住三条原则,能省掉后面 80% 的返工。

原则一:宁可拆细,不要造「瑞士军刀」。 一个 convert(input, from, to, options) 看着通用,但模型要在描述里读四种格式 × 两种方向的所有组合,容易填错。拆成 json_to_csv、json_to_yaml 这种动词明确的工具,每个 schema 更窄、命中更准。

原则二:用枚举收口取值空间。 凡是有限选项,就别给 string:

server.tool(
  "json_to_yaml",
  "Convert a JSON object string to YAML.",
  {
    json: z.string().describe("A valid JSON object string"),
    flow: z
      .enum(["block", "flow"])
      .default("block")
      .describe("YAML output style"),
  },
  async ({ json }) => { /* ... */ }
);

原则三:结构化输出比纯文本更可信。 当返回的数据模型还要二次处理时,用 structuredContent 让客户端拿到结构化对象,而不是从一段文本里正则抠:

async ({ json }) => {
  const data = JSON.parse(json);
  const keys = Object.keys(data);
  return {
    content: [{ type: "text", text: `Found ${keys.length} keys` }],
    structuredContent: { keyCount: keys.length, keys }, // 机器友好
  };
}

五、错误处理:让工具「说得清人话」

工具迟早会失败。MCP 给了你三个不同的失败通道,混用会让模型抓狂。记住这张对照表:

失败类型 正确做法 模型看到的效果
参数不合法 什么都不做,让 Zod 拦 客户端直接回 InvalidParams,模型自动重试
工具业务失败(如 JSON 解析错) return { isError: true, content } 模型读到错误文本,决定改参数还是换工具
服务器崩溃 抛异常 / 进程退出 客户端收到错误,但你丢了这次会话的连接

最常见的错误是把业务失败当成异常抛。一抛异常,进程可能崩;即便不崩,模型拿到的也是栈轨迹,毫无信息量。正确写法永远是:在 handler 内 try/catch,然后把错误翻译成一句人话塞进 content:

async ({ json }) => {
  let data: unknown;
  try {
    data = JSON.parse(json);
  } catch (err) {
    return {
      isError: true,
      content: [{
        type: "text",
        text: `无法解析 JSON:${(err as Error).message}。请检查引号、逗号是否成对,再传一次。`,
      }],
    };
  }
  // ...正常路径
}

注意「翻译」这件事——Unexpected token < in JSON 对模型是噪音,「你的输入像是 HTML 而非 JSON」才是可行动的提示。

六、用 MCP Inspector 闭环测试

写完先别急着接 Claude Code。官方的 MCP Inspector 是一个独立的交互式调试客户端,能让你脱离大模型、手动点按测试每个工具:

npx @modelcontextprotocol/inspector npm run dev

它会启动一个本地 Web UI(默认 http://localhost:6274),左侧能看到服务器暴露的所有工具、入参 schema,右侧可以填值、点 Run Tool 看原始 JSON-RPC 响应。

Inspector 的价值在于:它让你看到模型将看到的东西——schema 长什么样、错误怎么回的、structuredContent 对不对。把每个工具都点一遍,确认契约无误,再进下一步。这是把「连不上 / 调不对」的排查成本前置消灭掉的关键一环。

sequenceDiagram
    participant U as 你
    participant I as MCP Inspector (Client)
    participant S as 你的 Server
    U->>I: 填参数,点 Run Tool
    I->>S: tools/call { name, args }
    S->>S: Zod 校验入参
    S-->>I: content / isError
    I-->>U: 渲染结果
    Note over U,S: 全程不消耗大模型 token

七、接入 Claude Code:三种作用域

服务器测好了,正式挂到 Claude Code。核心命令是 claude mcp add,关键是理解三种作用域(scope),它决定了配置写到哪里、谁能共享:

# 1) local(默认):仅当前项目 + 当前机器,写进本地缓存,最私密
claude mcp add format-forge -- node /绝对路径/dist/index.js

# 2) project:写进项目根的 .mcp.json,可随 git 提交,团队共享
claude mcp add format-forge --scope project -- node ./dist/index.js

# 3) user:跨你本机所有项目可用,适合通用工具
claude mcp add format-forge --scope user -- node /绝对路径/dist/index.js

注意 -- 之后才是真正启动你进程的命令。node 后面务必用绝对路径(project scope 下相对路径也可,但需团队成员机器结构一致)。验证连接:

claude mcp list          # 看所有服务器与连接状态
claude mcp get format-forge  # 看单个服务器详情

在交互式 REPL 里输入 /mcp,能看到每个服务器的连接状态、暴露的工具数。如果工具没出现,多半是 schema 编译失败或进程启动即崩。

团队协作时,.mcp.json 的标准长这样(可提交进仓库):

{
  "mcpServers": {
    "format-forge": {
      "command": "node",
      "args": ["./dist/index.js"],
      "env": { "LOG_LEVEL": "info" }
    }
  }
}

队友拉下代码后,Claude Code 首次发现 .mcp.json 会提示授权加载。

八、调试实战:服务「连不上」的五大原因

按概率从高到低,逐个排除:

① stdout 被污染。 你在代码里随手 console.log("hello") 或第三方库往 stdout 打日志,把 JSON-RPC 帧破坏了。铁律:服务器里所有日志一律走 console.error(stderr)或专用 logger,stdout 只准回协议帧。这是「StdioServer 进程连上就挂」的头号原因。

② 启动路径用了相对路径。 Claude Code 在不同 cwd 下启动你的进程,./dist/index.js 可能找不到。固定用绝对路径,或先 npm run build 再指向产物。

③ 没构建就用 ts 源文件。 command: "node" 却指向 .ts 文件,直接报错。要么 command: "npx" + args: ["tsx", "src/index.ts"](开发期),要么先 build 指向 dist。

④ Zod schema 编译失败但没报。 比如用了运行时不存在的校验、或 schema 引用了函数。用 Inspector 先过一遍能抓到。

⑤ 进程读 stdin 是阻塞读、却没正确处理 EOF。 用官方 StdioServerTransport 不会有这问题;只有手写底层 Server + setRequestHandler 时才踩。新手优先用高层 McpServer。

一条万能排查命令——把服务器当普通进程直接喂一帧,看它怎么回:

echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}' | node dist/index.js

能回一帧 initialize 结果,说明协议层没问题;卡住或乱码,就回到上面五条。

九、完整实战:format-forge-mcp 全量代码

把前面拼起来,这是一个三个工具、带日志、可即跑的完整 src/index.ts:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";

const log = (...a: unknown[]) => console.error("[format-forge]", ...a);

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

// 工具 1:美化 JSON
server.tool(
  "format_json",
  "Validate and pretty-print a JSON string. Use on minified or pasted JSON.",
  {
    input: z.string().min(1).describe("Raw JSON string"),
    indent: z.number().int().min(0).max(8).default(2).describe("Indent spaces"),
  },
  async ({ input, indent }) => {
    try {
      const parsed = JSON.parse(input);
      log("formatted json, keys=", typeof parsed === "object" ? Object.keys(parsed as object).length : "scalar");
      return { content: [{ type: "text", text: JSON.stringify(parsed, null, indent) }] };
    } catch (err) {
      return { isError: true, content: [{ type: "text", text: `Invalid JSON: ${(err as Error).message}` }] };
    }
  }
);

// 工具 2:JSON 对象数组 → CSV
server.tool(
  "json_to_csv",
  "Convert a JSON array of flat objects into CSV. All objects should share the same keys.",
  {
    json: z.string().describe('A JSON array string, e.g. [{"a":1,"b":2}]'),
  },
  async ({ json }) => {
    let arr: unknown;
    try {
      arr = JSON.parse(json);
    } catch (err) {
      return { isError: true, content: [{ type: "text", text: `Invalid JSON: ${(err as Error).message}` }] };
    }
    if (!Array.isArray(arr)) {
      return { isError: true, content: [{ type: "text", text: "Input must be a JSON array." }] };
    }
    const rows = arr as Record<string, unknown>[];
    if (rows.length === 0) return { content: [{ type: "text", text: "" }] };

    const headers = Object.keys(rows[0]);
    const esc = (v: unknown) => {
      const s = v == null ? "" : String(v);
      return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
    };
    const csv = [headers.join(","), ...rows.map(r => headers.map(h => esc(r[h])).join(","))].join("\n");
    return {
      content: [{ type: "text", text: csv }],
      structuredContent: { rows: rows.length, columns: headers.length },
    };
  }
);

// 工具 3:点路径取值
server.tool(
  "json_path_get",
  "Read a value from a JSON object using a dot path, e.g. 'user.address.city'. Returns the value or an error if the path does not exist.",
  {
    json: z.string().describe("A JSON object string"),
    path: z.string().describe("Dot-separated path, e.g. 'a.b.0.c'"),
  },
  async ({ json, path }) => {
    let cur: unknown;
    try {
      cur = JSON.parse(json);
    } catch (err) {
      return { isError: true, content: [{ type: "text", text: `Invalid JSON: ${(err as Error).message}` }] };
    }
    for (const seg of path.split(".")) {
      if (cur == null) return { isError: true, content: [{ type: "text", text: `Path '${path}' not found (hit null at '${seg}').` }] };
      cur = (cur as Record<string, unknown>)[seg];
    }
    return {
      content: [{ type: "text", text: cur === undefined ? "(undefined)" : JSON.stringify(cur) }],
      structuredContent: { found: cur !== undefined, value: cur },
    };
  }
);

await server.connect(new StdioServerTransport());
log("format-forge mcp ready");

构建并接入:

npm run build
claude mcp add format-forge --scope user -- node "$(pwd)/dist/index.js"
claude mcp list

然后在 Claude Code 里直接对它说话:「把这段 {"users":[{"id":1,"name":"Ada"},{"id":2,"name":"Alan"}]} 转成 CSV」,模型会自动挑 json_to_csv。整套开发到验证的工作流:

flowchart TD
    A["写 src/index.ts<br/>server.tool(...)"] --> B["tsx 本地启动<br/>确认不崩"]
    B --> C["Inspector 点测<br/>逐个验 schema"]
    C --> D{契约 OK?}
    D -- 否 --> A
    D -- 是 --> E["npm run build"]
    E --> F["claude mcp add<br/>选 scope"]
    F --> G["claude mcp list<br/>看连接"]
    G --> H["REPL /mcp 看工具<br/>自然语言试调"]
    H --> I{调对?}
    I -- 否 --> J["查 stdout 污染<br/>/路径/构建"]
    J --> A
    I -- 是 --> K["✅ 上线"]

延伸阅读

训练营适用性评估

参考来源

  1. MCP TypeScript SDK — GitHub — 官方 SDK 仓库,含 v2 beta 与高层 McpServer API 全量示例
  2. MCP Inspector — Model Context Protocol — 官方交互式调试客户端使用指南
  3. Connect Claude Code to tools via MCP — Claude Code Docs — claude mcp add 三种作用域与 .mcp.json 配置参考
  4. Debugging — Model Context Protocol — 官方调试分层(Inspector / 日志 / 客户端工具)方法论
  5. Add Custom Tools to TypeScript MCP Servers — MCPcat — 第三方实战指南,Zod 校验与错误处理补充示例 —— McpServer 高层 API 与完整示例。
  6. MCP 规范:modelcontextprotocol.io/specification —— 协议细节、传输方式、生命周期。
  7. MCP 示例库:modelcontextprotocol.io/examples —— 官方维护的参考服务器集合。
  8. Microsoft MCP for Beginners:github.com/microsoft/mcp-for-beginners —— 微软出品的入门课程,含 stdio 服务器章节。
  9. Inspector 文档:用 npx @modelcontextprotocol/inspector 探索协议交互细节。

训练营适用性评估

维度 评估
适用训练营类型 全栈开发、AI 工程化、DevOps 工具链、企业内部 AI 提效训练营
学员基础要求 中级——需懂 TypeScript 与命令行
建议教学时长 4–6 小时(含动手实操)
可拆分模块 骨架搭建(1h) → 单工具实现(2h) → 调试接入(1h) → 自定义扩展(1–2h)
课后挑战 学员自选一个外部能力(如 Docker、Jira API、本地日历)仿照本教程造一个 MCP 服务器
评估方式 工具在 Inspector 跑通 + Claude Code 自然语言调用成功返回正确结果

教学价值:本教程把"协议理解 → 骨架 → 工具 → 调试 → 接入 → 验证"完整闭环走通,学员掌握的是方法论而非单一示例,结业即可独立开发任意 MCP 服务器。

参考来源

  1. MCP TypeScript SDK(GitHub) — 官方 TypeScript SDK 主仓库,含 McpServer 高层 API 文档与示例
  2. Build a server — Model Context Protocol — 官方构建 MCP 服务器指南,讲解传输与原语
  3. Transports — MCP Specification 2025-06-18 — 协议规范中 stdio / HTTP 传输机制定义
  4. Microsoft MCP for Beginners — stdio server — 微软入门课程,详解 stdio 服务器原理
  5. Build an MCP Server in Node.js(OneUptime) — Node.js 实战教程,覆盖工具注册与资源开发

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