本教程聚焦 MCP(Model Context Protocol)工具注解(Tool Annotations)这一组常被忽略、却直接决定客户端如何对待你的工具的语义标记。你会学到 title、readOnlyHint、destructiveHint、idempotentHint、openWorldHint 五个字段的精确含义与反直觉的默认值,并用 Python FastMCP 构建一个覆盖四种风险画像的文件操作服务器,接入 Claude Code 观察确认行为的差异。适合已经写过至少一个 MCP 服务器、想让模型和客户端"更正确地使用自己工具"的开发者。
claude mcp add 添加本地服务器。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,交互域是封闭的(如内存字典)。 |
readOnlyHint、destructiveHint、idempotentHint 回答的是同一个调用前的问题:客户端要不要在调用这个工具前请求确认?
readOnlyHint: true):安全观察,通常无需确认或仅轻量确认。readOnlyHint: false, destructiveHint: false):新增数据但不覆盖,风险中等。destructiveHint: true):可能不可逆,应重确认。idempotentHint: true):失败后可放心重试,不必担心重复执行造成二次损害。openWorldHint 与前三个不同。它回答的不是"调用前要不要确认",而是"调用后返回的内容可不可信"。官方原话:它"关心工具触及的范围以及它的输出可能带回什么",并且"在调用后和调用前一样重要"。规范建议:把工具认为"外部"的任何东西都视为潜在不可信内容的来源。
这条标记对部署上下文高度敏感——"外部"在公司服务器上可能指"公司网络之外",在你笔记本上可能指"本机之外"。所以最稳妥的姿势是:凡是被标记为开放世界的工具,其输出都应被当作不可信输入处理。
最容易踩坑的是默认值。一个什么注解都不写的工具,会被保守地假定为:非只读、可能破坏性、非幂等、开放世界。 也就是说,默认就是最危险的画像。这看起来很"冤",但设计是有意的——在缺乏信息时,客户端必须假设最坏情况,逼着有心的服务器作者主动声明 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 里直观对比确认行为差异。
mkdir annotated-file-ops && cd annotated-file-ops
python -m venv .venv && source .venv/bin/activate
pip install "mcp[cli]"
新建 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()
在项目目录用 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。
进入交互会话,依次让模型调用四类工具:
read_text:因为它标了 readOnlyHint: true,确认弹窗最轻,部分权限模式下可能直接放行。append_text:非只读但只追加,确认强度中等。set_kv:幂等写入,客户端知道重试无害。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 的情况下直接验证工具与注解是否正确暴露:
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
ToolAnnotations 的精确描述与默认值,比文档更"源真"。.mcp.json 项目级配置、作用域与权限交互。openWorldHint: true,并讨论它的输出在何种场景下应被视为不可信输入——这是把"风险词汇"落到安全工程的最好练习。claude mcp add、.mcp.json 项目级配置与作用域说明。[!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 等场景全覆盖。
