BlockRun
Back to Signal
Sep 2026

We Audited Our Own MCP Server. We Failed.

Tool schema crowding an agent's context window before the first user message

TL;DR — Uber cut cost per session 52% with the model held constant, and one of its findings was that MCP tool schemas were adding 50–70K tokens to every turn. We ran the same measurement against our own server: 13,200 tokens across 20 tools, about 660 per tool. For scale, Uber cited roughly 22K across 49 tools for a third-party suite — quoted from their post, not re-measured by us. We are not the biggest MCP server. We are a dense one. Descriptions — not parameter schemas — were 53% of it. The harness is in this post; run it on your own server.

Uber's engineering team published Running a Software Factory Efficiently at Uber Scale, and the number worth stealing is this: cost per 1,000 model requests down 34%, cost per session down 52% — with the model held constant. None of it came from a cheaper model or a better rate. As they put it, "the vendor sets the token price." Everything they saved came from the other terms in the equation.

One of those terms was MCP. Their agents carried 100+ tools, and the schema for them was adding 50–70K tokens to every single turn.

We ship an MCP server. So before writing a word about anyone else's context bloat, we pointed the same measurement at our own.

We came out worse than we expected.

You have not seen what you ship

The finding under every other finding in this post:

The schema the model receives is generated, not written. If you have only ever read your source, you have not seen what you ship.

You write a zod schema and a description string. What reaches the model is the output of a conversion you did not perform, wrapped in framing you did not author, prefixed by the host, and re-sent on every turn of every session. There is no view of that in your editor. The only way to know what it costs is to capture it off the wire and count it.

We had never done that. When we did, two of the three largest line items were things nobody on the team had decided.

What we measured, and how

We spawned the built server over stdio, ran a real MCP handshake — initialize, notifications/initialized, tools/list — and captured the exact JSON a client receives. Then we tokenised the model-visible projection of each tool: {name, description, input_schema}, including the mcp__blockrun__ prefix the host prepends. That projection is what actually lands in the API tools array, which is the thing that occupies your context.

Two notes on rigour:

  • Raw tools/list on the wire runs about 475 tokens heavier than the projection — annotations, present on all 20 tools, and _meta.ui on two. The host consumes both and does not forward them to the model. Counting them would have inflated the headline by 3.7%, so we don't.
  • We tokenised with o200k_base. Claude's tokenizer isn't public and typically runs a few percent higher on JSON-ish text, so treat every figure here as a slight under-count.

The number

Measured on the default profile, before we changed anything:

ProfileToolsModel-visible schema
full (default)2013,200
trading95,689
media75,541
research63,114
chat31,969

Descriptions were 7,019 tokens of that — 53%. Input schemas were 5,540, or 42%. Names and JSON framing made up the rest.

That split is the story. This is not a case of over-specified parameters or sprawling enums. It is prose. More than half of what our server puts in front of a model is English that somebody wrote into a description field and never measured.

The top five tools were 46% of the total — again as measured, before any fix:

ToolTotalDescriptionSchemaProps
blockrun_video1,63972088810
blockrun_markets1,2741,0411784
blockrun_image1,1385076039
blockrun_chat1,07428776111
blockrun_polymarket94444746415

For scale: Uber cited about 22K tokens for a 49-tool third-party workspace suite. We measured 13,200 across 20 tools, about 660 per tool.

We are deliberately not turning that into a multiple. Our figure is a tightly defined projection — {name, description, input_schema}, host prefix included, annotations, _meta and outputSchema excluded, o200k_base, measured off the wire. Theirs is a round number quoted from a blog post, and we do not know what it counted. If it included annotations or output schemas, or used a different tokenizer, or was rounded from 19K or 24K, the ratio swings by more than our entire $schema fix is worth. A post arguing that people quote schema numbers they have never measured should not headline a multiple against a denominator it did not measure.

The two patterns we got wrong

Both of our worst offenders are self-inflicted, and neither is subtle once you look at the wire instead of the source.

Route catalogues in the description. blockrun_markets spends 1,041 tokens listing about 55 endpoints — 4,009 characters of prose — to document a schema with four properties: {path, params, body, agent_id}. The description is nearly six times the schema it describes. Worse, 26 of those routes are already documented in a skill that loads on demand. We were shipping the same documentation twice and paying for the copy that loads unconditionally.

