一个删库请求是怎么被放行的
设想一个企业内部的运维助手:用户说“把上周的测试环境日志清理一下”,模型自动发现了一个名为 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 当作安全依据,等于把门锁的钥匙交给需要被检查的人。它们可以改善交互体验,但不能承担安全职责。