DEV Community

Cover image for One question, 437,000 tokens: what real agents found in our MCP server
Alexander Lukashov
Alexander Lukashov

Posted on Originally published at foxnose.net

One question, 437,000 tokens: what real agents found in our MCP server

One question. 437,000 input tokens.

Not a hard question either. An agent connected to our MCP server, asked something a support engineer answers in a sentence, and worked its way there through twenty tool calls, each one dragging every earlier answer along behind it.

Nothing was broken while that happened. The server answered initialize correctly, spoke the 2025-03-26 revision, returned valid JSON-RPC to everything we threw at it. All of which turned out to be beside the point.

So we pointed real agents at production and watched. 18 scenarios, two vendors, a 5 dollar budget that we topped up once. This is the long version with the traces in it. There is a shorter one on our blog if you only want the conclusions.

What the server is

Briefly, because it shapes everything below.

FoxNose stores content as collections: schema-defined records with typed fields, some of them vector indexed. The MCP server is generated from that schema and served from the same URL prefix as the REST API. Fixed catalog of seven tools regardless of how many collections exist, five read and two optional write.

Two of those properties matter below. Collections are what an agent chooses between, so a badly described collection is effectively invisible. And the agent inherits exactly the rights of the API key it connects with, so there is no second allowed-tools list drifting out of sync with the first.

What the harness actually is

A scenario is a question in plain English, a set of tools, and a check.

The checks are where we made the most mistakes, so start there. They do not look at the answer text. Model output moves between runs, and a suite that asserts on wording is a suite you quietly stop trusting. They look at the trace: which tools ran, in what order, with what arguments, which errors came back, how many tokens the whole thing burned.

A check is a small predicate over the run:

any_of(
    no_tool_errors(),
    recovered_after("unknown_resource", then="search_records"),
)
Enter fullscreen mode Exit fullscreen mode

That second branch exists because of a mistake I will get to at the end.

We ran everything twice, on Anthropic's hosted MCP connector and on OpenAI's hosted MCP in the Responses API. Same server, same questions, two clients.

Finding 1: the bill is round trips, not bytes

Back to those 437,000 tokens.

A hosted connector runs the tool loop on the vendor's side. Every iteration re-sends the whole conversation to the model. So a tool call does not cost what it returned. It costs what it returned, times the number of turns after it.

Each connector iteration re-sends the whole conversation, so every earlier result is paid for again

Input tokens per scenario, before and after we shipped truncated search results and changed the page size:

scenario before after
R4 437,751 78,072
N5 175,421 26,078
R5 128,800 17,054
R8 115,027 12,607
R7 93,770 123,831

Same questions, same collections, same models. The only thing that moved is how much each call handed back and how many calls it took to get there.

R7 went the wrong way, and it is the clearest evidence for the point. It compares two collections, five hits each was not enough, so the agent searched again and again and finished at fourteen tool calls. A smaller page moves cost out of the payload and into the turns, and the turns are the expensive part. One scenario out of nine moving backwards was a trade we took, but it is the shape to watch.

These numbers are from the Anthropic connector. Every scenario ran on both, but the token comparison above is single vendor.

Twenty calls against fourteen is not twenty over fourteen. Everyone tunes model choice. Almost nobody counts round trips.

What we shipped. Text fields in search results are capped at 1000 characters by default, and every field that got cut carries a marker with its path, its locale, and the original length. So the agent knows per field whether it is holding a fragment. An 11,917 character body arrives as 1000 and says so. get_record hands over all 11,917.

Page size went to a default of 5 with a maximum of 100, and both numbers live in the tool's JSON schema, so the model reads the range in the catalog before its first call.

The number was never the fix. Publishing the bounds was. We could not choose a good page size for everybody, so we stopped trying and told the caller the range instead.

Finding 2: your error strings are control flow

An agent hit an error, read the hint attached to it, did exactly what the hint said, failed the same way, read the same hint again. No crash. Nothing logged as wrong. It spent its entire iteration budget being obedient to a wrong instruction.

The agent follows a hint that points at the wrong failure and loops, while the real cause is never stated

One line out of place caused it. We return structured errors, an error code plus a message plus a hint saying what to do next, and one code had been handed a different error's hint. Code right, status right, wrong sentence.

A person reads a wrong hint and shrugs. A model does not shrug. The hint is the next instruction.

Here is the same mechanism working, which is the part I actually want to show you. Real trace, from a scenario where the agent guessed:

discover_resources                                  ok
search_records  resource_id="documentation"         unknown_resource
                hint: call discover_resources and reuse exact resource_id
search_records  resource_id="kb_foxnose"            ok
get_record      MbJbdnzj9fJw                        ok
Enter fullscreen mode Exit fullscreen mode

Four calls. It invented an identifier, got told exactly how to recover, recovered, answered. The whole exchange costs less than one wide search. Cheap recovery is something you build on purpose, and it only works when every hint points at its own failure.

Finding 3: a JSON-RPC notification is defined by the absent id

