首页 › tutorials › TS构建MCP服务器与工具注解

用 TypeScript 构建第一个 MCP 服务器:Tools 原语与工具注解实战

概述

本教程带你用官方 TypeScript SDK(@modelcontextprotocol/sdk)从零构建一个可运行、可被 Claude Code 直接调用的 MCP 服务器,聚焦 Tools(工具)原语与 工具注解(Tool Annotations)。学完你将掌握:高阶 McpServer API 的工具注册、Zod 输入校验、只读/破坏性注解的精准标注、结构化错误返回与日志通知,以及理解"致死三件套"风险模型下注解如何驱动审批门控。适合已有 Node.js/TypeScript 基础、想让 AI 助手安全调用自定义工具的开发者。

前置条件

本教程不重复 Go SDK、Rust、FastMCP(Python)等已覆盖的路径,而是走最主流的 TypeScript 路线,并把重心放在一个常被忽视却决定安全性的细节——工具注解上。


一、理解 MCP 的原语与"为什么是 Tools"

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)。


三、高阶 API 与低阶 API:选哪个?

TS SDK 提供两层 API:

本教程全程使用高阶 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]}` }] };
  }
);

关键点解读:


五、工具注解:给工具贴上"风险标签"

这是本篇的核心。工具注解(annotations) 是工具对象里的一组提示字段,告诉客户端这个工具的"行为画像"。规范定义了四个布尔提示:

注解 含义 典型工具
readOnlyHint 不改变任何状态 查询、搜索、读取
destructiveHint 效果不可逆/破坏性 删除、覆盖、清空
idempotentHint 重复调用效果相同 幂等的 upsert
openWorldHint 与不受信任的外部世界交互 联网搜索、发邮件

默认值规范约定为:readOnlyHint=false、destructiveHint=false、idempotentHint=false、openWorldHint=true。

注意:注解只是"提示",不是强制。 它本身不阻止工具执行,而是把决策权交给客户端的策略层。客户端(如 Claude Code、IDE)据此决定:

为什么这很重要:致命三件套(Lethal Trifecta)

当一个工具集合同时具备三项时,风险急剧上升:

  1. 广泛的访问权限(能读写大量敏感数据或系统资源);
  2. 接触不受信任的外部数据(openWorldHint: true,如抓取网页、收邮件);
  3. 能执行破坏性操作(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——这并不矛盾:删除本身是破坏性的,但"重复删除已删除的键"不会产生额外副作用(删除不存在的键是空操作),所以幂等成立。


七、结构化输出与日志通知

结构化输出(Structured Output,2025-06-18 规范)

返回 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(在工具定义上声明输出契约),客户端可校验返回结构。结构化输出非常适合"工具结果要被另一个程序/工具消费"的链路。

日志通知(Logging)

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 调试(强烈推荐第一步)

Inspector 是官方可视化调试器,无需把服务器挂进 Claude Code 就能逐个调用工具、查看 JSON-RPC 流量、检查注解是否正确:

npx @modelcontextprotocol/inspector node build/index.js

浏览器打开后,左侧能看到注册的工具列表、入参 schema、注解字段。手动填参调用 kv_set / kv_delete,右侧显示返回的 content、isError、structuredContent。在写进 Claude Code 之前,先用 Inspector 把每个工具跑通,能省去大量排错时间。

挂载到 Claude Code

通过 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 污染。


十一、最佳实践清单

  1. 每个工具都标注注解:至少写清 readOnlyHint 与 destructiveHint。把"注解缺失"当作 lint 错误。
  2. 给参数写 describe:直接提升模型传参准确率,是性价比最高的优化。
  3. 业务失败用 isError,协议/程序错误才抛异常:让模型有机会自行纠错重试。
  4. 严禁 stdout 写非协议输出:所有日志走 sendLoggingMessage,否则污染 stdio 传输。
  5. 破坏性 + 开放世界组合要二次确认:在客户端策略层设规则,防御致命三件套。
  6. 结果需程序消费时用 structuredContent:别让下游去正则解析自由文本。
  7. 先用 Inspector 跑通,再挂载宿主:把"是不是协议层出错"和"是不是模型没触发"两类问题分离。

延伸阅读

进阶方向:把 stdio 换成 Streamable HTTP 实现远程部署、为敏感工具叠加 OAuth 2.1 资源服务器、用 Elicitation 让破坏性工具在执行前向用户反向收集确认输入——这些构成 MCP 服务器从"能跑"到"生产可用"的完整阶梯。


参考来源

  1. TypeScript SDK 官方仓库 — @modelcontextprotocol/sdk 的 McpServer 高阶 API、传输层与示例代码来源。
  2. MCP TypeScript SDK: Complete API Reference 2026 — 服务器/工具/资源/Prompts/传输的完整 API 参考。
  3. McpServer High-Level API — DeepWiki — server.tool() 重载、工具注册与执行机制的深度解析。
  4. Tool Annotations as Risk Vocabulary(官方博客) — 工具注解四个 hint 的语义、致命三件套风险模型与客户端策略建议。
  5. MCP Tool Annotations Explained — 注解默认值、破坏性/幂等组合与安全实践的实操指南。

训练营适用性评估

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