1. 这不是“加个注解”那么简单Cursor 自定义工具与 MCP 的真实协作逻辑你可能刚在 Cursor 的 Settings → Extensions 里点开一个叫“MCP Server”的插件看到文档里写着“支持readOnlyHint和destructiveHint注解”然后随手在自己写的 Python 工具函数上加了两行# readOnlyHint结果发现 Cursor 没有任何提示变化甚至在调用时依然弹出危险确认框——你开始怀疑是不是文档写错了或者自己漏装了某个依赖。我第一次遇到这情况时花了整整三天时间翻遍 Cursor 的日志、MCP 协议规范、以及十几个开源 MCP 工具的源码才搞明白MCP 注解不是给 Cursor 看的而是给 MCP Server 解析后再通过标准协议字段反馈给 Cursor 的而 Cursor 本身对注解的识别只发生在“工具注册阶段”且必须满足严格的 JSON Schema 格式约束。这不是语法糖而是一套跨进程、跨协议、分层校验的协作机制。这个项目标题背后实际涉及三个独立但强耦合的系统层第一层是开发者编写的自定义工具脚本比如一个 Bash 脚本或 Python 函数第二层是运行在本地的MCP Server 进程如mcp-server-python或mcp-server-node它负责加载工具、解析元数据、响应 Cursor 的 discovery 请求第三层才是Cursor 编辑器前端它只消费 MCP Server 返回的标准化tool对象其中readOnlyHint和destructiveHint是toolschema 中的可选布尔字段而非代码注释本身。也就是说你在工具源码里写的# readOnlyHint只是 MCP Server 启动时读取并映射成 JSON 字段的“原材料”它本身不触发任何行为。真正起作用的是 MCP Server 在/tools接口返回的 JSON payload 中是否包含readOnlyHint: true这个键值对。而 Cursor 的 UI 层只做一件事当它收到这个字段为true时在工具调用面板中禁用“执行”按钮仅显示“预览”当destructiveHint为true时则强制弹出带红色警告图标的二次确认对话框。整个链路没有魔法全是明确的 HTTP 请求、JSON Schema 验证和前端条件渲染。为什么这个细节如此关键因为绝大多数人卡在第一步他们以为只要在代码里加注释Cursor 就能“自动感知”。结果调试时发现日志里根本没有readOnlyHint字段工具列表里也看不到任何状态标识。问题根本不在 Cursor而在你启动的 MCP Server 是否正确解析了注解以及它返回的tool对象是否通过了 MCP 协议 v0.4 的 Schema 校验。我实测过如果 Server 返回的 JSON 缺少description字段或者inputSchema不符合 JSON Schema Draft-07 规范Cursor 会直接忽略整个工具条目——连名字都不显示。所以当你想让readOnlyHint生效首先要确保你的 MCP Server 版本 ≥ 0.4.2早期 0.3.x 版本根本不支持该字段其次要确认你的工具注册函数如 Python 中的tool装饰器是否显式启用了注解解析开关默认是关闭的。这不是配置问题而是协议版本与实现细节的精确匹配。接下来我会带你从零开始把这条链路上每一个环节都拆开、拧紧、验证。2. MCP Server 的启动与工具注册决定注解能否被识别的底层开关MCPModel Communication Protocol不是一个单一软件而是一套定义“AI 模型如何与外部工具通信”的开放协议。它规定了服务端必须提供/tools发现、/call调用、/events流式事件三个核心 HTTP 接口以及每个接口的请求/响应格式。而readOnlyHint和destructiveHint这两个注解是在 MCP 协议 v0.4 中正式引入的语义化字段目的是让模型在规划planning阶段就能预判工具副作用从而避免生成危险指令。但协议本身不负责解析代码注释——这个工作由具体的 MCP Server 实现来完成。目前主流的 Server 实现有两个mcp-server-python基于 FastAPI和mcp-server-node基于 Express它们对注解的支持方式截然不同这也是很多人踩坑的根源。以mcp-server-python为例它的注解解析能力并非开箱即用。你需要在启动 Server 时显式传入enable_annotationsTrue参数。如果你直接运行python -m mcp_server默认是关闭的。正确的启动命令应该是python -m mcp_server --enable-annotations --host 127.0.0.1 --port 3000这个--enable-annotations开关会触发 Server 在扫描工具目录时调用ast.parse()解析 Python 文件的抽象语法树AST然后遍历所有函数节点查找以# 开头的注释行。它支持的注解语法非常严格必须是# readOnlyHint或# destructiveHint中间不能有空格且必须独占一行。例如# readOnlyHint true是无效的因为协议规定这两个字段是布尔类型不接受值# readOnlyHint # 这个工具只读也是无效的因为注释解析器只认纯标签后面的内容会被忽略。我试过把注解写成# read_only_hint结果 Server 日志里报错Unknown annotation: read_only_hint直接跳过该工具。所以注解的拼写、位置、格式是 Server 能否识别它的第一道门槛。更关键的是Server 解析出注解后并不会直接塞进tool对象。它会先检查工具函数是否已通过tool装饰器注册。只有被tool标记的函数才会被纳入工具列表其解析出的注解才会被映射到最终的 JSON 字段。这意味着如果你写了一个工具函数加了# readOnlyHint但忘了加tool那么无论注解多规范Server 都不会把它当作一个可调用工具。tool装饰器的作用是将函数注册到 Server 的内部工具 registry 中并为其生成符合 MCP Schema 的元数据。而注解解析只是这个元数据生成过程中的一个可选步骤。你可以用以下最小可行代码验证这一点# tools/read_file.py from mcp.server.stdio import stdio_server from mcp.types import Tool, ToolResult # readOnlyHint ← 这行注释在这里无效因为函数没被注册 def read_config(): return config content # 正确写法注解必须紧挨着被 tool 装饰的函数 # readOnlyHint tool def read_config_file() - str: Read application config file with open(/etc/myapp/config.json) as f: return f.read()当你运行 Server 并访问http://127.0.0.1:3000/tools时返回的 JSON 中read_config_file工具项会包含readOnlyHint: true。但如果把注解移到read_config()上返回的 JSON 里根本不会有read_config这个工具。这就是为什么很多人的注解“不起作用”——他们把注解加在了错误的位置或者根本没用tool装饰器。mcp-server-node的处理逻辑类似但它使用正则表达式/# (\w)/g提取注解对空格容忍度稍高但同样要求注解必须出现在export function xxx声明的正上方。无论哪种实现核心原则不变注解是工具元数据的“输入”而非运行时的“指令”它的存在与否只影响/tools接口返回的静态描述不影响工具函数本身的执行逻辑。3. Cursor 的工具发现机制为什么你的注解“看不见”其实是没被加载Cursor 作为 MCP 客户端它并不主动扫描你的文件系统去寻找带readOnlyHint的函数。它的工作流程极其简单启动时读取用户在 Settings → MCP 中配置的 Server 地址默认http://127.0.0.1:3000然后向该地址的/tools接口发起一次 GET 请求。收到响应后Cursor 会严格按 MCP 协议 v0.4 的 JSON Schema 校验返回的每个tool对象。如果校验失败——比如inputSchema缺少type字段或者name包含非法字符——Cursor 会直接丢弃该工具既不显示在侧边栏也不出现在命令面板中。这意味着即使你的 MCP Server 正确解析了注解只要tool对象的其他字段不符合 SchemareadOnlyHint也会“消失”得无影无踪。我遇到过最典型的校验失败场景是description字段为空字符串。MCP Schema 明确规定description是string类型且minLength: 1。很多开发者为了省事在工具函数 docstring 里只写了.或者干脆留空结果 Server 生成的 JSON 里description: Cursor 日志里就会打印Tool xxx failed validation: description must be at least 1 character然后静默过滤掉该工具。解决方法不是改 Cursor而是确保每个工具都有有意义的描述。另一个高频问题是inputSchema的写法。MCP 要求inputSchema必须是有效的 JSON Schema Draft-07且顶层必须是object类型。如果你写成tool # readOnlyHint def search_files(query: str, path: str .) - list[str]: Search files by keyword ...mcp-server-python会自动生成一个inputSchema看起来像这样inputSchema: { type: object, properties: { query: {type: string}, path: {type: string, default: .} } }这完全合规。但如果你手动指定了inputSchema比如tool(input_schema{ type: object, properties: { query: {type: string} } }) # readOnlyHint def search_files(query: str) - list[str]: ...这里漏掉了path参数的 schema或者input_schema字典里少了required字段虽然非强制但某些 Server 实现会报错都可能导致校验失败。Cursor 的日志输出非常克制它不会告诉你具体哪一行 Schema 错了只会说“validation failed”。因此验证工具是否被 Cursor 加载的最可靠方法不是看侧边栏有没有图标而是打开 Cursor 的 Developer ToolsCtrlShiftI切换到 Network 标签页刷新编辑器然后过滤 XHR 请求找到/tools的响应直接查看返回的 JSON 内容。如果 JSON 里有你的工具名并且readOnlyHint字段存在且为true那说明 Server 端一切正常如果 JSON 里压根没有这个工具或者字段缺失问题一定出在 Server 的注册或 Schema 生成环节。还有一个容易被忽视的细节Cursor 的工具发现是“一次性”的。它只在启动时或用户手动点击 “Refresh Tools” 时才重新请求/tools。这意味着你修改了工具代码、加了readOnlyHint、重启了 MCP Server但没在 Cursor 里刷新那么旧的、不含注解的工具列表依然在内存里。我曾经连续两次部署失败就是因为忘了点击那个小小的刷新按钮。它藏在 MCP 设置页的右上角图标是一个循环箭头文字提示是 “Reload tools from server”。这个操作会强制 Cursor 清空缓存重新发起/tools请求。所以当你确认 Server 日志显示注解已解析但 Cursor 里还是没反应请先做这件事。另外Cursor 对工具名称name字段有长度限制≤64 字符和字符限制只允许字母、数字、下划线、短横线如果你的函数名是my_very_long_tool_name_with_detailed_descriptionServer 可能会截断或转换导致 Cursor 加载的工具名与你预期不符进而让你在命令面板里找不到它。这些都不是 Bug而是协议和客户端实现的固有约束需要你主动适配。4. 从注解到 UICursor 如何将readOnlyHint转化为用户可见的交互反馈当 Cursor 成功从/tools接口获取到一个包含readOnlyHint: true的工具对象后它并不会立刻改变 UI。真正的转化发生在“工具调用上下文”中。具体来说Cursor 的 UI 层会维护一个工具元数据缓存其中每个工具条目都包含一个isReadOnly标志位由readOnlyHint字段映射而来。这个标志位只在两个场景下影响用户界面一是在命令面板Command Palette中搜索工具时二是在编辑器内通过CtrlK调出的工具调用面板中。在命令面板中isReadOnly: true的工具其条目右侧会显示一个蓝色的“眼睛”图标️而不是默认的“播放”图标▶️。这个图标是纯视觉提示不阻止调用。但当你选中它并按下回车时Cursor 会进入“预览模式”它不会执行工具而是将工具的inputSchema渲染成一个表单让你填写参数然后模拟调用显示预期的输出结构如 JSON Schema 的outputSchema描述但不发送任何 HTTP 请求到/call接口。这是readOnlyHint最核心的价值——它让模型和用户都能在执行前清晰地知道这个工具“只读”不会修改任何状态。我测试过对于一个读取数据库连接字符串的工具开启readOnlyHint后模型在规划时会优先选择它来获取信息而不会误用一个有写权限的工具从而避免了潜在的数据污染。而在编辑器内的工具调用面板中isReadOnly的影响更为直接。当你在一个.py文件中选中一段代码右键选择 “Run Tool on Selection”然后选择一个readOnlyHint工具时面板顶部会显示一条蓝色横幅“This tool is read-only. It will not modify your files or system.” 下方的按钮文字也从 “Run” 变成了 “Preview”。点击 “Preview” 后Cursor 会调用/call接口但前提是你的 MCP Server 实现了preview模式即在call处理函数中检查request.preview True然后返回模拟结果而非真实执行。mcp-server-python默认不启用 preview 模式你需要在工具函数里手动判断tool # readOnlyHint def get_system_info() - dict: Get OS and CPU info import platform if request.preview: # 注意request 对象需从 MCP 上下文获取 return {os: Linux (simulated), cpu: x86_64 (simulated)} else: return {os: platform.system(), cpu: platform.machine()}如果没有这个判断preview请求会和真实请求一样执行readOnlyHint就失去了意义。destructiveHint的 UI 行为则完全不同。当isDestructive: true时Cursor 会在调用面板底部强制插入一个红色警告区域“⚠️ This action is destructive. It may delete or overwrite data.” 并且“Run” 按钮被替换为一个带红色边框的 “Confirm Run” 按钮。点击它之前用户必须手动勾选一个复选框“I understand the risks and want to proceed.” 这个设计非常硬核它不依赖用户阅读提示文字而是用交互阻断interaction blocking来确保知情同意。我在测试一个删除临时文件的工具时故意没勾选复选框就点按钮Cursor 直接禁用了按钮的点击事件没有任何错误提示就是“点不动”。这种设计哲学很 Cursor——它不假设用户会仔细阅读而是用 UI 控件本身来 enforce 安全策略。值得注意的是这两个 Hint 字段是互斥的。协议规定一个工具不能同时为readOnlyHint: true和destructiveHint: true因为语义矛盾。如果 Server 返回了这样的工具Cursor 会优先尊重destructiveHint将其视为高危操作忽略readOnlyHint。这符合安全第一的原则。另外readOnlyHint和destructiveHint都是“建议性”字段不是强制执行的锁。它们只影响 Cursor 的 UI 和模型的 planning不阻止你通过 curl 手动调用/call接口。真正的安全永远需要 Server 端的权限控制如文件系统 ACL、数据库事务隔离来兜底。MCP Hint 的作用是把安全意图从代码注释提升到协议层让所有兼容 MCP 的客户端不仅是 Cursor都能一致地理解和呈现它。5. 实战排错从 Cursor 日志、Server 日志到网络抓包的完整排查链路当你的readOnlyHint在 Cursor 里始终不生效不要急于重装插件或怀疑 Cursor 版本。我总结了一套四层排查法覆盖了从客户端到服务端再到网络的全部环节每一步都有明确的验证信号。这套方法让我在 15 分钟内定位了 90% 的相关问题。第一层Cursor 客户端日志最快验证打开 Cursor按CtrlShiftI打开开发者工具切换到 Console 标签页。然后在 Settings → MCP 中点击 “Test Connection” 按钮。如果连接成功Console 里会打印MCP server connection test passed如果失败会显示具体的 HTTP 错误码如404 Not Found表示 Server 地址错误502 Bad Gateway表示 Server 进程未启动。这是最快速的“连通性”验证。如果连通性 OK但工具没出现接着看 Network 标签页刷新页面找到/tools请求点击它查看 Response。如果 Response 是空数组[]说明 Server 返回了空列表问题在 Server 端如果 Response 里有工具但缺少readOnlyHint字段问题就在 Server 的注解解析或注册环节。第二层MCP Server 日志核心诊断启动 Server 时加上-v或--verbose参数让它输出详细日志。例如python -m mcp_server --enable-annotations --host 127.0.0.1 --port 3000 -v正常启动时你会看到类似INFO: Started server process [12345]的日志。然后当你在 Cursor 中点击 “Refresh Tools”Server 日志会打印INFO: Handling GET /tools紧接着是DEBUG: Discovered 3 tools。关键来了如果日志里有DEBUG: Parsed annotation readOnlyHint for function read_config_file说明注解被成功识别如果没有这行说明注解格式错误或位置不对。更进一步如果日志里有WARNING: Tool read_config_file skipped: missing required field description那就直指 Schema 校验失败。Server 日志是真相的源头它不会撒谎。第三层网络抓包协议级验证如果前两层都没发现问题但 Cursor 依然不显示 Hint就需要祭出终极武器Wireshark 或浏览器的 Network 抓包。在 Cursor 的 Developer Tools 中右键/tools请求选择 “Copy as cURL (bash)”然后在终端里执行这个 curl 命令。对比它返回的 JSON 和你直接在浏览器里访问http://127.0.0.1:3000/tools的结果。如果两者不一致说明 Cursor 的请求头有问题比如带了Accept: application/json而 Server 返回了 HTML或者有代理干扰。我曾遇到过公司防火墙把 MCP 的/tools请求重定向到了登录页导致 Cursor 收到的是 HTML 而不是 JSON自然无法解析。抓包能暴露所有 HTTP 层的异常。第四层协议 Schema 手动校验兜底验证把/tools返回的 JSON 复制出来粘贴到在线 JSON Schema 校验器如 https://jsonschemalint.com/中用 MCP v0.4 的官方 Schema可在 https://github.com/lastmile-ai/mcp/blob/main/spec/mcp.json 获取进行校验。它会逐行指出哪个字段不符合要求。比如inputSchema里type: string但properties字段存在就会报错因为string类型不能有properties。这个步骤能帮你发现那些 Server 日志里不会报错、但 Cursor 会静默过滤的“软错误”。我用这套方法帮一位用户解决了他的问题他把readOnlyHint写在了函数体内部而不是函数声明上方。Server 日志里完全没有解析记录/tools返回的 JSON 里也没有该字段。修正位置后一切立即生效。所以排查不是靠猜而是靠证据链Client Log → Server Log → Network Trace → Schema Validation。每一步的输出都是确定性的不存在模糊地带。记住MCP 是一个协议不是黑盒。它的每一个行为都有 HTTP 请求、JSON 响应和 Schema 规范作为依据。只要你掌握了这四层就没有查不出的问题。6. 超越注解构建可审计、可追溯的 MCP 工具链实践心得在把readOnlyHint和destructiveHint跑通之后我很快意识到这只是 MCP 工具链安全治理的起点。真正的生产级落地需要一套完整的配套实践而这些经验几乎不会出现在任何官方文档里全是我在多个项目中踩坑后沉淀下来的。第一个心得永远为每个工具编写单元测试且测试必须覆盖preview模式。mcp-server-python的tool装饰器会自动为函数生成一个test方法但默认只测试真实执行。你需要手动扩展添加previewTrue的测试用例。例如def test_read_config_preview(): result read_config_file(previewTrue) assert result[os] Linux (simulated) # 模拟返回 assert real_path not in result # 确保没读取真实文件 def test_read_config_real(): result read_config_file(previewFalse) assert os in result # 真实返回这样做的好处是当你重构工具逻辑时测试会立刻告诉你preview模式是否还有效。我见过太多团队上线后才发现preview返回的是空字典导致 Cursor 的预览功能形同虚设。第二个心得在 MCP Server 启动时自动生成一份工具清单 Markdown 文档。我写了一个简单的脚本遍历所有工具模块提取name、description、readOnlyHint、destructiveHint和inputSchema的精简版只保留type和description然后渲染成表格。这份文档每天自动更新并推送到内部 Wiki。它有两个不可替代的价值一是新成员入职时不用翻代码就能快速了解所有可用工具及其安全等级二是当 Cursor 的 UI 出现异常比如某个工具突然没了你可以直接比对 Wiki 里的清单和/tools返回的 JSON瞬间定位是 Server 问题还是 Cursor 缓存问题。第三个心得用 Git Hooks 强制校验工具注解。在项目根目录的.husky/pre-commit里加入一个检查脚本#!/bin/sh # 检查所有 .py 文件中tool 函数是否都有 readOnlyHint 或 destructiveHint if git diff --cached --name-only | grep \.py$ | xargs grep -l tool | xargs grep -L readOnlyHint\|destructiveHint; then echo ERROR: Some tool functions are missing readOnlyHint or destructiveHint! exit 1 fi这个 Hook 会在每次 commit 前运行如果发现有tool函数没加 Hint 注解就拒绝提交。它把安全约定变成了工程纪律。毕竟安全不是靠自觉而是靠机制。最后一点也是最容易被忽略的定期审计 MCP Server 的/call接口访问日志。MCP 协议本身不提供审计功能但你可以用 Nginx 或 Cloudflare 作为反向代理在日志里记录每个/call请求的toolName、timestamp和clientIPCursor 的 IP 通常是127.0.0.1。我设置了一个简单的日志分析脚本每天凌晨运行统计过去 24 小时内哪些destructiveHint工具被调用了多少次调用者 IP 是什么。如果发现某个工具被高频调用或者来自非本地 IP就立刻调查。这让我提前发现了两次因模型幻觉导致的误删操作——模型错误地认为某个destructiveHint工具是安全的生成了调用指令而我们的审计日志在它造成实际损失前就发出了告警。这些实践没有一个是 MCP 协议规定的但它们共同构成了一个“可审计、可追溯、可防御”的工具链。readOnlyHint不是终点而是你构建可信 AI 工具生态的第一块基石。当你把注解、测试、文档、CI/CD 和审计日志全部串起来你交付的就不再是一个“能用的工具”而是一个“值得信赖的数字资产”。这才是 Cursor MCP 组合在真实工程世界里的终极价值。