首页 › tutorials › 用 TypeScript SDK 造 MCP 工具集

用 TypeScript SDK 造 MCP 工具集

概述

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

前置条件

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

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

  1. 创建 McpServer 实例,写明名称与版本;
  2. 注册 你的 tools / resources / prompts;
  3. 连接 一个 transport(stdio 本地,或 Streamable HTTP 远程)。
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

下面围绕这条主线展开。

二、初始化项目

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

关键依赖说明:

tsconfig.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,从最小可运行服务器开始:

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:

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 呈现、撤销逻辑上做出正确决策:

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

工具错误有两种表达,效果截然不同:

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 默认就是以子进程方式拉起你的服务器:

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

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

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

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

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

把前面四块拼成一个真实可用的多工具服务器 src/index.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):

npx tsx src/index.ts

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

九、接入 Claude Code

两种等价方式。临时接入(用户级):

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

项目级,在仓库根目录建 .mcp.json,团队共享:

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

验证是否生效:

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:

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

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

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

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

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 }

延伸阅读

训练营适用性评估

参考来源

  1. Server | MCP TypeScript SDK 官方文档 — 官方 server 构建 API 全集,含 registerTool、annotations、错误处理、logging、progress
  2. modelcontextprotocol/typescript-sdk · GitHub — SDK 源码仓库与可运行示例(simpleStreamableHttp 等)
  3. Build an MCP Server with TypeScript: 2026 Tutorial — DEV Community — 含 9700 万月下载量等行业数据与从零构建示例
  4. How to Build an MCP Server for Claude Code — Build This Now — claude mcp add 与 .mcp.json 接入实践
  5. Building Your Own MCP Server with TypeScript — jangwook.net — 高层 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学通信知识星球海报-扫码加入