Small, pure protocol, which is why it lives here and not in the short version.

In JSON-RPC 2.0 a notification is a request with no id member. That is the entire definition. You must not reply to one, and you must reply to everything else.

Our server was deciding by method name. Anything starting with notifications/ got treated as a notification.

Those two rules agree nearly always, which is how it survived as long as it did. They part company when a client sends notifications/initialized with an id. That is a request. The spec says answer it. We sent silence.

No client we tested does this. It is the kind of thing that surfaces a year from now, in an integration you did not write, with a symptom that looks nothing like the cause. We key off the presence of id now, like the spec says.

Finding 4: the client decides your authentication

We had two auth schemes, both requiring you to write your own Authorization header. Never a problem, because every client we developed against was one where we wrote the header ourselves.

Hosted connectors do not work that way. One token field, always sent as Authorization: Bearer <token>. That is the whole interface. No configuration, no workaround.

So our server could not be used from the two clients most people reach for first. Not broken. Unusable. And invisible from the inside, which is the part that stuck with me.

What we shipped. Bearer tokens: opaque, 47 characters, no colon so they cannot be confused with a public:private pair, shown once at issue, bound to an existing API key and exactly as powerful as it.

Then we measured how long it actually takes to cut someone off:

deleting the key            took effect after 305 s
emptying the key's role     took effect after 284 s
revoking a bearer token     took effect on the next request
Enter fullscreen mode Exit fullscreen mode

The first two are a permission snapshot cache expiring. Revoking the token skips it, because the token itself stops resolving. Worth knowing before the day you need it.

Finding 5: two vendors, two shapes

We wrote the OpenAI transport after the Anthropic one and assumed it was the same code with the nouns swapped. It would have been wrong in three places.

Tool call arguments arrive parsed in one and as a JSON string in the other, so you parse in one transport and must not in the other.

The OpenAI SDK types the error field on an mcp_call as an optional string. The wire sends a dict:

{"type": "mcp_tool_execution_error",
 "content": [{"type": "text", "text": "<json>"}]}
Enter fullscreen mode Exit fullscreen mode

Trust the annotation and you will eventually call a string method on a dict, in a branch that only runs when something has already gone wrong. Great place for a second bug.

The third one will bite anyone building on the Anthropic connector. It runs at most 10 tool iterations per request. If the agent has not finished, you get stop_reason: "pause_turn" with the partial work. HTTP 200. No error field anywhere.

A client that only checks for end_turn silently accepts a truncated answer; handling pause_turn continues the loop

A client that checks only for end_turn records a success and shows a truncated answer. We caught it because a scenario answered half a question and the harness passed it.

Two things about the tests themselves

A check that forbids all errors forbids recovery. Our no_tool_errors check failed three scenarios that were completely fine. In each one the agent guessed, got a hint, corrected itself, answered. The trace higher up in this post is one of them.

We had spent real effort making guessing cheap, then written a check that punished guessing. That is where the any_of at the top comes from. If your surface is meant to be explored, your tests have to let it be explored.

A negative result is not a result. We checked that a key from one environment cannot read another. It could not, and I nearly wrote that down as proof of isolation.

Then we checked whether the key could read anything at all. "Access denied" looks identical whether the isolation held or the credential was already dead. This caught us three separate times: isolation, bearer auth, revocation. Each of those needed a positive control sitting beside it, a case you know should succeed. Without one, all you have proven is that broken things stay broken.

What it cost

Under ten dollars of API spend across about ten days. The harness is a few hundred lines. Reading traces took far longer than running them.

Final state is 18 out of 18 on both vendors, against production.

If you are shipping an MCP server: take five real questions, run them through a hosted connector, and read the traces rather than the answers. Count the calls. Look at what each one handed back. Then ask whether a model that cannot see your dashboard could have done better than that.

Updated after publishing: added the one scenario that regressed, and noted that the token numbers come from the Anthropic connector.

Top comments (12)

Collapse
 
vinhnguyenthanhdn profile image
Vinh Nguyen

R7 is the only scenario in your table that regressed and the only one you give a call count for, and I think those two facts connect through Finding 5 rather than through payload math. Fourteen tool calls sits on the far side of the Anthropic connector's ten-iteration cap, so R7 is the one scenario that had to come back as pause_turn and get resumed, and a resume re-sends the whole conversation the same way an iteration does. That turns crossing the cap into a step on top of the linear payload saving: the scenarios that stayed under ten got their reduction, and the one that crossed paid an extra full-context re-send that no page size buys back. It also means the page-size default and the pause_turn handling are the same knob measured twice, which the any_of check cannot see either way, because a paused turn produces no tool error for no_tool_errors and nothing for recovered_after to match against.

The cheap check is to log tool-call count and stop_reason beside the token number for each scenario, then see whether the regression only ever appears above ten iterations and whether the excess looks like one context re-send rather than something proportional to the page change. I have not run your harness, so this is arithmetic on your published table plus the cap you documented in Finding 5.

Collapse
 
alexander_lukashov profile image
Alexander Lukashov

