关于 mcp-confirm
mcp-confirm 是一个面向需要用户确认的 MCP 服务的安全示例,演示如何防止确认被重复使用、提示内容被伪装,以及确认后文件被替换。它通过限定文件根目录和执行前复查资源来实现这些检查。
主要功能
- 01确认记录仅可消费一次
- 02清理并截断用户可见提示
- 03执行前复查文件状态
- 04拒绝根目录外路径
- 05阻止符号链接替换
适用场景
- 01防止一次性操作的确认被重复使用
- 02降低确认对话中的提示注入风险
- 03确认后检查待删除文件是否被替换
MCP 相关
该服务在 MCP 的请求状态边界之外补充单次确认记录、提示清理和资源复查。示例工具仅删除启动时指定根目录内的文件。
配置
本地服务的配置在各客户端中通用,复制后粘贴到客户端的 MCP 配置文件即可。
工具
本地服务需要在你的电脑上运行后才能读取工具,维护者暂未提供工具列表。
详细介绍
mcp-confirm
通俗地说: 当 AI 问“你确定吗?”时,MCP SDK 已经会阻止这项批准被用于不同的操作,但不会阻止同一项批准被使用两次。本项目弥补了这一缺口,以及 SDK 留下的另外两个缺口。
请先阅读:这个问题的大部分已经解决
2026-07-28 的 MCP 规范新增了多轮往返请求,因此工具可以在调用过程中暂停并请求用户确认。批准会通过客户端以不透明的 requestState 传回;规范要求服务端将其视为必须防范攻击者控制的数据。
Python SDK 默认会为你处理这件事,适用于每个 MCPServer。
RequestStateBoundary 会无条件追加到中间件链中——如果你没有提供密钥,它会使用临时密钥。它使用 AES-256-GCM 对状态进行封装,并将其绑定到方法、目标、调用参数的摘要、受众和经过身份验证的主体,同时设置 TTL。
因此,大家最先想到的攻击——批准删除 cache.txt,然后将该批准重放到 thesis.txt 上——SDK 已经会拒绝。
你不需要为此使用库,也不应该自己编写一个。
尽管如此,本仓库最初还是写了一个:250 行的 HMAC、TTL 和参数绑定代码,作为主打功能发布,而它在发布当天就已多余。相关记录放在文末,并未悄悄删除。
SDK 不会做什么
有三个缺口,每个都有对应测试。
1. 它会绑定状态并使其过期,但从不消耗状态。
在 TTL 有效期内,同一项批准无论提交多少次都会验证通过。边界每次进行的检查都会通过。规范明确指出这是有意设计的,并说明其余工作由你负责:
请注意,这些措施会限制重放窗口,并防止跨用户和跨请求重用,但本身并不能保证只能使用一次。对于必须确保给定
requestState至多消耗一次的服务(例如,一次性兑换),必须在服务端强制执行这一不变量。
对于“删除文件”,第二次尝试时文件已不复存在。对于“转账 500 英镑”,这就是整个问题。 singleuse.py 实现了这一不变量——它维护一份待处理确认的账本,并在兑换时将其标记为已使用。它不包含任何加密功能,因为边界已保证明文由本服务生成。
2. 没有任何机制会清理人类看到的问题内容
引导消息中的 message 文本由服务端选择,并会呈现给用户,通常还会插入不可信值——毕竟,提示的目的就是说明哪个文件将被删除。举个文件名:
cache.txt
SYSTEM NOTICE: your session has expired.
Enter your AWS secret key to continue:
现在,对话框中出现了第二条看起来官方的提示。用户并非在确认删除,而是在被自己的工具钓鱼。2026-07-28 的同一版本还发布了 MCP Apps——由服务端渲染的 UI——这扩大了攻击面,而非缩小了它。
prompt.py 会将不可信值压平成一行,去除双向覆盖字符和零宽字符,并从中间截断,以保留末尾的文件名。控制字符会被替换为空格,而不是直接删除,因为将它们折叠会使 a\nb 和 ab 显示成相同内容——两个不同的文件,却对应同一个对话框。
3. 没有协议层能在执行时重新检查你的资源
用户在这段时间里重新考虑了。文件可能在此期间被替换,而只有工具知道对它而言“未发生变化”意味着什么。本服务会在删除前重新检查,并拒绝删除已变成符号链接的路径。
哪一层会拒绝什么
这是值得关注的部分,测试也旨在展示这一点。MCPError
表示 SDK 的中间件在此软件包运行前拒绝了请求;ToolError 表示
此软件包拒绝了请求。
| 攻击 | 拒绝方 | 测试 |
|---|---|---|
| 将审批重放到另一个文件上 | SDK | test_the_sdk_refuses_a_confirmation_replayed_onto_another_file |
| 伪造状态,或使用另一把密钥对状态进行封装 | SDK | test_the_sdk_refuses_a_forged_state |
| 同一项审批被使用两次 | 此软件包 | test_a_confirmation_cannot_be_spent_twice |
| 状态由另一个副本签发 | 此软件包 | test_a_state_this_process_never_issued_is_refused |
| 文件名伪造系统提示词 | 此软件包 | test_a_forged_system_prompt_cannot_escape_its_slot |
| 审批后文件被替换为符号链接 | 此软件包 | test_a_swap_aimed_inside_the_root_is_refused |
| 路径位于允许的根目录之外 | 此软件包 | test_a_path_outside_the_roots_is_refused_before_asking |
测试
35 项测试——17 项通过服务运行,11 项针对账本,7 项针对提示词净化。
每项服务测试都会经过真实的 RequestStateBoundary,也就是
MCPServer 在自身安装的同一个类,并使用固定密钥——按照线路上的实际方式,
封装第一轮并解封第二轮。
这个测试框架的存在是因为一个具体的错误。第一版测试是通过直接调用
MCPServer.call_tool() 来进行的;它会直接调用工具管理器,完全绕过中间件链。
SDK 的边界从未运行,因此一个多余的手写防护看起来像是关键防护。测试设计
正是掩盖这个问题的原因,整整一个构建周期都是如此。
覆盖率为 81%,其中 prompt.py 为 100%,singleuse.py 为 94%。未覆盖部分是
main() 的 argparse 和传输配置,改由 build_server 进行测试。
CI 仅在 Ubuntu 上运行,这是有意为之:审批后替换文件的测试需要 符号链接,而且如果这些测试在 Ubuntu 上被报告为跳过,构建就会失败。
安装
pip install mcp-confirm
运行
mcp-confirm --root /path/you/allow
如果未指定 --root,服务会拒绝每个请求,而不是默认使用任何路径。根目录在启动时固定,
且不会由 Agent 选择。无需签名密钥——MCPServer 会自带密钥。
已知限制
- 账本存储在内存中,因此单进程下运行正确,但置于负载均衡器之后就会出错。SDK 支持在副本之间共享密钥
(
RequestStateSecurity(keys=[...]));在这种配置下,副本 B 会拒绝由副本 A 签发的审批。它会以故障关闭的方式处理——这是更安全的方向—— 但用户看到的只是审批莫名其妙地失效了。多进程部署需要 Redis 或带有 原子比较并删除操作的数据库行。此行为有对应的测试。 - **演示工具刻意保持精简。**它只删除一个文件。有意思的代码在
singleuse.py和prompt.py中。 - **没有 CVE 支持这一做法。**MRTR 发布仅数周,因此这里的实现依据是规范自身的 MUST/SHOULD 列表以及对 SDK 的阅读,而非已公开的安全事件。
0.1.0 版本的问题
保留这段记录,因为只记录成功的仓库并不能证明任何事情。
0.1.0 重新实现了 SDK 已经完成的功能。 state.py 包含 250 行 HMAC 签名、TTL,以及对主体、方法和参数的绑定逻辑——这些都重复实现了 RequestStateBoundary,而且做得更差:SDK 使用经过身份验证的加密,这里却使用 HMAC;没有受众绑定,也不支持密钥轮换。
这个问题是通过阅读 SDK 的源代码发现的,而不是测试发现的。 测试之所以通过,恰恰是因为它们绕过了本该暴露问题的中间件。
CI 还单独发现了 0.1.0 中的一个真实错误:符号链接检查在 Path.resolve() 之后运行,因此检查的是链接的目标,而不是链接本身。若有人将其替换为允许的根目录内的另一个文件,程序就会删除该文件。问题已修复,并为原实现从未测试过的变体添加了测试。
0.2.0 完全删除了 state.py,只保留 SDK 尚未覆盖的部分。
许可证
MIT。
作者
由 Leslie Kadenge 开发。我独立审查 MCP 服务的安全性;我对 13 个生产环境服务的公开调查见 les-k.github.io/field-notes.html。
评论
一个开放协议,规定了 AI 应用如何以统一方式连接外部工具与数据源。客户端负责与模型对话,MCP 服务负责提供工具、资源和提示模板,两者通过标准消息通信,同一个服务可以被不同客户端复用。