# MCP 工具注释与审批边界

MCP 工具注释是很有用的文档，也很容易让不安全的审批系统看起来井然有序。如果客户端把 `readOnlyHint`、`destructiveHint` 或 `idempotentHint` 当成授权依据，服务器作者实际上就替用户写好了审批政策，却没有证明实现真的配得上这种信任。

这完全颠倒了顺序。注释可以帮助解释提示内容、整理工具列表，或为人工审核操作的人建议一个合理默认值。审批必须依据服务器将要执行的请求、使用的凭据、触达的目标，以及可能触发的副作用来决定。我见过太多集成调用名为 `get` 的端点，返回一个客气的 JSON 对象，却仍然在别处创建了任务。

代理场景下，这个区别尤其重要，因为代理会重试、组合工具，而且行动速度很快，一个小小的分类错误也可能带来高昂代价。某个工具单独调用一次是安全的，放进循环里就可能不安全。某个工具对一个 API 是只读的，对另一个 API 却可能成为数据导出通道。某个工具看起来具备幂等性，但超时掩盖第一次成功后，重试可能创建重复任务。

## 注释描述行为，但不授予权限

Model Context Protocol 工具规范把注释描述为关于工具行为的提示。这个措辞是有意为之：客户端可以用它们改进界面，却不能安全地把未经验证的声明当成安全决策依据。

这三个字段分别描述不同的主张：

- `readOnlyHint: true` 表示工具不会修改环境。
- `destructiveHint: true` 表示工具可能执行破坏性更新。
- `idempotentHint: true` 表示使用相同参数重复调用，不会对环境产生额外影响。

这些主张没有覆盖调用的全部风险。某个工具可能读取整个客户数据库，把结果发送给代理，同时诚实地将自己标记为只读。另一个工具可能只写入访问时间，这听起来影响很小，直到这个时间改变了数据保留、计费或事件记录。幂等性并不能说明第一次产生的影响是否可以接受。

规范还为这些字段设置了保守的默认值。`readOnlyHint` 默认值为 false。`idempotentHint` 默认值为 false。`destructiveHint` 默认值为 true，而且只有在工具不是只读工具时才有实际意义。不要用自定义规则替换这些默认值，例如“缺少元数据就算足够安全”。缺少元数据往往意味着服务器作者没有认真考虑过分类问题。

还有一个让人不太舒服的事实：善意的服务器也可能出错。开发者看到处理程序执行了 `SELECT`，就添加 `readOnlyHint: true`，但随后某个库层刷新了令牌、写入缓存，或调用了请求钩子。行为发生变化很久之后，注释仍然保持不变。没有人打算欺骗客户端，但如果客户端据此自动批准操作，仍然会做出错误决定。

## 读取不代表结果无害

只读操作可能泄露数据、消耗稀缺资源，或激活远程服务中的某些行为。把“不会写入”理解成“不需要审批”，是概念上的错误。

考虑一个名为 `get_build_log`、接受任务 ID 的工具。服务器从构建系统读取日志并返回输出，它完全可以正确声明 `readOnlyHint: true`。但日志中可能包含源代码、环境信息、签名下载 URL，或其他系统意外打印出的凭据。即使构建系统的数据库没有任何变化，把响应返回给自主代理也改变了谁可以使用这些信息。

管理 API 也会出现同样的问题。`get_user` 可能返回恢复代码。`list_invoices` 可能暴露银行信息。代理增大分页大小，或遍历每个前缀时，`search_documents` 可能变成批量提取工具。这里产生的副作用是信息披露，而注释中没有用于描述披露敏感度的字段。

读取操作还可能改变远程服务。有些 API 会更新 `last_accessed_at`、消耗一次性下载令牌、登记预览，或发起计量查询。缓存未命中可能会唤醒成本高昂的下游服务。这些影响并不意味着每次读取都很危险，但它们足以让不加限定的只读审批规则站不住脚。

从两个独立维度对调用分类：它是否会修改系统，以及它可能在系统之外暴露或引发什么。低风险状态探测和批量导出都可能不修改数据，但不应接受相同的审批处理。

实用的审核记录应使用通俗语言写明数据边界。“读取项目 A 的部署状态”可以审核。“调用 `get_status`”无法审核。后者隐藏了目标、范围、账户，也没有说明另一个服务器上名称相近的方法可能代表不同含义。

## 在一次性目标上测试处理程序

只看工具名称或输入模式，无法证明注释是安全的。应在能够观察请求、响应以及调用前后状态的环境中运行服务器。

先准备一个包含可丢弃记录的测试账户。为它配置独立的 API 凭据，并将通知 webhook 路由到捕获端点。记录服务器发出的请求、数据库状态（如果你能控制数据库）、审计事件、电子邮件、排队任务，以及计费或使用量计数器。响应内容是证据，但不是完整记录。

