<!-- title: 给MCP工具挂四类行为注解 -->

## 概述

本教程聚焦 MCP（Model Context Protocol）工具注解（Tool Annotations）这一组常被忽略、却直接决定客户端如何对待你的工具的语义标记。你会学到 `title`、`readOnlyHint`、`destructiveHint`、`idempotentHint`、`openWorldHint` 五个字段的精确含义与反直觉的默认值，并用 Python FastMCP 构建一个覆盖四种风险画像的文件操作服务器，接入 Claude Code 观察确认行为的差异。适合已经写过至少一个 MCP 服务器、想让模型和客户端"更正确地使用自己工具"的开发者。

## 前置条件

- 已安装 Node.js（用于 Claude Code CLI）与 Python 3.10+。
- 已能独立启动一个 stdio MCP 服务器（可参考本系列《Python造MCP三原语服务器》）。
- 已安装 Claude Code CLI，并能通过 `claude mcp add` 添加本地服务器。
- 安装官方 Python SDK：`pip install "mcp[cli]"`。

## 一、为什么需要工具注解

一个没有注解的工具，对客户端来说是"黑盒"。客户端不知道调用它会读文件还是会删库，只能一律弹出最重的确认框，或在受信模式下武断放行——两种极端都不理想。

工具注解就是给这个黑盒贴上一组"风险词汇"。MCP 官方 2026 年 3 月的博客把它称为 **"Tool Annotations as Risk Vocabulary"（工具注解即风险词汇）**：服务器用四个布尔标记描述工具会不会修改环境、改动是否可逆、能否安全重试、是否触及外部世界；客户端据此在**调用前**判断"要不要确认"，并在**调用后**判断"返回值可不可信"。

关键性质只有一句话，但它决定了本文所有结论：

> 所有注解都是 **hint（提示）**，不是 **contract（契约）**。规范明确要求：来自不可信服务器的注解，客户端**不得**据此做出工具调用决策。

记住这条，后面所有的"影响权限"都要在这个边界内理解。

## 二、五个字段的精确含义

以下定义来自 MCP 2025-06-18 规范的 `schema.json` 中 `ToolAnnotations` 的官方描述，逐字核对：

| 字段 | 类型 | 默认值 | 含义 |
|------|------|--------|------|
| `title` | string | 无 | 人类可读的工具标题，仅用于界面展示。注意：连它本身也是 hint，不保证准确。 |
| `readOnlyHint` | boolean | `false` | 若为 `true`，工具**不修改**其运行环境。它是另外两个标记的"总开关"。 |
| `destructiveHint` | boolean | `true` | 若为 `true`，可能进行**破坏性**更新；若为 `false`，只做**追加性**更新。仅在 `readOnlyHint == false` 时才有意义。 |
| `idempotentHint` | boolean | `false` | 若为 `true`，用相同参数重复调用**不会产生额外副作用**，可安全重试。仅在 `readOnlyHint == false` 时才有意义。 |
| `openWorldHint` | boolean | `true` | 若为 `true`，工具可能触及"开放世界"的外部实体（如联网搜索）；若为 `false`，交互域是封闭的（如内存字典）。 |

### 2.1 三个"前置确认"标记

`readOnlyHint`、`destructiveHint`、`idempotentHint` 回答的是同一个**调用前**的问题：**客户端要不要在调用这个工具前请求确认？**

- 只读工具（`readOnlyHint: true`）：安全观察，通常无需确认或仅轻量确认。
- 写但只追加（`readOnlyHint: false, destructiveHint: false`）：新增数据但不覆盖，风险中等。
- 写且破坏（`destructiveHint: true`）：可能不可逆，应重确认。
- 幂等（`idempotentHint: true`）：失败后可放心重试，不必担心重复执行造成二次损害。

### 2.2 一个"输出信任"标记

`openWorldHint` 与前三个不同。它回答的不是"调用前要不要确认"，而是"**调用后**返回的内容可不可信"。官方原话：它"关心工具触及的范围以及它的输出可能带回什么"，并且"在调用后和调用前一样重要"。规范建议：把工具认为"外部"的任何东西都视为**潜在不可信内容**的来源。

