当你的 MCP 工具被 Claude Code、Cursor、Codex CLI 等不同客户端调用时,客户端需要回答一个关键问题:这个工具该不该弹确认框?能不能自动执行?失败后能不能重试? 仅靠工具的 name 和 description 无法回答这些——这正是工具注解(Tool Annotations)要解决的。本教程带你用官方 Python SDK 内置的 FastMCP 模块,从零构建一个"日志服务器",为每个工具标注正确的风险元数据,再用官方 MCP Inspector 可视化验证注解是否生效。
适用人群:已经能写基本 MCP 工具、想让自己的工具被多客户端安全调用的开发者;以及需要把控"哪些操作可自动放行、哪些必须人工确认"的工程负责人。
uv(推荐)或 pipnpx 即可,无需全局安装)claude --version 可用)一个模型发现工具时,看到的三样东西是:工具名、描述、入参 schema。但这回答不了真正重要的问题:
没有这些信息,客户端只能做最保守的假设:把每个工具都当成"破坏性、不可重试、连外网"来对待。结果是——每次调用都弹确认框,体验极差;或者干脆不允许自动执行。
工具注解就是 MCP 给出的标准化"风险词典"。它是在 2025-03-26 规范修订中正式引入的,让服务器用一个结构化的 annotations 字段主动声明工具的行为属性。
关键认知:注解是 hint(提示)而非 contract(契约)。规范明确写道——除非来自可信服务器,否则客户端必须把注解视为不可信。这意味着注解影响的是交互体验(要不要确认、要不要高亮、要不要重试),而不是安全强制。一个恶意/有 bug 的服务器完全可以把删除工具标记成
readOnlyHint: true来骗过确认框。所以注解是用来优化 UX 的,真正的安全边界仍要靠权限沙箱和可信来源管理。
很多人不知道:官方 Python SDK(pip install mcp)内部就自带了一个 fastmcp 高级模块,路径是 mcp.server.fastmcp。它用装饰器风格写工具,几行就能跑起来,不必单独装第三方 FastMCP 包。
新建项目目录并安装依赖:
mkdir log-mcp && cd log-mcp
uv init --python 3.11
uv add "mcp[cli]"
创建 server.py:
# 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 传输下它由宿主驱动):
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,为"搜索"和"删除"两个工具标注截然不同的风险画像:
# 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。直接写驼峰会报错。ToolAnnotations 来自 mcp.types(官方 SDK)。如果你用的是第三方 fastmcp 包,导入路径是 from fastmcp.tools import ToolAnnotations,字段名一致。写完注解,怎么确认它真的发出去了、字段值对不对?靠肉眼读源码不算数——要看线上序列化后的 JSON-RPC 响应。官方 MCP Inspector 就是这个用途的交互式调试器。
在项目目录下,用 npx 直接拉起 Inspector 并指向你的服务器(无需全局安装):
# 用 uv 运行你的 Python 服务器
npx @modelcontextprotocol/inspector uv run server.py
# 或直接用 python
npx @modelcontextprotocol/inspector python server.py
Inspector 启动后会打印两个地址:
http://127.0.0.1:6274(在浏览器打开)127.0.0.1:6277(Inspector 代理,转发到你的 stdio 服务器)浏览器打开 6274 后:
server.py 完成 initialize 握手;search_logs 和 purge_logs;search_logs,在右侧详情里能看到完整的 annotations 块;keyword=login,点 Run Tool,下方直接看返回。search_logs 的注解块应该是:
"annotations": {
"title": "搜索日志",
"readOnlyHint": true,
"destructiveHint": false,
"idempotentHint": true,
"openWorldHint": false
}
purge_logs 则是 destructiveHint: true、read_only_hint 为 false。如果 Inspector 里看到的是这个 camelCase 结构且值正确,说明注解已正确序列化。如果 annotations 字段根本不存在,说明注解没传进去——回到第四步检查导入路径和拼写。
CLIENT_PORT=6280 npx @modelcontextprotocol/inspector ...。uv run server.py 单独在终端跑一遍,看是否有 Python 报错。list_tools 没返回。确认每个被 @mcp.tool 装饰的函数都有类型注解和 docstring。mcp.run(transport="streamable-http") 暴露的 URL。注解验证无误后,把它接进真实客户端,对比"只读工具"和"破坏性工具"在交互上的差异。
用 CLI 注册(推荐用绝对路径,避免工作目录漂移):
claude mcp add log-explorer -- uv run /abs/path/log-mcp/server.py
或在项目根放 .mcp.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 验证 → 客户端落地。
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"的约束措辞。modelcontextprotocol.io/docs/developer-tools/inspector,含端口配置、HTTP 传输调试、日志级别切换。github.com/modelcontextprotocol/python-sdk,mcp.server.fastmcp 模块与 mcp.types.ToolAnnotations 的源码定义。openWorldHint 正是用于识别这类组合。readOnlyHint=true 但实际会写文件的"撒谎工具",讨论客户端为何不能盲信 hint,引出权限沙箱与可信来源管理的必要性。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 等场景全覆盖。