Pricing tables in the description. blockrun_video carries a seven-model, per-second pricing table worth 720 tokens. blockrun_image and blockrun_realface do smaller versions of the same thing. That data is live-queryable through blockrun_models, which costs 109 tokens in total — and unlike a table pasted into a description, it doesn't drift. The table is simultaneously the expensive option and the one that goes stale.

The pattern behind both: a tool description is not documentation. It is the text a model reads to decide whether to call this tool. Anything that isn't helping it make that decision is rent, charged against every session, forever.

If you go looking on your own server, start with your longest description. In our case the top two were a catalogue and a price list — neither of which any model needed in order to choose the tool.

The illustration: a dead header nobody wrote

The smallest finding in the audit is the one that best demonstrates why you have to measure the wire.

Every one of our 20 tools was emitting this at the top of its input schema:

"$schema": "http://json-schema.org/draft-07/schema#"

Nobody wrote that. It is a JSON Schema dialect declaration. In @modelcontextprotocol/sdk 1.29.0, McpServer.setToolRequestHandlers() installs the tools/list handler, and that handler converts each tool's zod schema at list time — emitting the header on the way out. It is dead weight in the tool block the model carries every turn, and it is invisible from the server's own source code.

How dead, stated exactly as strongly as we can support it: no code path in the reference SDK reads it, its bundled validator never receives it — ajv is invoked on outputSchema and on elicitation schemas, never on inputSchemaand compiling all 20 of our schemas with and without the header, against five input samples each, produced identical validation verdicts on 100 of 100 pairs. We did not instrument closed-source clients, so we are not claiming every client ignores it; we are claiming the reference implementation demonstrably does.

Worth one footnote, because it cuts slightly against the "harmless" reading: ajv normally uses $schema to select a dialect, and a draft-2020-12 URI handed to a draft-07 instance throws. The header is inert here because the SDK emits draft-07 and configures ajv with validateSchema: false. That is "happens to be fine" rather than "cannot matter" — which makes removing it more attractive, not less.

It is ~15 tokens per tool — 300 for our 20, and about 735 on a 49-tool server of the kind Uber cited. That is 2% of our block. It is not the story. It is the proof of the story: we could not have found it by reading our code, because it isn't in our code.

Its presence was verified rather than inferred. Both zod branches of @modelcontextprotocol/sdk 1.29.0 were executed directly:

import { toJsonSchemaCompat } from "@modelcontextprotocol/sdk/server/zod-json-schema-compat.js";
import * as z4 from "zod";
import * as z3 from "zod/v3";

toJsonSchemaCompat(z4.object({ a: z4.string() }), { pipeStrategy: "input" }).$schema
// → "http://json-schema.org/draft-07/schema#"
toJsonSchemaCompat(z3.object({ a: z3.string() }), { pipeStrategy: "input" }).$schema
// → "http://json-schema.org/draft-07/schema#"

Both emit it, so which version of zod you are on makes no difference.

Scope, precisely: this affects servers that define tools with zod via registerTool — the documented, idiomatic path, which is most SDK-built servers but not all. A server passing raw JSON Schema is unaffected unless it adds the header itself.

The SDK exposes no option to suppress it, and the conversion happens inside a handler the SDK installs. The fix that needs no fork, no node_modules patch and no private-field access is to wrap the tools/list handler as it is installed, using only the public setRequestHandler — called before registering any tool, since that is when the SDK lazily installs it:

import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";

export function stripJsonSchemaDialect(server: McpServer): void {
  const lowLevel = server.server;
  if (typeof lowLevel?.setRequestHandler !== "function") return;
  const original = lowLevel.setRequestHandler.bind(lowLevel);

  lowLevel.setRequestHandler = ((requestSchema, handler) => {
    if (requestSchema !== ListToolsRequestSchema) return original(requestSchema, handler);
    return original(requestSchema, async (...args) => {
      const result = await handler(...args);
      for (const tool of result?.tools ?? []) delete tool.inputSchema?.$schema;
      return result;
    });
  }) as typeof lowLevel.setRequestHandler;
}

Two properties make it safe to ship, and both are worth copying along with the code. It is never load-bearing — the wrapper is identity-matched against ListToolsRequestSchema, so if a future SDK stops routing through it, the wrapper stops matching and the header simply comes back. Degraded output, never a crash. And it no-ops when there is nothing to wrap: test suites often pass a minimal fake server with only registerTool, and an earlier version that assumed server.server exists broke eight tests. An optimisation that throws is worse than no optimisation.

