<!-- title: MCP工具注解：声明风险让客户端安心 -->

# MCP工具注解：声明风险让客户端安心

## 概述

当你的 MCP 工具被 Claude Code、Cursor、Codex CLI 等不同客户端调用时，客户端需要回答一个关键问题：**这个工具该不该弹确认框？能不能自动执行？失败后能不能重试？** 仅靠工具的 `name` 和 `description` 无法回答这些——这正是**工具注解（Tool Annotations）**要解决的。本教程带你用官方 Python SDK 内置的 FastMCP 模块，从零构建一个"日志服务器"，为每个工具标注正确的风险元数据，再用官方 **MCP Inspector** 可视化验证注解是否生效。

**适用人群**：已经能写基本 MCP 工具、想让自己的工具被多客户端安全调用的开发者；以及需要把控"哪些操作可自动放行、哪些必须人工确认"的工程负责人。

## 前置条件

- Python 3.10+，已安装 `uv`（推荐）或 `pip`
- Node.js 18+（用于运行 MCP Inspector，`npx` 即可，无需全局安装）
- 已注册 Claude Code CLI（`claude --version` 可用）
- 了解 JSON Schema 基本概念（工具入参就是用 JSON Schema 描述的）
- 建议先读过 MCP 三原语（工具/资源/提示词）的基础知识

---

## 第一步：理解问题——为什么客户端需要"风险词典"

一个模型发现工具时，看到的三样东西是：工具名、描述、入参 schema。但这回答不了真正重要的问题：

- 它只读数据，还是会改东西？
- 它是删除/覆盖类的破坏性操作，还是追加/创建类的良性操作？
- 同样参数调用两次，结果是否一致？（决定失败后能否安全重试）
- 它会和外部世界交互（API、网页、邮件系统），还是只在封闭域内？

没有这些信息，客户端只能做最保守的假设：**把每个工具都当成"破坏性、不可重试、连外网"来对待**。结果是——每次调用都弹确认框，体验极差；或者干脆不允许自动执行。

工具注解就是 MCP 给出的标准化"风险词典"。它是在 **2025-03-26 规范修订**中正式引入的，让服务器用一个结构化的 `annotations` 字段主动声明工具的行为属性。

> 关键认知：注解是 **hint（提示）而非 contract（契约）**。规范明确写道——除非来自可信服务器，否则客户端必须把注解视为不可信。这意味着注解影响的是**交互体验**（要不要确认、要不要高亮、要不要重试），而不是**安全强制**。一个恶意/有 bug 的服务器完全可以把删除工具标记成 `readOnlyHint: true` 来骗过确认框。所以注解是用来优化 UX 的，真正的安全边界仍要靠权限沙箱和可信来源管理。

---

## 第二步：用官方 FastMCP 模块造第一个工具

很多人不知道：官方 Python SDK（`pip install mcp`）内部就自带了一个 `fastmcp` 高级模块，路径是 `mcp.server.fastmcp`。它用装饰器风格写工具，几行就能跑起来，不必单独装第三方 FastMCP 包。

### 操作说明

新建项目目录并安装依赖：

```bash
mkdir log-mcp && cd log-mcp
uv init --python 3.11
uv add "mcp[cli]"
```

创建 `server.py`：

```python
# server.py
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("Log Explorer")

@mcp.tool()
def search_logs(keyword: str, limit: int = 50) -> str:
    """按关键词搜索应用日志。只读取，不修改任何数据。"""
    # 演示用：真实场景接日志后端（ELK/Loki）
    fake = [
        f"[{i}] user=alice action=login ip=10.0.0.{i} status=ok",
        for i in range(3)
    ]
    matched = [line for line in fake if keyword in line]
    return "\n".join(matched[:limit]) or f"未命中关键词: {keyword}"

if __name__ == "__main__":
    mcp.run()   # 默认走 stdio 传输
```

> 上面的列表推导为了示例简洁省略了循环体，实际代码请用完整的 `for` 语句。

### 预期结果

直接运行能看到进程挂起等待 stdio 输入（正常，stdio 传输下它由宿主驱动）：

```bash
uv run server.py
# 无报错，进程等待 JSON-RPC 消息
```

### 常见问题

- **`ModuleNotFoundError: mcp`**：确认装的是官方包 `mcp`（不是 `mcp-cli`）。用 `uv pip show mcp` 查看版本，建议 ≥ 1.2。
- **装饰器没生效、`list_tools` 返回空**：函数必须有**类型注解**（`keyword: str`）和**docstring**。FastMCP 靠类型注解生成 JSON Schema，靠 docstring 当描述；缺一不可。
- **端口冲突/找不到传输方式**：`mcp.run()` 默认 stdio。想在浏览器/Inspector 里连 HTTP，需要显式 `mcp.run(transport="streamable-http", host="127.0.0.1", port=8000)`。

