作者开源mcp-worse模拟恶意MCP服务器,提供contrast-smoke测试框架,通过实际MCP客户端和 wire流量验证服务器是否遵守协议承诺。
TL;DR — 服务器的 README 可以写任何东西,但它的 tools/list 响应要么证实那些说法,要么证伪。
我构建了 mcp-worse —— 一个与 mcp-better 共用两个工具名的第二二进制文件,它故意省略 list-cache 时间戳并以错误顺序提供服务 —— 这样一个测试就能证明差异。这个测试叫 contrast-smoke:一条命令,真实的 MCP 客户端,真实的传输流量。只有好服务器通过契约且坏服务器未通过时,退出码才为 0。
这就是当你不再空谈"声明 = 传输"而是开始真正实现它时的样子。
每个 MCP 服务器的文档都会做出声明:无状态、可缓存的列表、稳定工具顺序。协议中没有任何规定阻止服务器声称三者全做到但实际一个都不做。客户端无法从工具名判断 —— health 和 echo 看起来完全一样,无论背后服务器是诚实的还是不是。
所以问题不是"这个服务器是否有 tools/list 端点"。而是:如果文档错了,什么会出问题,何时出问题?
大多数服务器从不回答这个问题,因为没有什么东西是被故意设计用来失败的。你只在生产环境中才会发现某个声明是假的 —— 来自一个行为不可预测的客户端,对抗一个在每次手动检查中都"正常"的服务器。
测试契约检查器的最干净方式,是交给它一个违反契约的东西 —— 不是假设,而是一个真实的二进制文件。
mcp-worse 就是那个二进制文件。相同的协议版本、相同的传输层,它按名称镜像了 mcp-better 的两个工具(health、echo)—— mcp-better 后来长出了第三个(confirm_echo,一个 MCPT retry-flow 示例),而那个说谎的同伴从未更新去匹配,所以现在工具数量本身也成了差距的一部分,再加上两个故意的破坏:
// src/worse.rs
/// Intentional anti-order (BETTER is health → echo).
const WORSE_TOOL_ORDER: &[&str] = &["echo", "health"];
/// Unstamped list with reversed order — the lie.
pub fn lying_list_tools(&self) -> ListToolsResult {
let mut tools = self.tool_router.list_all();
tools.sort_by(/* ...WORSE_TOOL_ORDER... */);
// Deliberately omit with_ttl_ms / with_cache_scope.
ListToolsResult::with_all_items(tools)
}
没有 ttlMs。没有 cacheScope。工具顺序反转。health 工具结果甚至直接明说:
{
"status": "ok",
"server": "mcp-worse",
"version": "0.4.3",
"protocol": "2026-07-28",
"tier": "LYING-DEMO",
"warning": "This binary deliberately fails the BETTER list contract for teaching."
}
这不是客户端在野外会落入的伎俩 —— 它有标注,是教学专用,永远不会发布到任何 registry。它的唯一工作就是故意出错、可靠地出错,好让其他东西能证明它能抓住谎言。
contrast-smoke 将两个二进制文件都生成为真实的子进程,通过 stdio 进行真实的 MCP 通信,并检查传输层 —— 不是源代码,不是文档:
// examples/contrast_smoke.rs
fn is_better_contract(p: &ListProbe) -> bool {
p.names == better_names()
&& matches!(p.ttl_ms, Some(ms) if ms > 0)
&& p.cache_scope == Some(CacheScope::Public)
}
fn is_lying_surface(p: &ListProbe) -> bool {
let unstamped = p.ttl_ms.is_none() || p.cache_scope.is_none();
let wrong_order = p.names != better_names();
unstamped || wrong_order
}
git clone https://github.com/Wolfe-Jam/mcp-better.git
cd mcp-better
cargo build --bins
这会并排构建 mcp-better 和 mcp-worse —— contrast-smoke 需要两者都在磁盘上才能探测它们。
cargo run --example contrast-smoke
预期输出(真实输出,2026-08-16 捕获,针对 v0.4.3):
better names=["health", "echo", "confirm_echo"] ttl=Some(60000) scope=Some(Public)
worse names=["echo", "health"] ttl=None scope=None
contrast-smoke: OK (mcp-better passes BETTER list contract · mcp-worse fails it)
把这两行并排看 —— 这就是整篇文章的两行文本。同样的协议,同样的传输层,一个服务器给列表加盖时间戳并排序,另一个没有,现在有一条命令能说出这一点,而不是一段声称如此的文字。
如果 mcp-better 将来退化 —— 有人在重构中删除了 ttlMs 时间戳,工具顺序不再确定 —— 这会在好服务器上大声失败,使用的是已经知道"坏"是什么样子的完全相同的探测。而如果 mcp-worse 某天意外开始通过契约,那也会失败(这个同伴必须保持作为一个可靠的骗子,否则测试就毫无价值)。
最后一行才是真正的要点。只在理想路径上运行的测试套件,只能证明理想路径存在。它不能证明检查器有效 —— 证明不了如果真的出现违规它能抓住。mcp-worse 存在是为了让 contrast-smoke 有一个真实的东西可以失败对抗,一次,在 CI 中,永远。
不是安全扫描器 —— 它不检查认证、注入或 prompt 级别的信任。它只检查一个特定的常见声明:list 响应是否与文档关于缓存和顺序的说法相符。
不是通用 MCP 模糊测试工具。两个工具,一个契约,故意为之 —— 小到可以在五分钟内读完。
不是产品。mcp-worse 从不发布到 MCP Registry。它存在于与 mcp-better 相同的 repo 中,原因是碰撞测试假人存在于汽车旁边。
不是说"MCP 服务器不可信"。大多数还没有经过这种方式的审计 —— 这就是这种模式要弥合的差距,不是指控。
你不需要 mcp-worse 本身。你需要的是这个模式:
写下你的服务器文档关于其传输行为的每一个声明(缓存提示、顺序、传输头 —— 无论你承诺了什么)。
对于每个声明,问:什么是最小的改动会让它变成假的?
构建那个版本 —— 故意地,一次性地,标注为教学/测试 fixture,永远不作为产品发布。
写一个探测,同时检查你的真实服务器和那个损坏的同伴,并断言它们在每个声明上落在相反的两侧。
如果你无法构建损坏版本,你就不清楚你的声明依赖于什么。
仓库(上面引用的完整源码):github.com/Wolfe-Jam/mcp-better
本系列前篇:"Not all MCP servers are equal — what 7/28 just made official" —— 这篇文章使之具象化的 claim=wire 检查清单
MCP 规范:modelcontextprotocol.io/specification/2026-07-28
README 无法欺骗一个生成真实进程并读取真实传输层的测试。mcp-worse 不算巧妙 —— 两个常量加一个缺失的函数调用就够了。这就是全部的教训:"声称是 BETTER"与"实际上是 BETTER"之间的差距通常就是这么小,而且在有东西被故意构建来失败之前是看不见的。
Claim = wire。构建那个损坏版本。发布那个会在它身上失败的探测。
你自己的服务器做出的最小声明是什么,你从未测试过?
我是 AAIF 大使。这篇文章是公开的 MCP 教育 —— 是这个项目存在的实用路径。