Yeah, I think you've got it.

Two things from the runs you didn't have. R1 and C6 both came back at eleven tool calls with stop_reason pause_turn, so the cap bites right about there. R7 finished at fourteen, which means it was continued at least once. So it's the one scenario in that table that definitely paid for a resume.

Whether that's the whole story I can't say, because I never logged stop_reason and call count next to the token numbers. Which is a stupid thing to have left out, since both are sitting right there in the run object. They just never made it into the comparison.

The check point is the one that stings. A paused turn throws no tool error, so no_tool_errors sails straight through and recovered_after has nothing to match on. We already got burned by this once: a scenario answered half a question and the harness said pass. We fixed detection in the transport afterwards and never pushed it down into the scenario checks. So the exact class of bug you're describing is still invisible to them.

And if you're right, the line in the post about small pages converting payload cost into turn cost isn't wrong, it's just not the main term. One cap crossing is.

Collapse
 
svgicons profile image
Svg/icons

The truncation markers seem key. Did you also test whether agents reliably switch from search to get_record once a field is marked incomplete, across both vendors?

Collapse
 
alexander_lukashov profile image
Alexander Lukashov

Not isolated, no.

We did separate truncation from the page size change. Two scenarios ran with
truncation on and the old page size, and those roughly halved on their own,
so truncation's own effect is measured.

The marker's effect is not. Every one of those runs had markers on. To know
whether the marker drives the follow-up you would need truncation with
markers against truncation without them, same scenarios, same everything,
and compare how often the agent goes to get_record. We never ran that, so
the marker's contribution is confounded with truncation's.

Which is annoying, given the last section of that post is about results that
have no control sitting next to them.

On the cross-vendor half: the token numbers in the post are from the
Anthropic connector. The search then get_record sequence shows up on both,
but I do not have the token comparison on OpenAI, so I would not claim the
behaviour is identical there.

Collapse
 
svgicons profile image
Svg/icons

That makes sense , thanks for clarifying. A marker-only A/B test sounds like the cleanest next step to isolate that effect.

Collapse
 
alexander_lukashov profile image
Alexander Lukashov

Both of those are now in the post: the regression is in the table with an
explanation, and the numbers are labelled as Anthropic connector only. The
marker confound I have not fixed, only admitted, since it needs a run we
have not done.

Collapse
 
svgicons profile image
Svg/icons

That makes sense. Better to be explicit about the limitation for now and leave the comparison for a future run

Collapse
 
nyx533 profile image
Nyx533

@alexander_lukashov The JSON-RPC bug is the real finding. Most MCP servers in production have at least one spec violation nobody notices because client and server share the same blind reading. They work until they don't, and then neither side can tell which broke.

Collapse
 
alexander_lukashov profile image
Alexander Lukashov

The part I'd add: in our case it wasn't a shared misreading, it was worse than that. The wrong rule and the right rule agree on every request a real client actually sends. A method name starting with notifications/ and an absent id line up perfectly until somebody sends one without the other. So there was nothing to notice. Both sides looked correct by observation.

Which is why we only found it by probing, not by reading. We have a family of scenarios that send deliberately malformed and edge-case requests, and that is the only thing that surfaced it. Reading your own server against the spec doesn't work, because you read it the way you built it.

And we still have one open. Our initialize accepts anything: protocolVersion as an integer, clientInfo missing, params absent entirely. All of it comes back 200 with a successful handshake instead of -32602. No security or data impact, which is exactly why it is still sitting there. A malformed client just believes it negotiated, and fails later for a reason that looks unrelated.

Collapse
 
jo-do profile image
Jo Do

Watching real agents meet your endpoint for the first time is its own genre. We put a public message board for agents (msgboard.dev) online last week - one POST, no keys, an agent card at /.well-known/agent-card.json - and pointed exactly one agent at it. Within 24 hours agents we had never met were posting; one found the board by crawling the .well-known path with no human involved. The token asymmetry you describe shows up there too: agents that read the agent card get everything in one fetch, while the ones scraping the HTML burn 10x the tokens to learn less. Discovery metadata is the cheapest optimization on the internet right now.

Collapse
 
alexander_lukashov profile image
Alexander Lukashov

The .well-known part is what I'd poke at, because we measured something adjacent and it surprised us.

We were checking whether .well-known was even reachable on our own setup, and the answer turned out to depend entirely on who was asking. Claude-User got through. ClaudeBot and GPTBot were blocked at the CDN in front of us, by bot rules, nothing in the application. So an agent fetching on behalf of a person could see the metadata and the same vendor's crawler could not.

Which makes your point true with a precondition bolted on: discovery metadata is the cheapest optimization available, right up until the layer in front of you decides the thing asking for it is a bot. Almost nobody will catch that, because the metadata is obviously there when you curl it yourself.

The 10x is our effect in different clothes, I think. One structured fetch versus many scraping passes is a round trip count, and round trips are what compound.

Some comments may only be visible to logged-in visitors. Sign in to view all comments.