---

## 第三步：四大注解详解——风险词典的每个词

`ToolAnnotations` 接口定义了四个布尔属性加一个人类可读标题。**重点不是去背字段名，而是理解每个"默认值"背后的安全哲学。**

| 注解 | 默认值 | 含义 | 典型设值 |
|------|--------|------|----------|
| `readOnlyHint` | `false` | 工具是否**不修改**环境 | 查询类设 `true` |
| `destructiveHint` | `true` | 非只读工具的修改是否**破坏性** | 删除/覆盖设 `true`；追加设 `false` |
| `idempotentHint` | `false` | 同参数多次调用是否**幂等** | set 值设 `true`；append 设 `false` |
| `openWorldHint` | `true` | 是否与**外部世界**交互 | 查内网库设 `false`；抓 URL 设 `true` |

**谨慎默认值的逻辑**：一个**未加任何注解**的工具，在四个维度上都会被当成最坏情况——会改数据、破坏性、不可重试、连外网。这是规范**故意偏向安全**的设计：服务器懒得标注，客户端就把它当危险品。

逐个拆解：

**1. `readOnlyHint` —— 最影响 UX 的一个。** 设为 `true` 是"可自动放行"的强信号，因为不该触发确认框。但要诚实：一个"搜索"工具如果**顺带把搜索词写进了分析库**，那它就不是只读的，哪怕主目的是读。判断标准是"调用后环境状态有没有变"，而不是"用户以为它在干嘛"。

**2. `destructiveHint` —— 默认值是 `true`，注意这个方向。** 它不是问"会不会删数据"，而是"非只读操作里，这个修改能不能轻松撤销"。删用户、覆盖文件、吊销 token、关闭 issue，都算破坏性。"创建一条记录""追加一行日志"是良性的，设 `false`。

**3. `idempotentHint` —— 影响重试策略。** 幂等指 `f(x)` 调两次和调一次结果一样。"把用户名改成 X"幂等；"往购物车加一件"不幂等（会变成两件）；"切换开关"不幂等（会反复横跳）。超时后，幂等工具可以放心重试，非幂等工具盲目重试可能导致重复付款、重复记录。

**4. `openWorldHint` —— 唯一一个"关注调用之后"的注解。** 前三个回答"调用前要不要确认"，它回答"返回的内容可不可信"。一个抓取用户给定 URL 的工具是 `openWorldHint: true`——它返回的网页内容可能藏着**提示注入**。查公司内部库的工具是 `false`——封闭域，相对可信。客户端据此决定要不要对返回内容做额外净化。

---

## 第四步：给工具加上正确注解

### 操作说明

在 `server.py` 里引入 `ToolAnnotations`，为"搜索"和"删除"两个工具标注截然不同的风险画像：

```python
# server.py（完整版）
from mcp.server.fastmcp import FastMCP
from mcp.types import ToolAnnotations

mcp = FastMCP("Log Explorer")

@mcp.tool(
    annotations=ToolAnnotations(
        title="搜索日志",          # 人类可读标签，UI 里展示
        read_only_hint=True,      # 只读，可自动放行
        destructive_hint=False,
        idempotent_hint=True,     # 查询天然幂等
        open_world_hint=False,    # 只在封闭的日志库内
    )
)
def search_logs(keyword: str, limit: int = 50) -> str:
    """按关键词搜索应用日志。只读取，不修改任何数据。"""
    return f"命中关键词 '{keyword}' 的日志共 N 条（演示）"

@mcp.tool(
    annotations=ToolAnnotations(
        title="清理旧日志",
        read_only_hint=False,
        destructive_hint=True,    # 删除，破坏性
        idempotent_hint=True,     # 再删一次也是空，幂等
        open_world_hint=False,
    )
)
def purge_logs(before_date: str) -> str:
    """永久删除指定日期之前的日志，操作不可逆。"""
    return f"已清理 {before_date} 之前的日志（演示）"

if __name__ == "__main__":
    mcp.run()
```

### 预期结果

`ToolAnnotations` 的字段名是 **snake_case**（`read_only_hint`），序列化成 JSON-RPC 时会自动转成 spec 要求的 **camelCase**（`readOnlyHint`）。这是 SDK 帮你做的转换，你不必手写驼峰。

### 常见问题

