{"slug": "i-built-a-lying-mcp-server-on-purpose-here-s-how-you-catch-it", "title": "\"I built a lying MCP server on purpose — here's how you catch it\"", "summary": "A developer built mcp-worse, a deliberately deceptive MCP server that omits list-cache stamps and reverses tool order, to prove that a server's README claims can be false. The companion contrast-smoke test spawns both mcp-better and mcp-worse as child processes and verifies the wire protocol, demonstrating that contract violations can be caught automatically.", "body_md": "**TL;DR** — A server's README can say anything. Its `tools/list`\n\nresponse either backs that up or it doesn't.\n\nI built ** mcp-worse** — a second binary, sharing two of\n\n`mcp-better`\n\n's tool names, that deliberately omits the list-cache stamps and serves tools in the wrong order — so a test could prove the difference. That test is `contrast-smoke`\n\nThis is what \"claim = wire\" looks like when you stop saying it and start shipping it.\n\nEvery MCP server's docs make claims: *stateless*, *cacheable list*, *stable tool order*. Nothing in the protocol stops a server from claiming all three and doing none of them. The client can't tell from the tool **names** — `health`\n\nand `echo`\n\nlook identical whether the server behind them is honest or not.\n\nSo the question isn't \"does this server have a `tools/list`\n\nendpoint.\" It's: **if the docs are wrong, what breaks, and when?**\n\nMost servers never answer that, because nothing is *built to fail* on purpose. You only find out a claim was false in production, from a client that behaved unpredictably against a server that \"worked\" in every manual check.\n\nThe cleanest way to test a contract-checker is to hand it something that violates the contract — not a hypothetical, a real binary.\n\n`mcp-worse`\n\nis that binary. Same protocol version on the wire, same transport, and it mirrors two of `mcp-better`\n\n's tools by name (`health`\n\n, `echo`\n\n) — `mcp-better`\n\nhas since grown a third (`confirm_echo`\n\n, an MRTR retry-flow demo) that the lying companion was never updated to match, so the tool *count* alone is now part of the gap too, alongside two deliberate breaks:\n\n``` js\n// src/worse.rs\n/// Intentional anti-order (BETTER is health → echo).\nconst WORSE_TOOL_ORDER: &[&str] = &[\"echo\", \"health\"];\n\n/// Unstamped list with reversed order — the lie.\npub fn lying_list_tools(&self) -> ListToolsResult {\n    let mut tools = self.tool_router.list_all();\n    tools.sort_by(/* ...WORSE_TOOL_ORDER... */);\n    // Deliberately omit with_ttl_ms / with_cache_scope.\n    ListToolsResult::with_all_items(tools)\n}\n```\n\nNo `ttlMs`\n\n. No `cacheScope`\n\n. Tools reversed. The `health`\n\ntool result even says so out loud:\n\n```\n{\n  \"status\": \"ok\",\n  \"server\": \"mcp-worse\",\n  \"version\": \"0.4.3\",\n  \"protocol\": \"2026-07-28\",\n  \"tier\": \"LYING-DEMO\",\n  \"warning\": \"This binary deliberately fails the BETTER list contract for teaching.\"\n}\n```\n\nIt's not a trick client would fall for in the wild — it's labeled, it's teaching-only, it never ships to a registry. Its only job is to be **wrong on purpose, reliably**, so something else can prove it catches a lie.\n\n`contrast-smoke`\n\nspawns both binaries as actual child processes, talks real MCP over stdio, and checks the wire — not the source, not the docs:\n\n``` php\n// examples/contrast_smoke.rs\nfn is_better_contract(p: &ListProbe) -> bool {\n    p.names == better_names()\n        && matches!(p.ttl_ms, Some(ms) if ms > 0)\n        && p.cache_scope == Some(CacheScope::Public)\n}\n\nfn is_lying_surface(p: &ListProbe) -> bool {\n    let unstamped = p.ttl_ms.is_none() || p.cache_scope.is_none();\n    let wrong_order = p.names != better_names();\n    unstamped || wrong_order\n}\ngit clone https://github.com/Wolfe-Jam/mcp-better.git\ncd mcp-better\ncargo build --bins\n```\n\nThis builds `mcp-better`\n\nand `mcp-worse`\n\nside by side — `contrast-smoke`\n\nneeds both on disk to probe them.\n\n```\ncargo run --example contrast-smoke\n```\n\nExpect (real output, captured 2026-08-16 against v0.4.3):\n\n```\nbetter names=[\"health\", \"echo\", \"confirm_echo\"] ttl=Some(60000) scope=Some(Public)\nworse  names=[\"echo\", \"health\"]                 ttl=None        scope=None\ncontrast-smoke: OK (mcp-better passes BETTER list contract · mcp-worse fails it)\n```\n\nRead those two lines side by side — that's the whole post in two rows of text. Same protocol, same transport, one server stamps and orders its list, the other doesn't, and now there's a command that says so instead of a paragraph that claims so.\n\nIf `mcp-better`\n\never regresses — someone drops the `ttlMs`\n\nstamp in a refactor, tool order stops being deterministic — this fails loudly, on the *good* server, using the exact same probe that already knows what \"bad\" looks like. And if `mcp-worse`\n\never accidentally started passing the contract, that fails too (the companion has to stay a reliable liar or the test is worthless).\n\n| Claim | Evidence |\n|---|---|\n`mcp-better` 's list is cache-stamped |\n`ttlMs > 0` , `cacheScope == Public` , read off the wire |\n| Tool order is a real contract, not incidental |\n`mcp-worse` reversing it is what makes the test fail |\n| The checker isn't fooled by names |\n`mcp-worse` shares two tool names with `mcp-better` (`health` , `echo` ); wrong order and missing stamps fail it regardless — no name-matching heuristic to fool |\n| The contract has a negative case | Not just \"good passes\" — \"bad provably fails,\" same probe |\n\nThat last row is the actual point. A test suite that only ever runs against the happy path proves the happy path exists. It doesn't prove the checker *works* — that it would catch a violation if one showed up. `mcp-worse`\n\nexists so `contrast-smoke`\n\nhas something real to fail against, once, in CI, forever.\n\n`mcp-worse`\n\nnever ships to the MCP Registry. It exists in the same repo as `mcp-better`\n\n, for the same reason a crash-test dummy exists next to the car.You don't need `mcp-worse`\n\nspecifically. You need the shape:\n\nIf you can't build the broken version, you don't know what your claim depends on.\n\nA README can't lie to a test that spawns the real process and reads the real wire. `mcp-worse`\n\nisn't clever — two constants and a missing function call are enough. That's the whole lesson: the gap between \"claims to be BETTER\" and \"is BETTER\" is usually that small, and invisible until something is built to fail on it.\n\nClaim = wire. Build the broken version. Ship the probe that fails on it.\n\n**What's the smallest claim your own server makes that you've never tested?**\n\n*I'm an AAIF Ambassador. This piece is public MCP education — the kind of practical path the program exists for.*", "url": "https://wpnews.pro/news/i-built-a-lying-mcp-server-on-purpose-here-s-how-you-catch-it", "canonical_source": "https://dev.to/wolfejam/i-built-a-lying-mcp-server-on-purpose-heres-how-you-catch-it-102g", "published_at": "2026-08-17 05:06:18+00:00", "updated_at": "2026-08-17 05:12:20.506047+00:00", "lang": "en", "topics": ["developer-tools", "ai-infrastructure"], "entities": ["mcp-worse", "mcp-better", "contrast-smoke", "Wolfe-Jam"], "alternates": {"html": "https://wpnews.pro/news/i-built-a-lying-mcp-server-on-purpose-here-s-how-you-catch-it", "markdown": "https://wpnews.pro/news/i-built-a-lying-mcp-server-on-purpose-here-s-how-you-catch-it.md", "text": "https://wpnews.pro/news/i-built-a-lying-mcp-server-on-purpose-here-s-how-you-catch-it.txt", "jsonld": "https://wpnews.pro/news/i-built-a-lying-mcp-server-on-purpose-here-s-how-you-catch-it.jsonld"}}