<!-- title: 用 TypeScript SDK 造 MCP 工具集 -->

# 用 TypeScript SDK 造 MCP 工具集

## 概述

Model Context Protocol（MCP）的 TypeScript SDK 已经累计 **9700 万次月下载**、超过 1 万个公开服务器实现。本教程面向已熟悉 TypeScript、想给 Claude Code / Cursor 等客户端开发自定义工具集的工程师，带你用官方 `@modelcontextprotocol/server` 从零搭建一个多工具服务器，掌握工具注解、结构化输出、错误处理与本地接入的完整闭环。

## 前置条件

- Node.js 20+（推荐 22 LTS）
- 基础 TypeScript 与 npm 知识
- 已安装 Claude Code CLI（用于接入与验证）

## 一、心智模型：MCP 服务器只需三步

TypeScript SDK 把构建服务器的过程压缩为三步，理解这三步就理解了 90% 的工作：

1. **创建** `McpServer` 实例，写明名称与版本；
2. **注册** 你的 tools / resources / prompts；
3. **连接** 一个 transport（stdio 本地，或 Streamable HTTP 远程）。

```mermaid
flowchart LR
    A[McpServer<br/>name+version] --> B[注册工具<br/>registerTool]
    B --> C[选择 transport<br/>stdio / HTTP]
    C --> D[connect 握手]
    D --> E[Claude Code<br/>发现并调用]
    style A fill:#e8f0fe,stroke:#1a73e8
    style E fill:#e6f4ea,stroke:#1e8e3e
```

下面围绕这条主线展开。

## 二、初始化项目

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

关键依赖说明：

- `@modelcontextprotocol/sdk`：官方 TS SDK，包含 server、transport、类型定义；
- `zod`：用于声明工具入参 schema，SDK 会自动把它转成 JSON Schema 暴露给客户端；
- `tsx`：开发期直接跑 `.ts`，免去编译往返。

`tsconfig.json` 至少保留这几项：

```json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "strict": true,
    "outDir": "./build",
    "esModuleInterop": true,
    "skipLibCheck": true
  }
}
```

> **常见问题**：报 "Cannot find module '@modelcontextprotocol/sdk/server/mcp.js'"。原因几乎都是 `moduleResolution` 没设成 `Node16`/`NodeNext`。SDK 用了子路径导出（exports map），旧式 `node` 解析找不到。

## 三、定义你的第一个工具

新建 `src/index.ts`，从最小可运行服务器开始：

```ts
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import * as z from 'zod';

const server = new McpServer({
  name: 'filekit',
  version: '0.1.0',
});

server.registerTool(
  'word-count',
  {
    title: '字数统计',
    description: '统计一段文本的字数、行数、字符数',
    inputSchema: {
      text: z.string().describe('待统计的文本'),
    },
  },
  async ({ text }) => {
    const result = {
      chars: text.length,
      lines: text.split('\n').length,
      words: text.trim().split(/\s+/).filter(Boolean).length,
    };
    return {
      content: [{ type: 'text', text: JSON.stringify(result) }],
      structuredContent: result,
    };
  }
);

const transport = new StdioServerTransport();
await server.connect(transport);
```

**预期结果**：服务器启动后通过标准输入输出等待 JSON-RPC 消息，不会打印任何东西到 stdout（stdout 被协议独占）。`registerTool` 的第一个参数是工具名，第二个是元数据（`description` 是模型决定是否调用它的关键依据），第三个是处理函数。

**入参 schema 的写法**：注意这里 `inputSchema` 直接传一个 Zod 对象的"形状" `{ text: z.string() }`，SDK 内部会包成 `z.object(...)`。`.describe()` 写的说明会变成字段的 JSON Schema `description`，对模型填参有直接帮助——务必给每个字段写一句人话。

## 四、结构化输出与一个 TypeScript 的坑

想让工具返回结构化数据被客户端程序化消费，就加 `outputSchema`：