- **`TypeError: unexpected keyword 'readOnlyHint'`**：Python 侧必须用 snake_case `read_only_hint`。直接写驼峰会报错。
- **注解不生效、Inspector 里看不到**：确认 `ToolAnnotations` 来自 `mcp.types`（官方 SDK）。如果你用的是第三方 `fastmcp` 包，导入路径是 `from fastmcp.tools import ToolAnnotations`，字段名一致。
- **担心注解被滥用绕过确认**：记住 hint 非 contract。客户端对不可信服务器仍会强制确认。注解只优化"可信服务器"的体验，不会成为安全漏洞的借口。

---

## 第五步：用 MCP Inspector 可视化验证注解

写完注解，怎么确认它真的发出去了、字段值对不对？靠肉眼读源码不算数——要看**线上序列化后的 JSON-RPC 响应**。官方 **MCP Inspector** 就是这个用途的交互式调试器。

### 操作说明

在项目目录下，用 `npx` 直接拉起 Inspector 并指向你的服务器（无需全局安装）：

```bash
# 用 uv 运行你的 Python 服务器
npx @modelcontextprotocol/inspector uv run server.py

# 或直接用 python
npx @modelcontextprotocol/inspector python server.py
```

Inspector 启动后会打印两个地址：

- **Inspector UI**：`http://127.0.0.1:6274`（在浏览器打开）
- **MCP Proxy**：`127.0.0.1:6277`（Inspector 代理，转发到你的 stdio 服务器）

浏览器打开 6274 后：

1. 左侧点 **Connect**，Inspector 会通过代理与你的 `server.py` 完成 initialize 握手；
2. 切到 **Tools** 标签，列出 `search_logs` 和 `purge_logs`；
3. 点开 `search_logs`，在右侧详情里能看到完整的 `annotations` 块；
4. 在参数表单里填 `keyword=login`，点 **Run Tool**，下方直接看返回。

### 预期结果

`search_logs` 的注解块应该是：

```json
"annotations": {
  "title": "搜索日志",
  "readOnlyHint": true,
  "destructiveHint": false,
  "idempotentHint": true,
  "openWorldHint": false
}
```

`purge_logs` 则是 `destructiveHint: true`、`read_only_hint` 为 false。如果 Inspector 里看到的是**这个 camelCase 结构且值正确**，说明注解已正确序列化。如果 `annotations` 字段**根本不存在**，说明注解没传进去——回到第四步检查导入路径和拼写。

### 常见问题

- **Inspector 打不开 6274**：端口被占用时可用环境变量改端口，`CLIENT_PORT=6280 npx @modelcontextprotocol/inspector ...`。
- **Connect 一直转圈**：多半是服务器启动失败（依赖没装、语法错）。Inspector 命令里把 `uv run server.py` 单独在终端跑一遍，看是否有 Python 报错。
- **Tools 列表为空**：`list_tools` 没返回。确认每个被 `@mcp.tool` 装饰的函数都有类型注解和 docstring。
- **想测 HTTP 传输而非 stdio**：在 Inspector 左侧把传输类型切到 **Streamable HTTP**，填你 `mcp.run(transport="streamable-http")` 暴露的 URL。

---

## 第六步：接入 Claude Code，看注解的实际效果

注解验证无误后，把它接进真实客户端，对比"只读工具"和"破坏性工具"在交互上的差异。

### 操作说明

用 CLI 注册（推荐用绝对路径，避免工作目录漂移）：

```bash
claude mcp add log-explorer -- uv run /abs/path/log-mcp/server.py
```

或在项目根放 `.mcp.json`（团队共享）：

```json
{
  "mcpServers": {
    "log-explorer": {
      "command": "uv",
      "args": ["run", "/abs/path/log-mcp/server.py"]
    }
  }
}
```

启动 Claude Code，输入：

```
帮我在 log-explorer 里搜一下 login 相关的日志
```

再输入：

```
把 2024 年以前的旧日志清理掉
```

### 预期结果

- 搜日志（`readOnlyHint: true`）：在可信配置下，Claude Code 倾向于**直接调用**，不弹确认或只做轻提示。
- 清理日志（`destructiveHint: true`）：会**明确弹确认**，要求你批准这次破坏性调用。

这就是注解落地的价值——**同一台服务器上的两个工具，因为风险画像不同，获得了不同的放行策略**，而这一切由你用四个布尔值声明。

### 常见问题

- **两个工具都弹确认 / 都不弹**：检查 `.mcp.json` 是否被正确加载（`claude mcp list` 能看到 `log-explorer`），以及该服务器是否被标记为可信来源。Claude Code 会综合"注解 + 服务器可信度"做决策，纯 hint 不会单方面放行不可信服务器。
- **想批量减少确认打扰**：配合 `fewer-permission-prompts` 类工具或项目的 `settings.json`，把只读、可信的工具加入允许列表，而不是靠注解"自证清白"。

---

