AI 技术
#MCP#工具调用#Agent#授权#安全边界

MCP 工具注解的信任边界:readOnlyHint 与 destructiveHint 为何不能作为安全依据

Agent 自动调用写操作工具时,客户端常靠 readOnlyHint、destructiveHint 决定是否弹窗确认。本文拆解这些注解的语义与来源,说明它们由服务端自述、客户端无从验证的信任假设,对比服务端强制鉴权与人工审批,并给出校验方法与可观测指标。

一个删库请求是怎么被放行的

设想一个企业内部的运维助手:用户说“把上周的测试环境日志清理一下”,模型自动发现了一个名为 cleanup_logs 的工具,参数是环境名和保留天数,于是直接发起 tools/call。客户端在渲染这个调用时看了一眼工具定义里的 readOnlyHint: false 和 destructiveHint: true,弹出一个确认框,用户点了“允许”。日志被删了。

这个流程看起来有确认环节,但确认依据来自服务端自己填写的字段。如果同一个服务端把 destructiveHint 写成 false,客户端很可能不弹窗,模型就直接把删除动作执行完了。注解没有说谎检测能力,它只是一段由被审查方提交的元数据。

MCP 规范对此有明确表态:出于信任与安全考虑,客户端必须把工具注解视为不可信,除非它们来自可信服务器。这句话点出了问题的核心,但“可信服务器”本身是一个需要额外机制去建立的判断,协议层没有替你完成。

注解的语义与来源

工具定义里有哪些字段

MCP 的工具通过 tools/list 暴露。每个工具定义包含 name、可选的 title、description、inputSchema,以及可选的 outputSchema 和 annotations。annotations 就是承载行为提示的地方,常见字段包括 readOnlyHint、destructiveHint、idempotentHint 和 openWorldHint。

这些字段的用途是让客户端和模型对工具行为有一个粗略预期。readOnlyHint 表示工具不修改外部状态;destructiveHint 表示调用可能造成破坏性变更;idempotentHint 表示重复调用与单次调用效果相同;openWorldHint 表示工具会与外部系统交互。

它们从哪里来

关键点在于,这些值由服务端在返回工具列表时自行填写,客户端只是接收方。协议没有要求服务端提供证明,也没有定义客户端如何验证。客户端能看到的只有一段 JSON,看不到服务端内部是否真的只读。

社区讨论中有人指出,现有注解只是部分刻画了工具的风险特征,语义不够清晰,难以在客户端与服务端之间形成一致理解。讨论还提出,注解本应帮助系统识别“不可信输入、敏感数据访问、不可逆操作”这三类风险,但当前字段与这些风险维度并不能干净对应。例如 openWorldHint 同时混合了“读取外部不可信输入”和“向外部发送数据”两种不同行为,而可逆性没有被直接表达。

这说明注解设计仍在演进,把它当作稳定契约来驱动安全决策,风险很高。

一个容易忽略的细节

idempotentHint 常被误读为“安全”。幂等只说明重复执行的结果一致,不说明首次执行无害。一个幂等的“删除指定记录”工具,重复调用仍然只删一条,但那条记录确实没了。幂等性影响的是重试策略,不是审批策略。

客户端据注解弹窗的信任假设

隐含的假设链

当客户端用 destructiveHint 决定是否弹窗时,它默认了几件事:服务端如实填写了注解;服务端对工具行为的描述与实现一致;工具不会因为参数不同而改变风险性质;服务端不会在两次 tools/list 之间悄悄改变行为。

这些假设在受控的自建服务端上可能成立,但在第三方服务端、多租户服务端或动态注册的服务端上很难保证。MCP 的授权机制支持动态客户端注册,客户端可能事先并不知道所有服务端,这进一步削弱了“可信服务器”的默认前提。

参数会改变风险

同一个工具,参数不同,风险可能完全不同。一个 execute_query 工具,传 SELECT 是只读,传 DROP TABLE 就是破坏性操作。注解是工具级别的,无法表达参数级别的差异。客户端如果只看 readOnlyHint,就会把这两类调用一视同仁。

注解可能过期

工具列表可以变化,服务端会通过 notifications/tools/list_changed 通知客户端。客户端如果缓存了旧注解,而服务端已经修改了工具实现,缓存中的 destructiveHint 可能已经不再反映真实行为。规范建议服务端以确定性顺序返回工具,便于客户端缓存,但缓存一致性仍依赖客户端正确处理变更通知。

贯穿场景:一次自动清理的完整数据流

下面用运维助手的清理场景,把从发现工具到执行删除的流程画出来,重点看注解在哪一步进入决策,以及哪一步缺少强制约束。

flowchart TD
    A[用户请求清理测试环境日志] --> B[客户端 tools/list 获取工具]
    B --> C[服务端返回 cleanup_logs 及注解]
    C --> D[客户端缓存注解]
    D --> E[模型选择 cleanup_logs 并生成参数]
    E --> F{客户端读取 destructiveHint}
    F -->|true| G[弹出确认框]
    F -->|false| H[直接发起 tools/call]
    G --> I[用户确认]
    I --> J[客户端发送 tools/call]
    H --> J
    J --> K[服务端鉴权与执行]
    K --> L{服务端是否强制校验权限}
    L -->|是| M[按令牌范围放行或拒绝]
    L -->|否| N[按注解假设直接执行]
    M --> O[返回结果或错误]
    N --> O