```ts
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';

server.registerTool(
  'word-count',
  {
    title: '字数统计',
    description: '统计文本的字数、行数、字符数',
    inputSchema: { text: z.string() },
    outputSchema: {
      chars: z.number(),
      lines: z.number(),
      words: z.number(),
    },
  },
  async ({ text }): Promise<CallToolResult> => {
    const output = {
      chars: text.length,
      lines: text.split('\n').length,
      words: text.trim().split(/\s+/).filter(Boolean).length,
    };
    return {
      content: [{ type: 'text', text: JSON.stringify(output) }],
      structuredContent: output,
    };
  }
);
```

**关键坑**：如果你想给返回类型起一个命名类型，**必须用 `type` 别名，不能用 `interface`**。命名 interface 缺少隐式索引签名，无法赋值给 `{ [key: string]: unknown }`，会编译报错。临时绕过也可以展开：`structuredContent: { ...result }`。

## 五、工具注解：让客户端"看懂"你的工具

这是大多数教程一笔带过、却最值得花时间的特性。注解不改变执行语义，但告诉客户端这个工具**是只读、破坏性、还是幂等**，从而在权限提示、UI 呈现、撤销逻辑上做出正确决策：

```ts
server.registerTool(
  'delete-temp',
  {
    title: '清理临时文件',
    description: '删除指定目录下的临时文件',
    inputSchema: { dir: z.string() },
    annotations: {
      title: '清理临时文件',
      destructiveHint: true,
      idempotentHint: true,
    },
  },
  async ({ dir }): Promise<CallToolResult> => {
    // ... 执行删除 ...
    return { content: [{ type: 'text', text: `已清理 ${dir}` }] };
  }
);
```

四个注解的含义：

| 注解 | 含义 | 典型工具 |
|---|---|---|
| `readOnlyHint` | 不修改环境，纯查询 | 查表、读文件、搜索 |
| `destructiveHint` | 会破坏数据 | 删除、覆盖、drop |
| `idempotentHint` | 重复调用结果相同 | 创建-或-已存在 |
| `openWorldHint` | 与不可控外部交互 | 联网请求、发邮件 |

> **最佳实践**：只读查询显式标 `readOnlyHint: true`，Claude Code 在快速审批白名单时会更从容；任何会改动状态的工具都评估是否标 `destructiveHint`。标注诚实、不过度声明，是 MCP 工具工程化的基本功。

## 六、错误处理：抛异常 vs 返回 isError

工具错误有两种表达，效果截然不同：

```ts
server.registerTool(
  'fetch-url',
  {
    title: '抓取网页',
    description: '抓取一个 URL 的正文',
    inputSchema: { url: z.string().url() },
  },
  async ({ url }): Promise<CallToolResult> => {
    try {
      const res = await fetch(url);
      if (!res.ok) {
        return {
          content: [{ type: 'text', text: `HTTP ${res.status}: ${res.statusText}` }],
          isError: true,
        };
      }
      return { content: [{ type: 'text', text: await res.text() }] };
    } catch (err) {
      return {
        content: [{ type: 'text', text: `失败: ${err instanceof Error ? err.message : String(err)}` }],
        isError: true,
      };
    }
  }
);
```

**核心区别**：返回 `{ isError: true }` 的错误**对模型可见**，模型读到后能自我修正、重试或换参数；而 handler 里直接 `throw` 抛出的协议级错误**对模型不可见**，只有宿主能看到。

SDK 其实会自动把抛出的异常转成 `{ isError: true }`，所以 try/catch 不是必需的，但它让你**掌控错误文案**——对自愈能力影响很大。另外注意：`isError: true` 时 `outputSchema` 校验会被跳过。

## 七、本地传输与 server instructions

stdio 是本地集成的标准传输，Claude Code 默认就是以子进程方式拉起你的服务器：

```ts
const transport = new StdioServerTransport();
await server.connect(transport);
```

