Model Context Protocol(MCP)的 TypeScript SDK 已经累计 9700 万次月下载、超过 1 万个公开服务器实现。本教程面向已熟悉 TypeScript、想给 Claude Code / Cursor 等客户端开发自定义工具集的工程师,带你用官方 @modelcontextprotocol/server 从零搭建一个多工具服务器,掌握工具注解、结构化输出、错误处理与本地接入的完整闭环。
TypeScript SDK 把构建服务器的过程压缩为三步,理解这三步就理解了 90% 的工作:
McpServer 实例,写明名称与版本;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
关键依赖说明:
@modelcontextprotocol/sdk:官方 TS SDK,包含 server、transport、类型定义;zod:用于声明工具入参 schema,SDK 会自动把它转成 JSON Schema 暴露给客户端;tsx:开发期直接跑 .ts,免去编译往返。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,对模型填参有直接帮助——务必给每个字段写一句人话。
想让工具返回结构化数据被客户端程序化消费,就加 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 工具工程化的基本功。
工具错误有两种表达,效果截然不同:
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 校验会被跳过。
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 里已有的信息,只写跨工具的关系与约束。
把前面四块拼成一个真实可用的多工具服务器 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 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 做团队级开关。
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 } |
ts.sdk.modelcontextprotocol.io/documents/server.html)—— 全部 API 的权威参考modelcontextprotocol/typescript-sdk 仓库 examples/server/ 目录 —— 含 Streamable HTTP、SSE、会话管理等完整可运行示例[!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 等场景全覆盖。