这条标记对部署上下文高度敏感——"外部"在公司服务器上可能指"公司网络之外"，在你笔记本上可能指"本机之外"。所以最稳妥的姿势是：凡是被标记为开放世界的工具，其输出都应被当作不可信输入处理。

### 2.3 反直觉的默认值

最容易踩坑的是默认值。**一个什么注解都不写的工具，会被保守地假定为：非只读、可能破坏性、非幂等、开放世界。** 也就是说，默认就是最危险的画像。这看起来很"冤"，但设计是有意的——在缺乏信息时，客户端必须假设最坏情况，逼着有心的服务器作者主动声明 `readOnlyHint: true` 来为只读工具"减负"。

实践推论：**只读工具一定要显式标 `readOnlyHint: true`**，否则它会背着"可能删库"的包袱，每次调用都被重确认。

## 三、组合为何比单值更重要

官方博客提出一个概念叫 **"Lethal Trifecta"（致命三件套）**：单看一个标记判断风险是错的，风险真正住在标记的**组合**里。理解几个典型组合：

- `readOnly: true`：纯观察，最轻。其余三个标记此时被规范判定为"无意义"。
- `readOnly: false, destructive: false, idempotent: false`：追加写入，如 `log.append`。中等。
- `readOnly: false, destructive: false, idempotent: true`：幂等写入，如 `set(key, value)`。可安全重试。
- `readOnly: false, destructive: true, idempotent: true`：幂等破坏，如 `delete(path)`——删已删文件无额外副作用，故幂等；但首次执行不可逆，故破坏。这是"危险但可重试"的典型。
- 任意组合叠加 `openWorld: true`：输出要按不可信处理，风险被进一步放大。

所以给你的建议：**填注解时按组合填，而不是孤立地勾某个布尔值。** 一个工具的真实风险画像，往往需要三个甚至四个标记联合表达。

## 四、实战：构建覆盖四种画像的文件操作服务器

我们来做一个迷你服务器 `annotated-file-ops`，刻意覆盖"只读 / 追加 / 幂等写 / 破坏删"四种画像，让你在 Claude Code 里直观对比确认行为差异。

### 步骤 1：初始化项目

```bash
mkdir annotated-file-ops && cd annotated-file-ops
python -m venv .venv && source .venv/bin/activate
pip install "mcp[cli]"
```

### 步骤 2：编写带注解的服务器

新建 `server.py`，注释保留英文以便模型理解：