对所有可能影响审批的工具，使用一组小型测试矩阵：

1. 使用普通的有效输入调用一次，保存完整的调用前后状态。
2. 使用逐字节完全相同的输入再次调用，比较每一项可观察影响。
3. 使用不存在的资源、已经完成的操作和无效字段进行调用。
4. 服务器收到请求后中断客户端，然后重试相同调用。
5. 如果代理可以并发发起调用，就同时运行两次相同调用。

超时场景可以捕获一种常见故障。假设 `create_ticket` 已经发送工单请求，但服务器返回前连接中断。代理看到错误后重试。如果工单系统没有幂等令牌，工具就会创建两张工单。处理程序的代码即使可以接受相同输入两次，也不能改变远程结果，不能因为这一点就把它标记为幂等。

记录一种迫使人们检查影响，而不是相信绿色响应的输出格式：

```text
case: retry after response timeout
request: {"title":"rotate staging certificate","request_id":"test-104"}
first call: transport timeout after request received
second call: 201 {"ticket":"842"}
remote records: ["841", "842"]
result: not idempotent without a remote idempotency mechanism
```

示例中的 `request_id` 只有在远程 API 保存并强制执行它时才有用。服务器完全忽略的客户端生成标识符只是装饰。要验证远程系统是否会返回原操作而不是创建新操作，请使用完全相同的标识符重复调用，并检查它是否真正执行了约束。

将这些测试和服务器一起维护。注释漂移通常来自代码变更、依赖更新或新端点。一个检查声明提示是否符合可观察行为的通过测试，比工具定义旁边的一条注释更有价值。

## 只读声明会在边界处失效

最容易出现的错误 `readOnlyHint`，来自只查看主数据库查询。真正重要的边界包括处理程序调用的每个服务，以及响应引发的每个动作。

假设服务器有一个获取文档的工具。它的主要请求是 `GET /documents/42`，但处理程序可能先交换刷新令牌、生成临时下载 URL、更新本地缓存，并写入访问事件。每个操作都可能以不同方式失败，也可能使用不同凭据并遵循不同的审计要求。

不要接受“写入太小，不算写入”的说法。小型写入也会带来自己的故障模式。最后查看标记可能影响数据保留。缓存可能在访问本应结束后继续保存内容。访问事件可能通知所有者。使用量计数器可能让账户进入付费层级。要问的是，这次写入是否改变了另一个人、进程或账单能够观察到的事实。如果改变了，就记录下来。

同样要审查由响应触发的行为。返回签名链接的工具可能让代理稍后获取该链接。返回可执行命令的工具可能导致代理在另一个通道中运行它。第一个工具从狭义上看仍然是只读的，但审批界面如果显示“安全读取”，就会让人错误理解代理接下来可以采取的动作。

当风险不同时，优秀的服务器会拆分操作。`get_document_metadata` 可以继续作为范围明确的检查调用。`create_download_link` 应该成为独立工具，因为它会创建一个持有者即可使用的能力，即使底层文档字节没有变化。这样的划分能帮助代理做出正确选择，也让审核者看到一项真正可以批准的操作说明。

## 破坏性取决于可逆性，而不是动词列表

`destructiveHint` 应反映调用是否可能造成难以逆转的有害更新，而不是工具名称中是否包含 `delete`。团队在这两方面都经常判断错误。

有些常见动词在一个系统中可以逆转，在另一个系统中却是永久性的。`archive` 可能只是隐藏记录，也可能启动清除计时器。`disable_user` 可能保留所有权限和文件，也可能撤销访问权限，让自动化流程陷入停滞。`replace_config` 可能只更新草稿，也可能立即触发生产部署。处理程序需要了解目标，而单个布尔值无法表达这些信息。

有些名称无害的工具其实明显具有破坏性。`sync_members` 可能删除不在提交列表中的账户。`apply_labels` 可能覆盖精心维护的分类体系。`reconcile` 可能通过任何人都不应轻率创建的日记账分录，修正外部账本。服务器作者如果因为 API 理论上可以撤销这些操作就把它们标记为非破坏性，实际上是在掩盖修复所需的运营成本。

把可逆性当作一个过程，而不是一个复选框。要问谁可以撤销结果、需要什么证据、撤销在多长时间内有效，以及在任何人有机会逆转前，后续流程是否会先消费这项变更。如果人工必须根据日志重建批量更新的意图，即使 API 提供了反向方法，这项操作也应归为破坏性操作。

一种常见但糟糕的建议是，只对明确的删除操作要求审批。它之所以受欢迎，是因为能让代理持续运行，也能让演示看起来流畅。但在生产环境中，破坏性变更通常以替换、撤销、发送或对账的形式出现。应审批真正的状态变化，而不是审批描述它时所用的词汇。

