对671个MCP服务器和27000+工具的扫描发现,80%执行写操作,其中32%完全无幂等保护; timeout后重试将直接导致重复执行,如邮件发送、支付扣款等场景风险最高。
每一次超时都是一道无人能答的题:它到底发生了没有?
Agent 调用了一个工具,结果调用超时了。Agent 不知道邮件有没有发出、发票有没有创建、扣款有没有成功。于是它重试——因为 Agent 就是这么做的。如果调用双方都无法区分第二次尝试和第一次,那么效果就会发生两次。
我们想知道这种情况在实际中有多普遍,于是扫描了整个生态:671 个 MCP 服务器,27,153 个声明的工具。
最尖锐的切分——那些执行写操作、存在重试可能、且没有可见防护的服务器——共 32 个,月均下载量合计 28,653 次。这些地方最有可能因为一次超时而产生重复。
在我们扫描的 23 个最大服务器(月均 10,000+ 下载)中,有 6 个执行写操作且没有可见的防护。
本次扫描看不到什么——引用数据之前请先读这一节
这是最重要的一节,所以放在分析之前而不是之后。
从外部读取代码库的扫描器通常无法证明"重复触发"的发生。三个原因,每一个我们都反复遇到:
防护往往存在于别处。许多服务器调用的后端自身会做去重——按 nonce 键控的支付 API、带唯一约束的数据库。从客户端代码库看,那种保护是不可见的。"未发现防护"意思是扫描器在这份代码里没找到,不代表"这个软件不安全"。
看起来像写操作的可能根本不是写操作。我们发现有些工具表面在发送支付,但实际上只是返回一个供人工签署的链接。名称在两个方向上都会撒谎。
我们自己的扫描器也出过错。早期的某个版本指控一个代码库完全没有幂等性防护,而它实际上内置了一整个模块的防护——一个没有词边界的正则表达式无法匹配 deriveIdempotencyKey。它在前三个真实目标上只手动验证了 3 个。当前版本中的每一条规则都是因为前一条出过错才保留下来的。
所以这些数字描述的是公开代码中可见的部分,而不是对任何项目的安全裁决。我们只发布汇总数据,不点名。如果你想了解某个特定服务器,诚实的答案是:去读它的代码,然后问维护者后端做了什么。
我们还排除了失败案例而非将其计为零:66 个仓库克隆失败,17 个返回了无法解析的输出,1 个把扫描器搞崩溃了。一次失败的查询不是一条发现。把它们算进去只会让每个百分比看起来更难看,而且是不对的。
为什么这种模式如此普遍
幂等性不难,难点不在代码。一把锁、一个键、一个存储的结果——一个下午就能搞定。它难是因为它迫使大多数系统从未明确过的一个问题:
什么让两次操作成为同一次操作?
把这个答案定得太窄,就会产生误重复:烦人、安全、吵闹。定得太宽,就会产生静默的数据丢失——两次本质上不同的操作被压缩成一次,第二次静默返回第一次的收据。这种失败看起来像成功。没有人会去调查一笔看似成功的交易。
我们交了昂贵的学费。我们曾向一个支付项目提出用 URL、金额和钱包来派生幂等键。有人指出这漏掉了收款人、网络和资产——意味着两次真正不同的购买会被压缩成一次。我们的修复方案比原来的 bug 更糟糕。
MCP 让这个问题比普通 API 设计更尖锐,原因在于结构层面:调用方是大语言模型。它不知道你的重试语义,读不了你的后端,而当一次调用模糊地失败时,它的本能是再试一次。在我们阅读的服务器中,工具 schema 是这种知识本应存在的地方——而它通常是沉默的。
三件事,按实现成本排序:
在 schema 里说明。如果一个工具盲目重试是不安全的,描述是模型唯一会读的地方。一句话:"如果这次超时,操作可能已经完成——重试前请先检查。"零成本,却是我们在所有改动中看到的单次最高杠杆。
接受调用方提供的键。让调用方标记两次尝试为同一次。不要仅从 payload 派生它。
让咽喉点原子化。无论什么接受这个效果,也同时负责检查它是否已经发生过。
检查你自己的服务器
npx fencescan
零依赖,无需安装,无需账号。基于上述原因,它只报告候选和证据,从不给出裁决。如果它标记了你知道有防护的东西,那是一个值得向我们报告的 bug——一次误报比漏报代价更高。
方法论、完整的规则列表和我们修复过的四种失败模式:https://github.com/aurumflux20/fencescan
如果你想看失败模式而不是阅读它,这里有 1,000 个并发 Agent 尝试对同一张卡扣费,有防护和无防护的对比:https://aurumflux20.github.io/once-kernel-ts/
我们构建开源的重试安全工具(once-kernel、effectfence),这就是我们最初会有一把扫描器对准这里的原因。数据就是数据——而上面的注意事项是让它们仍然值得一读的原因。