Guard it with a test. An identity-matched wrapper can be quietly undone by a dependency bump, and a saving that reverts unobserved is worse than one never made.

The version we are shipping is in blockrun-mcp#125 — open at the time of writing — if you want the real thing rather than the illustrative one.

Three corrections that keep this honest

This is where posts on this topic overclaim, and where we nearly did.

Tool schemas are prompt-cached. They sit at the front of the prompt, so after the first turn they re-send at cache-read rates — roughly a tenth of the input price — not at full price. Anyone writing "13.2K tokens re-sent every turn at full price" is wrong by close to an order of magnitude and will be corrected within a day.

The true claim is narrower and still bad: 13.2K tokens of your context window, permanently, before the user types anything. The context cost is 100% every turn even when the dollar cost is not. The harm is crowding — that budget is not available for the file the agent is editing — and it is tool-selection accuracy, which degrades as the catalogue grows.

Claude Code already defers MCP tool schemas. In the session that ran this audit, all 20 of our tools appeared as names only, with schemas fetched on demand. For those users our 13.2K is approximately zero. The real exposure is every client without deferred loading — Cursor, VS Code, Claude Desktop, Codex, Gemini CLI. A post claiming every host pays this would be wrong.

An estimate is not a measurement — including ours. The fix table below once carried a sixth row: shorten a repeated agent_id description across 17 tools, about 200 tokens, minutes of work. Re-deriving it on the wire killed it. The property costs 353 tokens across those tools, but 173 of that is irreducible — the key name, "type":"string", JSON framing — leaving 180 tokens of actual description text. Deleting all 13 identical descriptions outright would cap out at 104 tokens, and a rewrite that preserved the meaning saved 2 tokens per tool: 26 tokens in total.

We didn't do it. agent_id is the parameter that gates budget enforcement, and 26 tokens does not justify any risk of giving the model vaguer guidance about it.

That row was our own estimate, inside our own audit, and it was off by roughly 8×. It is this post's thesis turned back on the post: we guessed instead of measuring, and the wire disagreed. Not every line item survives that contact, and the discipline is knowing which ones to drop — an audit that only ever finds savings is an audit that is selling you something.

What we're changing

FixSavingEffort
Move the blockrun_markets route catalogue into its skill, leaving a pointer and the request-contract gotchas~790half day
Move video / image / realface pricing tables to blockrun_models; keep model names and hard constraints only~900half day
Trim wallet, modal, polymarket prose to the operative facts~600half day
Drop the $schema header on all 20 tools300minutes
Collapse repeated model prop descriptions across 5 tools to an enum plus one line~1501 hour

About 2,700 tokens — 21% — in roughly a day and a half. Halving every description over 300 tokens would reach ~5,000 tokens and about 38%, and we may go there. Nothing in that table changes any tool's behaviour.

What we have actually done, so far, is the 2%. The $schema fix is up as blockrun-mcp#125 — open and awaiting review as this is written, not yet merged. It takes the default profile to 12,900: every tool exactly 15 tokens lighter, which moves per-tool density from 660 to about 645 and pushes descriptions from 53% to roughly 54% of what remains. Descriptions themselves do not move at all — the numerator is unchanged, the denominator shrank.

The description work — the ~2,400 tokens that actually matter, and the part this post is most critical of us about — has not started at the time of writing.

That is the uncomfortable shape of an honest audit: here is what we found, here is the 2% we have a patch open for, here is the 19% we have not touched. If a follow-up post never appears with those numbers moved, hold it against us.

And one thing we should have been saying louder for months: the --profile flag already ships. --profile trading is 5,554 tokens against 12,900 — a 57% cut, available today, that our own README does not lead with. If you use our server for one thing, load one profile. The best available fix was already built, and we undersold it.

Run it on your own server

The measurement is not clever, which is the point: a real handshake, a projection down to what the model actually sees, and a tokenizer. Here it is in full, so you never have to take our number on trust — including against a future version of our server, to check whether we did what this post says we would.

No dependencies, works against any stdio MCP server:

// dump-tools.mjs — node dump-tools.mjs -- node dist/index.js > tools.json
import { spawn } from "node:child_process";

