Research /

What my MCP server actually did vs what the docs promised

A field report from a live MCP connector: what the protocol documentation promised, what the server actually did, and the marker-string test that settles the difference.

Measured against: Claude Desktop and the Claude mobile app · @modelcontextprotocol/sdk 1.30.0 · spec revision 2026-07-28 · observations from 6 to 10 August 2026. Everything below is one server on one vendor’s host. I have not tested any of it elsewhere, and where a vendor fixes something I will strike it through here rather than quietly delete it.

Earlier today I opened my own connector on my phone, asked it an ordinary question, and the assistant printed a warning above the answer. It had detected an attempt to inject malicious instructions into the conversation.

The attempt was mine.

Nothing was compromised and nobody was attacked. What actually happened is more interesting than that, and it is the reason I now distrust every sentence of protocol documentation until I have watched the behaviour myself.

I have been building a small assistant for a private course since the start of August; it went live on the seventh. It runs behind a real login and the practice journal is keyed to the account, so nobody can reach anyone else’s notes. Right now there is exactly one account and it is mine. That is the honest size of this: a real deployment with a user base of one.

Those few days turned up four things the documentation had not prepared me for. Two of them are documented behaviours that are simply not true on this host.

The method, because it is the transferable part

When you need to know whether something reaches a language model, you do not read the specification. You put a unique nonsense string into the channel, something that exists nowhere else in the world, and then you ask the receiver whether it can see that string. The answer is yes or no. A model cannot produce a plausible-sounding fake of a token it has never seen.

The whole discipline is that one move, plus one rule: write down what you expect before you run the test. Otherwise every outcome looks like confirmation afterwards. I have argued the same thing about measuring SEO edits, and it turns out to be the same discipline: freeze the expectation, then let the system disagree with you.

Everything below came out of that.

The MCP instructions field: on this host, the model never sees it

(Claude Desktop, tested 2026-08-06.)

An MCP server can declare an instructions field. It reads exactly like the place where you tell the assistant how your server is meant to be used. I put a marker string in it and asked the model, in several phrasings, whether it could see the string.

It could not. Not once.

So anything written there was decoration. Everyone’s first instinct, mine included, is to put the important rules in the most semantically obvious field. The obvious field was inert.

Three channels into the model. The instructions field stops dead and a deferred tool’s description loops back into itself; only the text a tool returns arrives, and that is the channel where an instruction reads as an attack.
Figure 1: The only channel that reliably reached the model is the one where an instruction reads as an attack, which is why the rule ended up being a constraint on tone rather than on plumbing.

When a tool is deferred, its own description cannot summon it

(This host, two occurrences, 2026-08-07 and 2026-08-08.)

Hosts do not always hand the model every tool at once. Some are deferred and fetched on demand. Which means a deferred tool’s description cannot tell the model when to use that tool. The model has to already be reaching for the tool in order to read the instruction telling it to reach.

I learned this from an empty file. My connector has a tool that writes a journal entry, and its description said, clearly, “call me at the end of every session”. Sessions ended. Nothing was written. The model never called the tool, so it never read the description telling it to.

Two fixes worked. The obligation moved into the response text of a neighbouring tool that was already loaded. And the first phrase of the description had to be written in English, because the host’s tool search is queried in English regardless of what language the conversation is in.

Why my server opened a new session on almost every tool call

(My own twenty-minute session against the live server, 2026-08-10, read back from its HTTP log.)

I built the server assuming that one conversation maps to one session, because that is how it reads in the docs and how any sane person would design it.

In the log, that single conversation made twelve tool calls across more than fifty HTTP requests, with a different session identifier on nearly every one. Here are six of them, straight out of the log: 72ea2853, 532215be, 6f612f1b, 230425cd, 04fed185, 77020fef. Anything I had stored in a per-session variable was gone by the next call.

The fix was to key state to the user instead of to the session. Then I read the specification revision published on 28 July, three weeks before I wrote any of this code, and found that it removes protocol-level sessions entirely. I had not anticipated the direction of travel. I had been dragged into it by a host bug, and only noticed afterwards that the migration I will owe the protocol is a migration I had accidentally already done.

What one of my MCP conversations cost on the wire

(Same log, same conversation.)

This one is not a bug. It is a cost that never appears in anyone’s estimate because nobody looks.

Before each tool call, the host re-fetches the list of available tools: 5,698 bytes, every time. Over a twelve-call conversation that is about 68 kilobytes of pure repetition, alongside the actual work, which was between zero and four milliseconds per call for everything except one corpus search that took 470.

Twelve identical blocks of 5,698 bytes, the same tool list re-sent before every call in one conversation, adding up to about 68 KB of pure repetition.
Figure 2: Protocol overhead, not payloads, dominated the traffic, which is an argument for a small tool set that has nothing to do with prompt quality.

If you are sizing a deployment, your traffic is dominated by protocol overhead, not by your payloads. Every tool you add is paid for on every call, by every user, forever. That is an argument for a small, carefully chosen set of tools which has nothing to do with prompt quality.

And the warning I saw on my own phone

(This host, 2026-08-10, fixed and redeployed the same day.)

Which brings me back to the beginning.

