本教程带你用官方 TypeScript SDK(@modelcontextprotocol/sdk)从零构建一个可运行、可被 Claude Code 直接调用的 MCP 服务器,聚焦 Tools(工具)原语与 工具注解(Tool Annotations)。学完你将掌握:高阶 McpServer API 的工具注册、Zod 输入校验、只读/破坏性注解的精准标注、结构化错误返回与日志通知,以及理解"致死三件套"风险模型下注解如何驱动审批门控。适合已有 Node.js/TypeScript 基础、想让 AI 助手安全调用自定义工具的开发者。
"type": "module")与 async/awaitnpx @modelcontextprotocol/inspector(调试利器,无需单独安装)本教程不重复 Go SDK、Rust、FastMCP(Python)等已覆盖的路径,而是走最主流的 TypeScript 路线,并把重心放在一个常被忽视却决定安全性的细节——工具注解上。
Model Context Protocol 把"AI 与外部世界的交互"抽象成一组原语(primitives)。不同原语承担不同语义:
| 原语 | 语义 | 谁主导 | 是否已在本系列覆盖 |
|---|---|---|---|
| Resources | 可订阅的知识源(只读、可寻址) | 应用拉取 | ✓ 已覆盖 |
| Prompts | 参数化模板 | 用户触发 | ✓ 已覆盖 |
| Completion | 参数智能补全 | 应用请求 | ✓ 已覆盖 |
| Roots | 项目边界声明 | 应用告知服务器 | ✓ 已覆盖 |
| Sampling / Elicitation | 服务器反向请求模型/用户 | 服务器主动 | ✓ 已覆盖 |
| Tools | 模型可调用的可执行函数 | 模型自主调用 | 本篇重点 |
Tools 的独特之处:它是唯一由模型自主决定何时调用的原语。 这意味着工具一旦注册,何时执行、传什么参数,主要由 LLM 根据上下文判断。这种"自主性"带来了巨大的能力,也带来了风险——这也正是工具注解存在的根本原因。
一个工具的完整定义(2025-06-18 规范)包含:
name 工具唯一标识
title 人类可读标题(UI 展示用)
description 给模型看的说明,决定能否被精准触发
inputSchema JSON Schema 形态的入参定义
outputSchema 结构化输出的契约(2025-06-18 新增)
annotations 行为提示:只读?破坏性?幂等?开放世界?
其中 description 与 annotations 是决定"安全调用"的两个杠杆。
创建一个干净的工作目录并初始化:
mkdir kv-store-mcp && cd kv-store-mcp
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node
npx tsc --init
把 package.json 改为 ESM 模块,并加上构建脚本:
{
"name": "kv-store-mcp",
"version": "1.0.0",
"type": "module",
"bin": { "kv-store-mcp": "build/index.js" },
"scripts": {
"build": "tsc",
"start": "node build/index.js",
"dev": "tsc --watch"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.6.0"
}
}
tsconfig.json 关键项(Node 现代模块解析):
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "build",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src"]
}
常见问题 1:运行时报
ERR_UNKNOWN_FILE_EXTENSION。 原因:未设置"type": "module"或用了"module": "CommonJS"。 解决:确认package.json有"type": "module",tsconfig用NodeNext,import 路径带.js后缀(如./server/mcp.js,即使源文件是.ts)。
TS SDK 提供两层 API:
McpServer:声明式,自动处理能力协商、schema 生成、消息路由。90% 的场景用它。Server + setRequestHandler:手动接管 JSON-RPC 消息,适合需要自定义协议行为或学习原理。本教程全程使用高阶 API。一个最小骨架:
// src/index.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
const server = new McpServer({
name: "kv-store",
version: "1.0.0",
});
// ... 在这里注册工具 ...
await server.connect(new StdioServerTransport());
StdioServerTransport 通过标准输入/输出与宿主通信——这是 Claude Code 挂载本地服务器最常用的传输方式。server.connect() 触发初始化握手(initialize 请求 + 能力协商),握手成功后服务器开始监听 JSON-RPC 消息。
预期结果:
node build/index.js启动后没有输出,进程挂起等待 stdin——这是正确的,MCP 服务器是"被动"的,等待宿主发指令。
server.tool() 的高阶签名有多个重载,最完整的形态是:
server.tool(
name, // 工具名(小写、下划线/连字符分隔)
description, // 给模型的说明
paramsSchema, // Zod raw shape(不是完整 z.object)
annotations, // 行为提示对象
handler // async (args, extra) => result
);
注意 paramsSchema 传的是 Zod 的 raw shape(如 { key: z.string() }),SDK 内部会包成 z.object 并转成 JSON Schema,省去你手写 JSON Schema 的负担。
先实现一个只读的键值读取工具(数据存在本地 kv.json):
// src/index.ts —— KV 存储辅助函数
import { promises as fs } from "node:fs";
import path from "node:path";
const DATA = path.join(process.cwd(), "kv.json");
async function load(): Promise<Record<string, string>> {
try {
return JSON.parse(await fs.readFile(DATA, "utf8"));
} catch {
return {};
}
}
async function save(kv: Record<string, string>) {
await fs.writeFile(DATA, JSON.stringify(kv, null, 2), "utf8");
}
// 只读工具:读取键值
server.tool(
"kv_get",
"读取持久化键值存储中指定键的值。只读,无副作用。",
{ key: z.string().describe("要读取的键名") },
{ readOnlyHint: true, openWorldHint: false },
async ({ key }) => {
const kv = await load();
if (!(key in kv)) {
return {
content: [{ type: "text", text: `键 "${key}" 不存在` }],
isError: true,
};
}
return { content: [{ type: "text", text: `${key} = ${kv[key]}` }] };
}
);
关键点解读:
z.string().describe():describe 文本会进入生成的 JSON Schema 的 description 字段,模型据此理解每个参数含义——给每个字段写 describe 是提升参数准确率的廉价手段。isError: true:不是抛异常,而是把"业务失败"作为正常返回。抛异常会变成 JSON-RPC error,宿主可能直接中断;返回 isError 让模型看到失败原因并可自行调整。{ readOnlyHint: true }:告诉宿主"这个工具不改任何东西"。这是本篇的核心。工具注解(annotations) 是工具对象里的一组提示字段,告诉客户端这个工具的"行为画像"。规范定义了四个布尔提示:
| 注解 | 含义 | 典型工具 |
|---|---|---|
readOnlyHint |
不改变任何状态 | 查询、搜索、读取 |
destructiveHint |
效果不可逆/破坏性 | 删除、覆盖、清空 |
idempotentHint |
重复调用效果相同 | 幂等的 upsert |
openWorldHint |
与不受信任的外部世界交互 | 联网搜索、发邮件 |
默认值规范约定为:readOnlyHint=false、destructiveHint=false、idempotentHint=false、openWorldHint=true。
注意:注解只是"提示",不是强制。 它本身不阻止工具执行,而是把决策权交给客户端的策略层。客户端(如 Claude Code、IDE)据此决定:
readOnlyHint: true 的工具 → 可能被自动批准(不打扰用户);destructiveHint: true 的工具 → 强制弹出确认框,甚至要求二次输入确认;openWorldHint: true 的工具 → 数据来源不可信,触发的指令需更严格审查。当一个工具集合同时具备三项时,风险急剧上升:
openWorldHint: true,如抓取网页、收邮件);destructiveHint: true,如删除文件、发转账)。攻击场景:模型被"读取到的恶意网页内容"做了提示注入,诱导它调用你的"删除文件"工具——于是不受信任的外部数据,驱动了对你受信任资源的破坏。
注解无法单独消除这个风险,但它是防御的信号基础设施:客户端可以在策略层设定规则,例如"任何同时满足 openWorldHint 触发的上下文 + destructiveHint 工具,必须人工确认"。这正是 Anthropic 官方博客 2026-03-16 文章《Tool Annotations as Risk Vocabulary》强调的要点——注解是风险词汇,而非执行开关。
常见问题 2:我标了
destructiveHint: true,但工具还是被自动执行了。 原因:注解是给客户端的提示,客户端必须主动实现审批逻辑才生效;某些宿主默认忽略它。 解决:确认你的客户端(Claude Code 版本)支持注解驱动的确认;在团队规范里把它作为"必标注 + 必检查"项。
现在用一个完整的键值存储,演示注解谱系的完整跨度:
// 列出所有键(只读)
server.tool(
"kv_list",
"列出持久化键值存储中的全部键名。",
{},
{ readOnlyHint: true, openWorldHint: false },
async () => {
const kv = await load();
const keys = Object.keys(kv);
const text = keys.length ? keys.join(", ") : "(存储为空)";
return { content: [{ type: "text", text }] };
}
);
// 设置键值(幂等、非破坏性)
server.tool(
"kv_set",
"设置或更新一个键值对。重复设置同一键值,结果相同。",
{
key: z.string().min(1).describe("键名,不可为空"),
value: z.string().describe("要存储的值"),
},
{ destructiveHint: false, idempotentHint: true, openWorldHint: false },
async ({ key, value }) => {
const kv = await load();
kv[key] = value;
await save(kv);
await server.sendLoggingMessage({
level: "info",
data: `kv_set: ${key} 已更新`,
});
return { content: [{ type: "text", text: `已设置 ${key}` }] };
}
);
// 删除键(破坏性、幂等)
server.tool(
"kv_delete",
"永久删除一个键值对。不可恢复,请谨慎调用。",
{ key: z.string().describe("要删除的键名") },
{ destructiveHint: true, idempotentHint: true, openWorldHint: false },
async ({ key }) => {
const kv = await load();
if (!(key in kv)) {
return {
content: [{ type: "text", text: `键 "${key}" 不存在,无需删除` }],
};
}
delete kv[key];
await save(kv);
return { content: [{ type: "text", text: `已删除 ${key}` }] };
}
);
注解谱系一览(这正是本示例的设计意图):
kv_get → readOnlyHint:true (最安全,可自动批准)
kv_list → readOnlyHint:true (最安全,可自动批准)
kv_set → idempotentHint:true (中等,幂等写入)
kv_delete → destructiveHint:true (最危险,应强制确认)
kv_delete 同时标了 destructiveHint: true 和 idempotentHint: true——这并不矛盾:删除本身是破坏性的,但"重复删除已删除的键"不会产生额外副作用(删除不存在的键是空操作),所以幂等成立。
返回 content 数组是给模型看的文本/图片。但有时下游应用需要机器可解析的结构化数据。2025-06-18 规范新增了 outputSchema 与 structuredContent:
// 进阶:让 kv_list 返回结构化数据
server.tool(
"kv_list_json",
"以结构化 JSON 返回所有键值对(供程序消费)。",
{},
{ readOnlyHint: true, openWorldHint: false },
async () => {
const kv = await load();
return {
content: [
{ type: "text", text: `共 ${Object.keys(kv).length} 个键` },
],
structuredContent: {
count: Object.keys(kv).length,
items: Object.entries(kv).map(([k, v]) => ({ key: k, value: v })),
},
};
}
);
structuredContent 是与 content 并列的字段,应用层可直接 JSON.parse 拿到结构化结果,而不必从自由文本里正则抽取。配合 outputSchema(在工具定义上声明输出契约),客户端可校验返回结构。结构化输出非常适合"工具结果要被另一个程序/工具消费"的链路。
server.sendLoggingMessage() 是服务器主动推送给客户端的 notifications/message,不影响工具返回值,仅用于可观测:
await server.sendLoggingMessage({
level: "info", // debug|info|notice|warning|error|critical|alert|emergency
data: "kv_set 完成",
logger: "kv-store", // 可选,便于客户端归类
});
级别遵循 debug < info < notice < warning < error < critical < alert < emergency。客户端可按级别过滤。把关键操作(写入、删除)记录成日志,便于在 Inspector 或宿主侧排查"模型到底做了什么"。
常见问题 3:
sendLoggingMessage报"transport not connected"。 原因:在server.connect()之前调用。 解决:确保日志发送发生在 connect 之后,且在 handler 内部(此时连接已建立)。
构建:
npm run build
Inspector 是官方可视化调试器,无需把服务器挂进 Claude Code 就能逐个调用工具、查看 JSON-RPC 流量、检查注解是否正确:
npx @modelcontextprotocol/inspector node build/index.js
浏览器打开后,左侧能看到注册的工具列表、入参 schema、注解字段。手动填参调用 kv_set / kv_delete,右侧显示返回的 content、isError、structuredContent。在写进 Claude Code 之前,先用 Inspector 把每个工具跑通,能省去大量排错时间。
通过 CLI 注册(-s user 表示全局可用,-s project 写进 .mcp.json):
claude mcp add kv-store -s user -- node /绝对路径/kv-store-mcp/build/index.js
或在项目根目录创建 .mcp.json(可提交到仓库,团队共享):
{
"mcpServers": {
"kv-store": {
"command": "node",
"args": ["./build/index.js"],
"cwd": "/绝对路径/kv-store-mcp"
}
}
}
启动 Claude Code,输入:
/mcp
应看到 kv-store 状态为 connected,并列出四个工具。
在 Claude Code 里直接对话:
帮我把 api_key 设置为 sk-test-123,然后列出当前所有键。
模型应自主调用 kv_set(写入)和 kv_list(只读读取)。注意注解的效果:在支持注解审批的客户端中,如果指令涉及 kv_delete,会触发额外确认。
常见问题 4:
/mcp显示failed to connect。 排查:① 在宿主侧用绝对路径运行一次node build/index.js,确认不报错;② 服务器不可向 stdout 写非协议输出(如console.log)——这会污染 stdio 传输,日志一律走sendLoggingMessage;③ 检查cwd是否能让程序找到/创建kv.json。
flowchart LR
U[用户对话] --> CC[Claude Code 宿主]
CC -- 模型决定调用 --> T1[kv_get 只读]
CC -- 自动批准 --> T1
CC -- 模型决定调用 --> T4[kv_delete 破坏性]
CC -- 注解触发确认 --> CONFIRM{用户确认?}
CONFIRM -- 是 --> T4
CONFIRM -- 否 --> REJ[拒绝执行]
T1 --> STDIO[(stdio JSON-RPC)]
T4 --> STDIO
STDIO --> SRV[McpServer TS SDK]
SRV -- load/save --> FS[(kv.json)]
SRV -- notifications/message --> LOG[日志通知]
LOG --> CC
SRV --> STDIO
STDIO --> CC
图中清晰呈现了三条注解驱动的决策路径:只读工具走自动批准、破坏性工具走人工确认、所有工具的结果与日志都经 stdio 双向回流宿主。
index.ts把前面的片段拼成一个完整、可编译运行的文件(src/index.ts):
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { promises as fs } from "node:fs";
import path from "node:path";
const DATA = path.join(process.cwd(), "kv.json");
async function load(): Promise<Record<string, string>> {
try {
return JSON.parse(await fs.readFile(DATA, "utf8"));
} catch {
return {};
}
}
async function save(kv: Record<string, string>) {
await fs.writeFile(DATA, JSON.stringify(kv, null, 2), "utf8");
}
const server = new McpServer({ name: "kv-store", version: "1.0.0" });
server.tool(
"kv_get", "读取持久化键值存储中指定键的值。只读,无副作用。",
{ key: z.string().describe("要读取的键名") },
{ readOnlyHint: true, openWorldHint: false },
async ({ key }) => {
const kv = await load();
if (!(key in kv))
return { content: [{ type: "text", text: `键 "${key}" 不存在` }], isError: true };
return { content: [{ type: "text", text: `${key} = ${kv[key]}` }] };
}
);
server.tool(
"kv_list", "列出持久化键值存储中的全部键名。",
{}, { readOnlyHint: true, openWorldHint: false },
async () => {
const kv = await load();
const keys = Object.keys(kv);
return { content: [{ type: "text", text: keys.length ? keys.join(", ") : "(存储为空)" }] };
}
);
server.tool(
"kv_set", "设置或更新一个键值对。",
{ key: z.string().min(1).describe("键名"), value: z.string().describe("值") },
{ destructiveHint: false, idempotentHint: true, openWorldHint: false },
async ({ key, value }) => {
const kv = await load();
kv[key] = value;
await save(kv);
await server.sendLoggingMessage({ level: "info", data: `kv_set: ${key}` });
return { content: [{ type: "text", text: `已设置 ${key}` }] };
}
);
server.tool(
"kv_delete", "永久删除一个键值对。不可恢复。",
{ key: z.string().describe("要删除的键名") },
{ destructiveHint: true, idempotentHint: true, openWorldHint: false },
async ({ key }) => {
const kv = await load();
if (!(key in kv))
return { content: [{ type: "text", text: `键 "${key}" 不存在,无需删除` }] };
delete kv[key];
await save(kv);
return { content: [{ type: "text", text: `已删除 ${key}` }] };
}
);
await server.connect(new StdioServerTransport());
编译运行验证:
npm run build && echo '{"name":"demo"}' > kv.json
npx @modelcontextprotocol/inspector node build/index.js
在 Inspector 中依次:调用 kv_set(key=token, value=abc) → 调用 kv_get(key=token) 看到 token = abc → 调用 kv_delete(key=token)。每一步观察返回结构与日志面板,确认无 console.log 污染。
readOnlyHint 与 destructiveHint。把"注解缺失"当作 lint 错误。describe:直接提升模型传参准确率,是性价比最高的优化。isError,协议/程序错误才抛异常:让模型有机会自行纠错重试。sendLoggingMessage,否则污染 stdio 传输。structuredContent:别让下游去正则解析自由文本。modelcontextprotocol.io/specification/2025-06-18 —— Tools 原语、outputSchema、Streamable HTTP、OAuth 2.1 授权的权威定义。ts.sdk.modelcontextprotocol.io/documents/server.html —— 高阶/低阶 API、日志级别、传输层完整说明。blog.modelcontextprotocol.io/posts/2026-03-16-tool-annotations/ —— 注解能做与不能做的官方阐述。modelcontextprotocol.io/docs/tools/inspector —— 调试器的进阶用法。进阶方向:把 stdio 换成 Streamable HTTP 实现远程部署、为敏感工具叠加 OAuth 2.1 资源服务器、用 Elicitation 让破坏性工具在执行前向用户反向收集确认输入——这些构成 MCP 服务器从"能跑"到"生产可用"的完整阶梯。
@modelcontextprotocol/sdk 的 McpServer 高阶 API、传输层与示例代码来源。server.tool() 重载、工具注册与执行机制的深度解析。kv_stats(返回结构化的键数量/总字符数)工具;② 把破坏性删除改成"软删除 + 撤销",对比注解如何变化;③ 模拟一次提示注入(让模型读取含恶意指令的"值"),观察注解 + 审批门控如何拦住破坏性调用。[!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 等场景全覆盖。