还可以在构造时给一段 `instructions`，描述跨工具的工作流约束，宿主可能把它放进系统提示：

```ts
const server = new McpServer(
  { name: 'filekit', version: '0.1.0' },
  {
    instructions:
      '调用 search 前先用 list_index 确认索引已就绪。所有查询默认最多返回 50 条。',
  }
);
```

> **铁律**：instructions 不要重复工具 description 里已有的信息，只写跨工具的关系与约束。

## 八、实战案例：filekit 文件搜索工具集

把前面四块拼成一个真实可用的多工具服务器 `src/index.ts`，模拟一个只读的本地文件检索能力：

```ts
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import * as z from 'zod';

const server = new McpServer(
  { name: 'filekit', version: '0.1.0' },
  { instructions: '先 list 再 read。read 的 path 必须来自 list 的结果，禁止手拼。' }
);

// 工具1：只读列表
server.registerTool(
  'list',
  {
    title: '列出目录',
    description: '列出目录下的文件名',
    inputSchema: { dir: z.string().describe('绝对路径目录') },
    annotations: { readOnlyHint: true },
  },
  async ({ dir }) => {
    const entries = await fs.readdir(dir);
    return {
      content: [{ type: 'text', text: entries.join('\n') }],
      structuredContent: { dir, files: entries },
    };
  }
);

// 工具2：只读读取
server.registerTool(
  'read',
  {
    title: '读取文件',
    description: '读取一个文本文件的内容',
    inputSchema: { file: z.string().describe('文件绝对路径') },
    annotations: { readOnlyHint: true },
  },
  async ({ file }) => {
    try {
      const text = await fs.readFile(file, 'utf8');
      return { content: [{ type: 'text', text }] };
    } catch (err) {
      return {
        content: [{ type: 'text', text: `读取失败: ${(err as Error).message}` }],
        isError: true,
      };
    }
  }
);

// 工具3：搜索（只读）
server.registerTool(
  'search',
  {
    title: '关键词搜索',
    description: '在某目录下递归搜索包含关键词的文件名',
    inputSchema: {
      dir: z.string(),
      keyword: z.string().min(1),
    },
    annotations: { readOnlyHint: true, openWorldHint: false },
  },
  async ({ dir, keyword }) => {
    const matches: string[] = [];
    const walk = async (d: string) => {
      for (const e of await fs.readdir(d, { withFileTypes: true })) {
        const full = path.join(d, e.name);
        if (e.isDirectory()) await walk(full);
        else if (e.name.includes(keyword)) matches.push(full);
      }
    };
    await walk(dir);
    return { content: [{ type: 'text', text: matches.join('\n') || '无匹配' }] };
  }
);

await server.connect(new StdioServerTransport());
```

跑起来验证（开发期用 tsx）：

```bash
npx tsx src/index.ts
```

进程会"挂住"等待 stdio 输入——这是正常的，说明它已就绪。

## 九、接入 Claude Code

两种等价方式。**临时接入**（用户级）：

```bash
claude mcp add filekit -- npx tsx /绝对路径/mcp-filekit/src/index.ts
```

**项目级**，在仓库根目录建 `.mcp.json`，团队共享：

```json
{
  "mcpServers": {
    "filekit": {
      "command": "npx",
      "args": ["tsx", "./src/index.ts"]
    }
  }
}
```

验证是否生效：

```bash
claude mcp list          # 应看到 filekit 及其工具数量
claude mcp get filekit   # 查看工具清单
```

随后在 Claude Code 会话里说"列出 /tmp 下的文件并读出其中一个"，模型应自动调用 `list` → `read` 两个工具。`.mcp.json` 里的服务器首次使用会触发权限确认，可配置 `disabledMcpjsonServers` / `enabledMcpjsonServers` 做团队级开关。

## 十、调试：MCP Inspector 与 stderr