图里有两个关键转折点。第一个在 F 节点,客户端用服务端自述的注解决定是否打扰用户。第二个在 L 节点,服务端是否真的做了权限校验。如果 L 节点缺失,F 节点的弹窗就是唯一防线,而这条防线建立在不可信数据上。

服务端强制鉴权与人工审批的对比

两种防线的职责不同

服务端鉴权回答的是“这个调用者有没有权限做这件事”,它基于令牌、作用域和资源策略,是强制性的。人工审批回答的是“这次具体操作是否应该发生”,它基于人的判断,是交互性的。两者不能互相替代:鉴权无法判断一次删除是否合理,审批无法阻止一个越权令牌反复尝试。

对比表

维度服务端强制鉴权客户端人工审批仅依赖注解
决策依据令牌作用域、资源策略、调用者身份用户对本次操作的判断服务端自述的注解字段
能否被服务端单方面绕过不能,除非鉴权实现有缺陷不能,但可被诱导点击能,改注解即可
能否表达参数级风险可以,按请求参数校验可以,用户看到具体参数不能,注解是工具级
对自动化的影响无额外交互,按策略放行每次敏感操作打断用户无交互,但判断不可靠
主要失败模式作用域过宽、令牌泄露审批疲劳、误点允许注解造假或过期
适用场景所有写操作的最后防线高风险、低频、需人判断的操作仅作 UI 提示,不作安全依据

表格里最值得注意的一行是“能否被服务端单方面绕过”。注解方案中,服务端只要把 destructiveHint 改成 false,客户端的弹窗逻辑就失效了。这不是实现缺陷,而是把安全决策建立在被审查对象自述之上必然的结果。

注解的合理位置

注解不是没用,它的合理位置是 UI 提示和默认策略的输入之一。比如客户端可以默认对 readOnlyHint: true 的工具减少打扰,对 destructiveHint: true 的工具提高提示级别。但这类策略应当是可配置的,并且在服务端不可信时退回到更保守的默认值。

校验与可观测指标

客户端侧能做什么

客户端无法验证注解真伪,但可以做几件事降低风险。第一,把注解来源与服务器信任级别绑定,只对已配置为可信的服务端采用宽松策略。第二,对写操作不依赖 readOnlyHint,而是维护自己的敏感工具名单。第三,在确认框里展示实际参数,而不是只展示工具名,让用户看到 DROP TABLE 这样的具体内容。第四,对同一工具的历史行为做记录,如果发现标注为只读的工具产生了写副作用,就降级信任。

服务端侧必须做什么

服务端不能把注解当权限控制。每个写操作都应在执行前检查调用者令牌的作用域和目标资源。MCP 授权规范要求 MCP 服务器实现 OAuth 2.0 受保护资源元数据,客户端使用资源指示符指定目标资源,令牌应当绑定受众。这些机制才是强制的边界。

值得监控的信号

生产环境中,以下指标能帮助发现注解与行为不一致:

  • 标注为 readOnlyHint: true 的工具触发了写操作审计事件的数量。这个值应当为零,非零说明注解或实现有问题。
  • 敏感工具调用中未经人工确认直接执行的比例。比例过高说明客户端过度信任注解。
  • 同一工具在 tools/list 中注解字段发生变化的次数。频繁变化说明服务端行为不稳定。
  • 确认框的允许率。如果接近 100%,说明审批已经退化为形式,用户不再认真判断。
  • 服务端鉴权拒绝率。持续为零可能意味着鉴权策略过宽,或者根本没有生效。

一个具体的验证方法

可以在测试环境构造一个“说谎”的服务端:声明 readOnlyHint: true,但实际执行写操作。观察客户端是否弹窗、是否记录异常、是否在后续调用中降低信任。这个测试能直接暴露客户端是否把注解当成了安全依据。

边界与未解决的问题

注解机制目前无法表达参数级风险,也无法证明服务端自述的真实性。社区提出的 egressHint 和 reversibleHint 等补充方向,试图把外部传输和可逆性拆成独立维度,但这些提案尚未成为规范,客户端不应提前依赖。

还有一个未解决的问题:当服务端本身是恶意的,客户端除了拒绝连接之外,能否在协议层获得任何可验证的信号?目前的答案是不能。注解、描述和 schema 都来自服务端,客户端能做的只是限制爆炸半径,例如通过令牌作用域、网络隔离和审计日志,而不是试图验证服务端的诚实。

把 readOnlyHint 和 destructiveHint 当作安全依据,等于把门锁的钥匙交给需要被检查的人。它们可以改善交互体验,但不能承担安全职责。

资料来源

  1. Model Context Protocol Specification - Tools
  2. Model Context Protocol - Concepts: Tools
  3. Model Context Protocol Specification - Authorization
  4. Clarifying MCP Tool Annotation Semantics + Proposal for egressHint and reversibleHint · modelcontextprotocol modelcontextprotocol · Discussion #2382 · GitHub