DEV Community

Cover image for Your MCP Tools Cost 6.6x More Context Than They Need. I Measured It Against the New Spec.
AI Explore
AI Explore

Posted on

Your MCP Tools Cost 6.6x More Context Than They Need. I Measured It Against the New Spec.

Your agent pays a tax before it does anything. The moment a host connects to an MCP server, tools/list pours the full JSON Schema of every tool into the model's context — before the user types a word, before the model reasons about anything. Most of those schemas will never be touched this session. You pay for them anyway. Every session.

I wanted to know exactly how much. Not vibes — a number I could defend. So I measured it against a real local MCP server: genuine JSON-RPC over stdio, tools written in the verbose github-style production servers actually ship, tokens counted with cl100k_base. The harness is open, dependency-free, and reruns in under a minute.

The answer: about 6.6× more context than the model needs, stable from 12 tools to 72. At 72 tools that is 33,709 tokens — roughly 17% of a 200k window — sitting idle before the conversation starts.

Shipped tool-definition tokens vs the minimal floor at 12–72 tools: 6.6–6.9× redundancy

What the numbers actually are

tools tool-def tokens minimal floor redundancy % of 200k ctx
12 5,589 815 6.9× 2.8%
24 11,213 1,667 6.7× 5.6%
48 22,461 3,371 6.7× 11.2%
72 33,709 5,075 6.6× 16.9%

The "minimal floor" is the smallest serialization that still names each tool, gives its one-line purpose, and lists its parameters as name:type — in other words, everything the model actually needs to pick a tool and call it. The gap between the columns is redundancy the protocol forces: full JSON Schema for every tool, up front, whether or not the task will touch it.

Two things in that table surprised me. First, the ratio barely moves with scale — this isn't a big-catalog pathology, it's the baseline cost of the format. Second, the per-tool cost is boringly constant at ~468 tokens, which means you can estimate your own tax from a tool count alone.

Share of a 200k context window spent on tool schemas: 2.8% at 12 tools to 16.9% at 72