```python
from pathlib import Path
from mcp.server.fastmcp import FastMCP
from mcp.types import ToolAnnotations

mcp = FastMCP("annotated-file-ops")

# In-memory store for the idempotent write demo
_store: dict[str, str] = {}


@mcp.tool(
    annotations=ToolAnnotations(
        title="读取文本文件",
        readOnlyHint=True,      # pure observation, modifies nothing
        openWorldHint=False,    # stays on local disk
    )
)
def read_text(path: str) -> str:
    """读取指定路径的文本文件内容，仅读不改。"""
    return Path(path).read_text(encoding="utf-8")


@mcp.tool(
    annotations=ToolAnnotations(
        title="追加写入",
        readOnlyHint=False,
        destructiveHint=False,   # additive only, never overwrites
        idempotentHint=False,    # appending twice doubles content
        openWorldHint=False,
    )
)
def append_text(path: str, content: str) -> str:
    """向文件追加文本，不覆盖已有内容。"""
    with Path(path).open("a", encoding="utf-8") as f:
        f.write(content)
    return f"appended {len(content)} chars to {path}"


@mcp.tool(
    annotations=ToolAnnotations(
        title="设置键值（幂等）",
        readOnlyHint=False,
        destructiveHint=False,   # overwrite a single value, additive to store
        idempotentHint=True,     # same key+value -> no extra effect
        openWorldHint=False,
    )
)
def set_kv(key: str, value: str) -> str:
    """将键值写入内存字典；重复设置相同值无副作用。"""
    if _store.get(key) == value:
        return "no-op: value unchanged"
    _store[key] = value
    return f"set: {key}"


@mcp.tool(
    annotations=ToolAnnotations(
        title="删除文件",
        readOnlyHint=False,
        destructiveHint=True,    # irreversible
        idempotentHint=True,     # deleting already-deleted file = no extra effect
        openWorldHint=False,
    )
)
def delete_file(path: str) -> str:
    """永久删除指定文件，不可恢复。"""
    p = Path(path)
    p.unlink(missing_ok=True)
    return f"deleted: {path}"


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

### 步骤 3：把服务器接入 Claude Code

在项目目录用 CLI 注册（推荐项目级作用域，便于随仓库共享）：

```bash
claude mcp add file-ops --scope project -- python server.py
```

这会在项目根写入 `.mcp.json`，等价于：

```json
{
  "mcpServers": {
    "file-ops": {
      "command": "python",
      "args": ["server.py"]
    }
  }
}
```

验证服务器是否被识别：

```bash
claude mcp list
claude mcp get file-ops
```

`mcp get` 的输出里应能看到四个工具，且每个工具都带着你声明的 `annotations`。

### 步骤 4：在 Claude Code 中观察行为差异

进入交互会话，依次让模型调用四类工具：

1. **`read_text`**：因为它标了 `readOnlyHint: true`，确认弹窗最轻，部分权限模式下可能直接放行。
2. **`append_text`**：非只读但只追加，确认强度中等。
3. **`set_kv`**：幂等写入，客户端知道重试无害。
4. **`delete_file`**：标了 `destructiveHint: true`，确认最重，通常会高亮"不可逆"提示。

需要强调边界：注解**调整的是确认 UX 的呈现与默认姿态，并不绕过权限模式本身**。你仍可在 `.claude/settings.json` 用显式的 allow/deny 规则覆盖默认，例如对 `delete_file` 永远要求确认、对 `read_text` 直接放行。注解与权限规则是两层：注解提供风险线索，规则给出最终裁决。

### 常见问题与解决

- **Q：我把只读工具标了 `readOnlyHint: true`，为什么还是每次都让我确认？**
  A：检查权限模式。在默认/计划模式下即便只读也可能确认；可对该工具加 allow 规则或切到更宽松的模式。注解只是"线索"，不等于"放行令"。

- **Q：`idempotentHint` 和 `readOnlyHint` 同时设会怎样？**
  A：规范规定 `idempotentHint` 与 `destructiveHint` 仅在 `readOnlyHint == false` 时有意义。只读工具不必纠结幂等，设了也会被忽略。

- **Q：注解写错了（比如把删文件标成只读）会怎样？**
  A：规范不校验真实性——它本就是 hint。写错只会误导客户端，给用户带来虚假的安全感。所以注解必须**如实**，这是服务器作者的责任，不是协议能保证的。

- **Q：FastMCP 里能直接传 dict 吗？**
  A：可以。新版本支持 `annotations={"readOnlyHint": True, "openWorldHint": False}`，但用 `ToolAnnotations(...)` 对象更利于 IDE 类型提示，推荐后者。

## 五、完整可运行示例

上面 `server.py` 即是完整示例。再加一个最小调用验证脚本 `probe.py`，在不上 Claude Code 的情况下直接验证工具与注解是否正确暴露：

```python
import asyncio
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client

async def main():
    params = StdioServerParameters(command="python", args=["server.py"])
    async with stdio_client(params) as (read, write):
        async with ClientSession(read, write) as session:
            await session.initialize()
            tools = await session.list_tools()
            for t in tools.tools:
                ann = t.annotations or {}
                print(f"{t.name:12} ro={ann.get('readOnlyHint')} "
                      f"destr={ann.get('destructiveHint')} "
                      f"idem={ann.get('idempotentHint')} "
                      f"open={ann.get('openWorldHint')}")