## 实战案例：一个完整的"日志服务器"

把上面的内容整合成一个可直接 `uv run server.py` 跑起来的最小项目。结构如下：

```
log-mcp/
├── pyproject.toml      # 依赖 mcp[cli]
├── server.py           # FastMCP 服务器，含两个带注解的工具
└── .mcp.json           # Claude Code 接入配置
```

`server.py` 用第四步的完整版即可。它的设计要点恰好示范了注解的**风险词典**用法：

- **`search_logs`**：纯读、幂等、封闭域 → `readOnlyHint=true, destructive=false, idempotent=true, openWorld=false` → 客户端可自动放行。
- **`purge_logs`**：删除、不可逆、幂等（删过的再删是空集）→ `destructiveHint=true` → 必须人工确认。

这两条工具共享同一个服务器，但通过注解让客户端**精准区分**该不该打扰用户。这就是"声明风险让客户端安心"的完整闭环：写工具 → 标注 → Inspector 验证 → 客户端落地。

---

## 工作流程

```mermaid
flowchart TD
    A["写 FastMCP 工具<br/>@mcp.tool + 类型注解 + docstring"] --> B["标注 ToolAnnotations<br/>四维度风险画像"]
    B --> C{"注解是否完整？"}
    C -- 否 --> B
    C -- 是 --> D["MCP Inspector 验证<br/>npx @modelcontextprotocol/inspector"]
    D --> E{"annotations 序列化正确？<br/>camelCase 值对吗？"}
    E -- 否 --> B
    E -- 是 --> F["接入客户端<br/>claude mcp add / .mcp.json"]
    F --> G["客户端按风险画像决策<br/>只读→放行 / 破坏性→确认"]

    style B fill:#e8f5e9
    style D fill:#fff3e0
    style G fill:#e3f2fd
```

---

## 延伸阅读

- **官方规范·工具注解**：理解 `ToolAnnotations` 的权威出处，重点读"hint 非 contract"的约束措辞。
- **MCP Inspector 官方文档**：`modelcontextprotocol.io/docs/developer-tools/inspector`，含端口配置、HTTP 传输调试、日志级别切换。
- **官方 Python SDK**：`github.com/modelcontextprotocol/python-sdk`，`mcp.server.fastmcp` 模块与 `mcp.types.ToolAnnotations` 的源码定义。
- **"致命三角"风险模型**：安全研究中关于"读私有数据 + 接触不可信内容 + 外发能力"三者同时出现时的提示注入风险，`openWorldHint` 正是用于识别这类组合。
- **FastMCP（gofastmcp.com）**：官方 SDK 之上的增强版框架，支持更丰富的注解校验与组合工具，适合工具规模上去后迁移。

---

## 训练营适用性评估

- **适合的营型**：MCP 进阶营、Agent 安全与可控性专题营、Python 后端工具开发营。不适合纯零基础营（需先掌握 MCP 三原语）。
- **教学时长**：约 1.5–2 小时。其中注解概念 25 分钟、写工具 30 分钟、Inspector 调试 30 分钟、客户端落地与讨论 25 分钟。
- **动手考核点**：学员能独立产出"同一服务器上含至少一个只读工具、一个破坏性工具"的项目，并在 Inspector 截图证明注解序列化正确，最后在 Claude Code 里演示两种不同的放行行为。
- **延伸练习**：故意写一个 `readOnlyHint=true` 但实际会写文件的"撒谎工具"，讨论客户端为何不能盲信 hint，引出权限沙箱与可信来源管理的必要性。

---

## 参考来源

1. [MCP Tool Annotations Explained: Hints, Trust, and the Risk Vocabulary — ChatForest](https://chatforest.com/guides/mcp-tool-annotations-explained/) — 四大注解的默认值、含义与 hint 非 contract 哲学的详解，引用了 2025-03-26 规范修订。
2. [Tool Annotations as Risk Vocabulary — MCP 官方博客](https://blog.modelcontextprotocol.io/posts/2026-03-16-tool-annotations/) — 官方对注解作为风险词典、不可信语义的权威说明。
3. [MCP Inspector — Model Context Protocol 官方文档](https://modelcontextprotocol.io/docs/developer-tools/inspector) — Inspector 的启动命令、端口与调试流程。
4. [FastMCP — GitHub (PrefectHQ)](https://github.com/prefecthq/fastmcp) — Pythonic 装饰器风格 MCP 框架，工具注解的进阶用法与 API。
5. [MCP Python SDK — GitHub](https://github.com/modelcontextprotocol/python-sdk) — 官方 SDK 的 `mcp.server.fastmcp` 模块与 `mcp.types.ToolAnnotations` 定义。

> [!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)