const sep = process.argv.indexOf("--");
const cmd = sep === -1 ? [] : process.argv.slice(sep + 1);
if (cmd.length === 0) {
  console.error("usage: node dump-tools.mjs -- <server command...>");
  process.exit(1);
}

const child = spawn(cmd[0], cmd.slice(1), { stdio: ["pipe", "pipe", "inherit"] });
const pending = new Map();
let nextId = 1;
let buf = "";

child.stdout.on("data", (chunk) => {
  buf += chunk;
  let i;
  while ((i = buf.indexOf("\n")) !== -1) {
    const line = buf.slice(0, i).trim();
    buf = buf.slice(i + 1);
    if (!line) continue;
    let msg;
    try { msg = JSON.parse(line); } catch { continue; }
    const resolve = pending.get(msg.id);
    if (resolve) { pending.delete(msg.id); resolve(msg); }
  }
});

const send = (m) => child.stdin.write(JSON.stringify(m) + "\n");
const request = (method, params) =>
  new Promise((resolve) => {
    const id = nextId++;
    pending.set(id, resolve);
    send({ jsonrpc: "2.0", id, method, params });
  });

await request("initialize", {
  protocolVersion: "2024-11-05",
  capabilities: {},
  clientInfo: { name: "schema-audit", version: "1.0.0" },
});
send({ jsonrpc: "2.0", method: "notifications/initialized" });

const { result } = await request("tools/list", {});
child.kill();

// The model-visible projection: exactly what lands in the API `tools` array.
// Set MCP_PREFIX to whatever your host prepends (e.g. "mcp__blockrun__").
const prefix = process.env.MCP_PREFIX ?? "";
const tools = (result?.tools ?? []).map((t) => ({
  name: prefix + t.name,
  description: t.description ?? "",
  input_schema: t.inputSchema,
}));

console.log(JSON.stringify(tools, null, 2));

Then count it. One dependency, npm i gpt-tokenizer:

// count-tools.mjs — node count-tools.mjs tools.json
import { readFileSync } from "node:fs";
import { encode } from "gpt-tokenizer/encoding/o200k_base";

const tools = JSON.parse(readFileSync(process.argv[2], "utf8"));

let total = 0;
const rows = tools.map((t) => {
  const tokens = encode(JSON.stringify(t)).length;
  total += tokens;
  return {
    tool: t.name,
    tokens,
    description: encode(t.description).length,
    schema: encode(JSON.stringify(t.input_schema)).length,
  };
});

rows.sort((a, b) => b.tokens - a.tokens);
console.table(rows);
console.log("TOTAL", total, "across", rows.length, "tools");
console.log("descriptions", rows.reduce((n, r) => n + r.description, 0));

One caveat that applies to every number in this post as much as to yours: Claude's tokenizer is not public. These figures use o200k_base, which on JSON-ish text tends to run a few percent below Claude's actual count. So treat what you measure as a floor, not a ceiling — which is the direction we would rather err in a post criticising ourselves.

And one trap that cost us an hour, which will cost you the same hour if you reach for Python: count the projection with the same JSON encoding the wire uses. Python's json.dumps escapes non-ASCII by default, so every , and in your descriptions becomes six ASCII characters before the tokenizer ever sees it. Two of us measured these same 20 tools and came out 565 tokens apart for precisely that reason — an em-dash that exists nowhere except inside one of the measurements. The tell that cracked it: of 20 tools, exactly two diverged by zero — dex and models, the only two whose descriptions are pure ASCII. JSON.stringify does not escape, and neither does the wire. If your count disagrees with a colleague's while your description totals match to the token, this is why.

Three things worth checking tonight if you maintain one:

  1. The dead $schema header. Very likely present, minutes to remove.
  2. Your longest tool description. If it is a catalogue, a price list or a tutorial, it belongs in a skill or a live query — not in the prefix of every turn.
  3. Repeated property descriptions. One agent_id string across 17 tools costs us 353 tokens — though as above, measure before you assume that is recoverable. Most of it turned out to be JSON framing we cannot delete.

We publish 96 models and 183 data and tool endpoints. Keeping that surface addressable without making every agent carry a catalogue is the real engineering problem, and this audit says we had not solved it. We had just been paying for it somewhere the bill never showed up.

Uber's caveat applies to us as much as to them: one environment, and mileage varies. What travels is the method, not our number.

All articles →