对于影响集合的操作，要求审核记录包含选择规则和数量。“同步用户”太模糊。“删除根据所提供 ID 选出的 14 名非活跃承包商”才能让人判断范围。如果服务器无法在执行前报告这个范围，就没有为客户端提供足够信息来生成严肃的审批提示。

## 幂等性必须经得住重试和并发

`idempotentHint` 是一个范围很窄的主张：第一次调用之后，使用完全相同的参数不应产生额外影响。它并不意味着调用安全、成本低、可以逆转，或适合让代理无限重复。

如果两次设置 `state=closed` 都只让同一条记录保持关闭状态，状态更新可以是幂等的。但如果处理程序每次都会发送电子邮件、追加审计评论或增加版本计数器，它就不再幂等。人们常常只检查数据库行，忽略了用户最先感知到的次级影响。

还需要精确定义输入相等。JSON 对象的字段顺序不应产生影响。服务器如果区别处理省略的 `note` 和 `note: ""`，就可能收到代理认为相同的请求，却执行两次不同的更新。时间值、自动生成的默认值，以及 `tomorrow` 这样的相对表达式，会让这个主张变得更弱，因为可见参数看起来没变，实际命令却发生了变化。

并发是随意宣称幂等性最容易崩溃的地方。两个工作进程都可能先检查对象不存在，然后同时创建对象。唯一约束、事务性 upsert 或远程幂等机制可以阻止这种情况。单个 MCP 服务器进程中的内存缓存无法保护一个运行多个进程的部署。

只有在定义好范围后，才使用幂等记录。将调用方提供的令牌与经过身份验证的身份、规范化后的请求正文、结果以及适合该操作的过期时间一起保存。如果重复使用的令牌对应不同的规范化输入，就拒绝请求。否则，代理可能意外地把旧令牌附加到新请求上，然后收到另一个操作的结果。

不要因为提示为 true 就自动重试。只有当你知道服务器是否收到调用时，才重试明确可判断的失败。如果无法确定，真正的解决办法是在实际发生变更的位置加入幂等机制，而不是依赖客户端提示。

## 根据实际执行的操作构建审批

审批系统应该回答这些问题：哪个进程发起请求，使用哪个凭据，哪个外部目标会收到请求，涉及哪些状态或数据，以及调用成功后会发生什么。工具注释可以让说明更简短，但无法提供服务器没有暴露的事实。

将会话审批与逐次调用审批分开。会话审批适合由已知代理进程使用普通能力执行范围明确的运行。逐次调用审批适合那些可以转移资金、改变生产访问权限、发送消息、披露敏感记录，或创建不可逆外部承诺的凭据。决定权属于凭据和操作上下文，而不是乐观的 `readOnlyHint`。

有用的提示会写出具体操作：“由此权限签名的代理将使用部署凭据，在账户 Y 中重启服务 X。”较弱的提示是：“允许工具 `deploy` 吗？”前者给审核者提供了可以评估的内容，后者却要求审核者相信某个实现细节。

Sallyport 将 API 和 SSH 凭据保存在加密保险库中，由自己执行 HTTP 或 SSH 操作，再把结果返回给代理，而不是返回凭据。它的会话授权会标识请求进程，同时允许按凭据设置每次使用都需要决定。与服务器提供的布尔值相比，把人工控制放在这里更合理。

即使凭据前面有审批闸门，也要保留记录最终请求目标和结果的活动记录。审批回答的是操作是否可以继续，审计记录回答的是操作继续后发生了什么。不要把这两个问题合并成一个含糊的“使用了工具”事件。

## 将注释检查纳入服务器维护

MCP 工具注释的正确用途，是用测试支持诚实的沟通。服务器作者应保守地设置注释，记录任何边界情况，并在行为变化时同步修改。客户端作者可以把注释作为界面设计的一个输入，但绝不能把它作为权限判断的唯一依据。

添加一些故意挑战声明属性的测试。对于只读声明，如果测试夹具发现写入、出站通知、凭据刷新，或创建了供稍后获取的能力，就让测试失败。对于破坏性声明，测试失败路径和撤销路径，包括下游任务消费变更后会发生什么。对于幂等性，在模拟超时后以及并发调用时运行相同的规范化请求。

不要通过更换测试目标直到测试通过来掩盖不匹配。可以缩小工具范围，让提示变得真实；也可以修改注释；还可以在审批详情中公开该影响。每个选择都能告诉下一位维护者代码实际做了什么。

如果 Sallyport 是操作网关，可以在事件复盘中运行 `sp audit verify`。它会离线验证加密哈希链，因此你无需打开保险库，就能检查记录的操作历史是否保持完整。这不能证明服务器的注释诚实，但能为跟随注释执行的调用提供一份防篡改记录。

实际标准很简单：注释应当经得住针对用户真正关心影响的对抗性测试。如果经不起测试，就保持保守，并把审批放在操作实际发生的地方。