Because the legitimate channel was inert, and tool descriptions were unreliable, I had routed my behaviour rules into the one channel that demonstrably arrived: the text my tools returned. Every substantive response carried a short frame of instructions. It worked. For four days it was the only reason the assistant behaved.

Then a security scanner on the host’s side read that frame the way a security scanner does. It saw imperative commands addressed to the model, sitting inside data. It saw an instruction to distrust other sources, because my journal had to win over the assistant’s own memory. And it saw a line telling the model that if a particular tool was not in its set, it should go and load it.

That last one is not merely suspicious. Telling a model to pull in extra tools from inside a tool’s output is the textbook description of a tool-poisoning attack. I had hand-written an attack signature, for good reasons, and shipped it.

The rule I landed on fits in one sentence. A tool result may describe data. It may not command the model. Everything imperative moved into the tool descriptions, which the host registers from me at connect time and therefore attributes to the server operator rather than to arbitrary content, and into the source material, where a rule belonging to the subject matter reads as a fact rather than as an order from me.

One rule in that frame was a safety rule, and safety rules are the ones you must not casually drop while tidying. Before deleting it I opened the source material and confirmed the author had written the same constraint there in full. Only then did the duplicate go.

The frame itself shrank from 1,043 characters to 509, which is roughly 130 tokens saved on every tool call, as a side effect of making the text honest.

The fifth thing, which is not about my server

On 28 July 2026 the protocol got a new specification. It made the core stateless, removed session identifiers, and deprecated the old transport with a twelve-month runway.

The TypeScript SDK that would implement it was published on 27 July, the day before, and tops out at the November 2025 protocol. There is no beta channel. So nobody is migrating a TypeScript server to the July specification right now, because the tooling to do it does not exist.

Correction, 11 August 2026. Both struck sentences are wrong, and they were wrong on the day I published them. I had checked one package. The v2 line shipped two weeks earlier under new names, @modelcontextprotocol/core, /server, /client and /node, all at 2.0.0 on 27 and 28 July, while @modelcontextprotocol/sdk stayed behind at 1.30.0. The beta channel I said did not exist had been running on the new name since 30 June, after alphas since 1 April.

The July revision is implemented there, not merely announced. The published build of @modelcontextprotocol/server carries FIRST_MODERN_PROTOCOL_VERSION = "2026-07-28", a separate 2026 wire codec, a server/discover handler installed on modern instances, and an HTTP path that answers modern traffic; the client package ships the matching negotiation probe with a legacy fallback. Whether any host speaks it yet is a separate question, and I have not measured that one.

How I got there is worth more than the error. The old package still reports 1.30.0 as its latest, is not marked deprecated, and its README still says it implements the full specification. The rename shows up only in the repository releases. And the same trap survives into v2, because LATEST_PROTOCOL_VERSION in @modelcontextprotocol/core 2.0.0 still reads 2025-11-25, with a comment in the source explaining that the modern revision is kept out of that constant on purpose so it can never leak into a 2025-era handshake. Grep for the version and you get the old answer twice, in two different packages, both times looking authoritative. That is the same failure as everything else in this article. A channel exists, the message does not arrive. The difference is that this time I was the one who missed it.

If you have a clock running, that is worth knowing before you plan a sprint.

The checklist this turned into

If you run an MCP server, this is what I would check, in this order. Each item exists because something surprised me, not because a document recommended it.

  1. Does your behaviour actually arrive? Put a marker string in each channel you rely on and ask the model to repeat it. Do this per host and per mode: hosts differ, and so can a vendor’s own web and desktop clients.
  2. Grep your tool results for second-person imperatives. If a security filter would read them as commands to the model, you have shipped an attack signature. Move them to descriptions.
  3. Never instruct a model to load additional tools from inside a tool result. There is no phrasing that makes this look innocent.
  4. Check what happens with no session. Assume the host will open a new one on every call, and will also hand you a session identifier you retired an hour ago.
  5. Measure your protocol overhead. Your payloads are probably the smaller half. Count the bytes on the wire for one real conversation.
  6. Check the discovery path you publish against the current draft, not against the blog post you read six months ago.
  7. Check whether your SDK can even speak the specification you are targeting before you promise a migration date, and check whether it moved to a different package name while you were not looking. npm view on the old name will not show you a rename; the repository releases will.

Point 6 has a confession attached, and I only found it while writing this article. This site publishes a discovery document at /.well-known/mcp.json. That path comes from an earlier proposal. The current draft extension, updated a week ago, specifies /.well-known/ai-catalog.json with its own media type, pointing at a Server Card. I had been serving a stale convention on a site whose entire argument is that I check things. It is fixed as of today: both paths are served, because both are draft-stage and I would rather carry two files than guess which one clients will follow. I have made the same kind of mistake before, in the other direction, when I measured whether anything actually reads llms.txt and found the answer was nothing at all. Both paths are live as of this morning. I will not know which one was right until a client actually fetches one.

Changelog

  • 2026-08-10: first published. Discovery path fixed the same day (see point 6).
Serhii Kravchenko

Non-technical founder who went all-in on AI. I write AWRSHIFT about agent systems, AI search, and building real things from zero. Co-founder of a stealth venture built to cut content and site-ops costs by an order of magnitude without adding headcount.