asyncio.run(main())
```

预期输出：

```
read_text    ro=True destr=None idem=None open=False
append_text  ro=False destr=False idem=False open=False
set_kv       ro=False destr=False idem=True  open=False
delete_file  ro=False destr=True  idem=True  open=False
```

（`read_text` 的 `destructiveHint`/`idempotentHint` 显示为 `None`，是因为只读时它们无意义，可省略——FastMCP 会用规范默认值补齐。）

## 六、工作流程图

下图展示注解从声明到影响行为的完整链路：服务器在 `tools/list` 暴露注解 → 客户端按组合做"前置确认"判断、按 `openWorldHint` 做"输出信任"判断 → 结合权限规则给出最终确认姿态 → 执行 →（若开放世界）输出按不可信处理。

```mermaid
flowchart TD
    A["服务器声明 ToolAnnotations<br/>title + 4 个 hint"] --> B["tools/list 返回给客户端"]
    B --> C{"客户端读取注解<br/>(默认: 非只读/破坏/非幂等/开放)"}
    C --> D{"前置确认三问<br/>readOnly? destructive? idempotent?"}
    C --> E{"输出信任一问<br/>openWorld?"}
    D -->|只读| F["轻确认 / 可放行"]
    D -->|追加写| G["中等确认"]
    D -->|破坏写| H["重确认, 高亮不可逆"]
    D -->|幂等| I["失败可安全重试"]
    E -->|封闭| J["输出可相对信任"]
    E -->|开放世界| K["输出视为不可信内容"]
    F --> L{"叠加权限规则<br/>settings.json allow/deny"}
    G --> L
    H --> L
    I --> L
    L --> M["最终裁决: 执行或拒绝"]
    M --> N["调用工具"]
    K -.影响.-> N
```

## 七、延伸阅读

- **MCP 官方规范 - Tools（2025-06-18）**：注解字段的权威来源，含"来自不可信服务器不得据此决策"的警告。
- **MCP 官方博客《Tool Annotations as Risk Vocabulary》（2026-03-16）**：提出"Lethal Trifecta 组合"与"注解能做/不能做"框架，是理解设计意图的最佳读物。
- **MCP schema.json（2025-06-18）**：`ToolAnnotations` 的精确描述与默认值，比文档更"源真"。
- **Claude Code 文档 - Connect Claude Code to tools via MCP**：`.mcp.json` 项目级配置、作用域与权限交互。
- 已有五个 SEP（规范增强提案）在讨论新增注解，关注 modelcontextprotocol 仓库可跟踪后续演进。

## 八、训练营适用性评估

- **适用训练营类型**：MCP 服务器开发进阶营、AI Agent 安全训练营、企业级工具集成实战营。不适合零基础——需先掌握至少一种 MCP 服务器写法。
- **预计教学时长**：1.5–2 小时。其中概念（字段、默认值、组合）约 40 分钟，动手构建并接入 Claude Code 观察差异约 60 分钟，边界讨论（不可信服务器、权限规则叠加）约 20 分钟。
- **核心学习产出**：能独立为任意 MCP 工具按"四种风险画像"填写准确注解，并解释每个标记如何影响客户端确认行为与输出信任判断。
- **延伸练习建议**：把一个已有的联网搜索工具正确标注为 `openWorldHint: true`，并讨论它的输出在何种场景下应被视为不可信输入——这是把"风险词汇"落到安全工程的最好练习。

## 参考来源

1. [Tools — Model Context Protocol (2025-06-18)](https://modelcontextprotocol.io/specification/2025-06-18/server/tools) — 官方规范，定义工具与 ToolAnnotations 字段，含"不可信服务器不得据此决策"的警告。
2. [Tool Annotations as Risk Vocabulary: What Hints Can and Can't Do — MCP Blog](https://blog.modelcontextprotocol.io/posts/2026-03-16-tool-annotations/) — 官方博客，提出 Lethal Trifecta 组合与"注解能/不能做"的评估框架（2026-03-16）。
3. [ToolAnnotations schema — MCP 2025-06-18 schema.json](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/2025-06-18/schema.json) — 字段定义与默认值的权威源文件（readOnly=false / destructive=true / idempotent=false / openWorld=true）。
4. [Connect Claude Code to tools via MCP — Claude Code Docs](https://code.claude.com/docs/en/mcp) — `claude mcp add`、`.mcp.json` 项目级配置与作用域说明。
5. [MCP Tool Annotations: Adding Metadata and Context to Your AI Tools](https://blog.marcnuri.com/mcp-tool-annotations-introduction) — 第三方实战解读，补充 FastMCP 中传 dict 与对象的用法对比。

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