Here's the part I'll take a position on: the 2026-07-28 release candidate — the largest MCP revision since authorization — does not touch this. It ships stateless transport, OAuth hardening, JSON Schema 2020-12, and deprecates Roots, Sampling, and Logging. The token tax isn't in it, and it isn't on the core roadmap. The two community threads that raised it (#2808 and SEP-1576) are closed without a spec-level answer. I think that's a mistake, and I'd rather argue about it with a reproducible number on the table than with adjectives.

The fix is boring. That's the point.

Nothing about the fix is novel, and I won't pretend otherwise. It's progressive disclosure — the pattern Anthropic's Tool Search and others already report at the same order of magnitude: index the catalog, expose tool_search(query) and tool_load(name) meta-tools instead of dumping raw schemas, and load a tool's full definition only when the task reaches for it.

What I can add is a measurement of it against the current release candidate, produced at the same boundary that does the security checks below:

Per-task tokens: 33,709 for the full 72-tool catalog vs 750–883 behind tool_search/tool_load

Per-task cost collapses from 33,709 tokens to 750–883 — a 97%+ reduction — with a one-off 209-token index as the always-paid overhead. Three real tasks, three real tool loads, same harness, same tokenizer.

The second gap lives at the same boundary

While I was staring at tools/list, a different problem kept staring back. The MCP spec says tool descriptions "should be considered untrusted" — and then provides no mechanism to act on that. The description crosses from the server into the model's context as-is, and the model reads it like instruction.

The nastiest version of this is what I call the approval-view fidelity gap. A tool description can render as clean, innocent text in the human approval dialog while carrying a hidden instruction in Unicode's invisible TAG range (U+E0000–E007F) that the model receives in full. The human approves what they can see. The model obeys what they can't.

The approval-view fidelity gap: clean human-view description vs the same bytes plus a hidden Unicode TAG-range instruction as the model receives them

There are five core concealment techniques here, and they deserve plain names: the TAG-block mirror, zero-width binary, the bidi Trojan-Source trick, variation-selector smuggling, and the homoglyph spoof. My harness demonstrates each one against a local server it controls, prints the human-view and the model-view side by side, and flags the divergence at the boundary.

Notice the shape of the argument: the optimization question ("why is all of this in context?") and the security question ("why is all of this trusted?") are the same question asked about the same place — the moment tool metadata crosses from a server into the model's context. One mediator at tools/list answers both. The integrity checks are nearly free once you're already mediating that boundary for the token savings everyone wants anyway.

I claimed "zero false positives." Then I tried to prove myself a liar.

This is the part of the project I'm least proud of and most glad happened.

Early on I shipped a marquee claim: i18n-honest — zero false positives on legitimate international text. It sounded rigorous. Then I did what I should have done first: I built a fresh battery of legitimate strings across ~40 writing systems and mechanisms — Thai and Khmer zero-width word breaks, Persian ZWNJ, Indic conjuncts, emoji-ZWJ professions, keycap and ©®™ presentation selectors, CJK ideographic variation sequences, RTL-plus-Latin bidi text, Greek glued to Latin in scientific notation — and ran my own detector against it.

It failed. Badly. Dozens of false positives on perfectly real text. My claim had been true only on the handful of examples I'd been looking at when I wrote it.

So I rebuilt the detector around a different principle: decide attack-versus-legitimate by context, not codepoint identity. A variation selector after a visible base character is typography; a run of them orphaned on invisible bases is a byte channel. Zero-width characters as word breaks are how Thai is written; zero-width characters as a dense run are a payload. Then round after round of the same loop: try to break it, fix what broke, rerun everything.

Where it landed, verified on both the Python and TypeScript implementations at parity on this corpus:

Detector scorecard: 5/5 techniques caught, 0 false positives on the i18n corpus and the 98 legitimate battery strings, 27/41 evasion variants

Zero false positives on the 35-string i18n corpus and on all 98 legitimate strings of a 139-string adversarial battery, while catching all five core techniques and 27 of the battery's 41 deliberate evasion variants. Deterministic the whole way down — no LLM, no network call, no randomness — so the same input always produces the same verdict, and anyone can audit why.

And it still misses things, on purpose, and the README says exactly what: wholly-non-Latin homoglyph words (catching those needs the full UTS #39 fold table — a naive fold false-positives on real Cyrillic), Latin brand words carrying Armenian look-alikes (Armenian legitimately agglutinates onto Latin words), visibly-distinct look-alikes a human reviewer can already see (fullwidth, small-caps, math alphanumerics), bidi embeddings and isolates (legitimate in RTL text — only the classic RLO/LRO overrides are flagged), and a zero-width payload smeared out as isolated singletons below 30% density, which evades the floor at the cost of conspicuously bloating the text.

The lesson I'd offer anyone building a detector: a documented false negative you can see coming is honest engineering; a false positive that cries wolf on someone's language is not. I chose an FP-free detector with named residuals over a leaky rule that would break real Persian, Thai, and Hindi text. That was a hard requirement, not a nice-to-have.

What I'm explicitly not claiming

The space is crowded and the prior art is good. Byte-pinning for rug-pulls ships in Invariant Labs' MCP-Scan. Deterministic hidden-Unicode detection ships in Microsoft's agent-governance tooling and Cisco's YARA rules. Token-costing exists in mcp-checkup. None of the primitives here are new.

The contribution is the compound: static tools/list catalog token-pricing and a context-aware concealment audit in one offline, dependency-free pass, measured against the current release candidate — I haven't found another tool that does both at this boundary. The injection and exfiltration signals in the report card are deliberately noisy hints for review, not a security boundary; byte-pinning is trust-on-first-use, so it catches post-approval mutation, not first-contact poisoning. Real defense stays defense-in-depth. This tool reports and grades; it doesn't guard.

Run it on your server. Tell me I'm wrong.

Everything above reruns from a clean checkout in under a minute, no dependencies beyond Python 3.10:

git clone https://github.com/singhpratech/mcp-turnstile && cd mcp-turnstile && python3 -m bench.token_tax

And the CLI will grade your server, local or remote: mcpturn scan --stdio -- <your server command> prints the report card and returns a CI exit code.

If your ratio comes out different from 6.6×, I want to know. If the detector false-positives on your language, I really want to know — that's the claim I've already been wrong about once, and the whole reason the battery exists. Repo: github.com/singhpratech/mcp-turnstile · docs: singhpratech.github.io/mcp-turnstile · full benchmark write-up with sources in report/BENCHMARK.md.

Top comments (0)