# "I built a lying MCP server on purpose — here's how you catch it"

> Source: <https://dev.to/wolfejam/i-built-a-lying-mcp-server-on-purpose-heres-how-you-catch-it-102g>
> Published: 2026-08-17 05:06:18+00:00

**TL;DR** — A server's README can say anything. Its `tools/list`

response either backs that up or it doesn't.

I built ** mcp-worse** — a second binary, sharing two of

`mcp-better`

'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`

This is what "claim = wire" looks like when you stop saying it and start shipping it.

Every 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`

and `echo`

look identical whether the server behind them is honest or not.

So the question isn't "does this server have a `tools/list`

endpoint." It's: **if the docs are wrong, what breaks, and when?**

Most 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.

The cleanest way to test a contract-checker is to hand it something that violates the contract — not a hypothetical, a real binary.

`mcp-worse`

is that binary. Same protocol version on the wire, same transport, and it mirrors two of `mcp-better`

's tools by name (`health`

, `echo`

) — `mcp-better`

has since grown a third (`confirm_echo`

, 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:

``` js
// 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)
}
```

No `ttlMs`

. No `cacheScope`

. Tools reversed. The `health`

tool result even says so out loud:

```
{
  "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."
}
```

It'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.

`contrast-smoke`

spawns both binaries as actual child processes, talks real MCP over stdio, and checks the wire — not the source, not the docs:

``` php
// 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
```

This builds `mcp-better`

and `mcp-worse`

side by side — `contrast-smoke`

needs both on disk to probe them.

```
cargo run --example contrast-smoke
```

Expect (real output, captured 2026-08-16 against 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)
```

Read 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.

If `mcp-better`

ever regresses — someone drops the `ttlMs`

stamp 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`

ever accidentally started passing the contract, that fails too (the companion has to stay a reliable liar or the test is worthless).

| Claim | Evidence |
|---|---|
`mcp-better` 's list is cache-stamped |
`ttlMs > 0` , `cacheScope == Public` , read off the wire |
| Tool order is a real contract, not incidental |
`mcp-worse` reversing it is what makes the test fail |
| The checker isn't fooled by names |
`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 |
| The contract has a negative case | Not just "good passes" — "bad provably fails," same probe |

That 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`

exists so `contrast-smoke`

has something real to fail against, once, in CI, forever.

`mcp-worse`

never ships to the MCP Registry. It exists in the same repo as `mcp-better`

, for the same reason a crash-test dummy exists next to the car.You don't need `mcp-worse`

specifically. You need the shape:

If you can't build the broken version, you don't know what your claim depends on.

A README can't lie to a test that spawns the real process and reads the real wire. `mcp-worse`

isn'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.

Claim = wire. Build the broken version. Ship the probe that fails on it.

**What's the smallest claim your own server makes that you've never tested?**

*I'm an AAIF Ambassador. This piece is public MCP education — the kind of practical path the program exists for.*