stdio 服务器最怕把日志打到 stdout（会污染 JSON-RPC 流），所有诊断**只能走 stderr**：

```ts
process.stderr.write(`[filekit] ready\n`);
```

更系统的做法是用官方 **MCP Inspector** 做交互式调试，它能图形化地手动调用工具、检查 schema、查看原始消息：

```bash
npx @modelcontextprotocol/inspector npx tsx src/index.ts
```

它会启动一个本地 Web 界面，列出所有工具，让你填参数、点 Call、看返回，是排查"工具没被发现""参数校验失败""返回格式不对"的首选工具。

```mermaid
flowchart TB
    subgraph 开发循环
      A[改 ts 代码] --> B[npx tsx 跑]
      B --> C{Inspector 验证}
      C -->|工具调用正确| D[mcp list 验证接入]
      C -->|不对| A
      D --> E[Claude Code 端到端测试]
      E -->|模型调用符合预期| F[完成]
      E -->|触发/参数异常| G[检查 description<br/>与 annotations]
      G --> A
    end
```

## 十一、常见问题速查

| 现象 | 排查方向 |
|---|---|
| `claude mcp list` 看不到工具 | 路径要用绝对路径；确认 tsx/依赖已装在能找到的位置 |
| 模型从不调用某工具 | `description` 写得太弱或太宽泛，补一句"何时该用/不该用" |
| stdout 出现乱码导致协议错误 | 把 `console.log` 全部改成 `process.stderr.write` |
| 参数校验失败 | 入参类型不对；检查 Zod schema 是否与客户端实际传入一致 |
| structuredContent 类型报错 | 改用 `type` 别名，或 `structuredContent: { ...result }` |

## 延伸阅读

- MCP 官方 TypeScript SDK server 指南（`ts.sdk.modelcontextprotocol.io/documents/server.html`）—— 全部 API 的权威参考
- `modelcontextprotocol/typescript-sdk` 仓库 `examples/server/` 目录 —— 含 Streamable HTTP、SSE、会话管理等完整可运行示例
- MCP 规范 Tools 与 Tool Annotations 章节 —— 理解注解语义与协议级约定

## 训练营适用性评估

- **适合**：MCP 工具开发训练营、AI 应用集成训练营、TypeScript 全栈进阶训练营
- **教学时长**：讲授 + 跟做约 3 小时，含实战案例与 Inspector 调试约 4–4.5 小时
- **难度**：中级，要求有 TypeScript 基础，不要求先学过其他语言 SDK
- **延伸建议**：可作为系列第二模块，之后衔接"Streamable HTTP 远程部署""Resources/Prompts 原语"两节，形成从本地到云端、从单原语到全原语的完整链路

## 参考来源

1. [Server | MCP TypeScript SDK 官方文档](https://ts.sdk.modelcontextprotocol.io/documents/server.html) — 官方 server 构建 API 全集，含 registerTool、annotations、错误处理、logging、progress
2. [modelcontextprotocol/typescript-sdk · GitHub](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/docs/server.md) — SDK 源码仓库与可运行示例（simpleStreamableHttp 等）
3. [Build an MCP Server with TypeScript: 2026 Tutorial — DEV Community](https://dev.to/jangwook_kim_e31e7291ad98/build-an-mcp-server-with-typescript-2026-tutorial-1ipk) — 含 9700 万月下载量等行业数据与从零构建示例
4. [How to Build an MCP Server for Claude Code — Build This Now](https://www.buildthisnow.com/blog/tools/mcp/claude-code-mcp-server-tutorial) — claude mcp add 与 .mcp.json 接入实践
5. [Building Your Own MCP Server with TypeScript — jangwook.net](https://jangwook.net/en/blog/en/mcp-server-typescript-sdk-step-by-step-2026/) — 高层 API（server.tool）实战步骤

> [!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学通信知识星球海报-扫码加入](https://wiki.500nian.com/images/zhixingqiu_qrcode.png)
