首页 › tutorials › 给MCP工具挂四类行为注解

概述

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

前置条件

一、为什么需要工具注解

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

工具注解就是给这个黑盒贴上一组"风险词汇"。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 回答的是同一个调用前的问题:客户端要不要在调用这个工具前请求确认?

2.2 一个"输出信任"标记

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

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

2.3 反直觉的默认值

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

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

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

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

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

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

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

步骤 1:初始化项目

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

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

新建 server.py,注释保留英文以便模型理解:

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 注册(推荐项目级作用域,便于随仓库共享):

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

这会在项目根写入 .mcp.json,等价于:

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

验证服务器是否被识别:

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 直接放行。注解与权限规则是两层:注解提供风险线索,规则给出最终裁决。

常见问题与解决

五、完整可运行示例

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

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 做"输出信任"判断 → 结合权限规则给出最终确认姿态 → 执行 →(若开放世界)输出按不可信处理。

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

七、延伸阅读

八、训练营适用性评估

参考来源

  1. Tools — Model Context Protocol (2025-06-18) — 官方规范,定义工具与 ToolAnnotations 字段,含"不可信服务器不得据此决策"的警告。
  2. Tool Annotations as Risk Vocabulary: What Hints Can and Can't Do — MCP Blog — 官方博客,提出 Lethal Trifecta 组合与"注解能/不能做"的评估框架(2026-03-16)。
  3. ToolAnnotations schema — MCP 2025-06-18 schema.json — 字段定义与默认值的权威源文件(readOnly=false / destructive=true / idempotent=false / openWorld=true)。
  4. Connect Claude Code to tools via MCP — Claude Code Docs — claude mcp add、.mcp.json 项目级配置与作用域说明。
  5. MCP Tool Annotations: Adding Metadata and Context to Your AI Tools — 第三方实战解读,补充 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学通信知识星球海报-扫码加入