Type to search posts and projects ↑↓ to navigate

The Stateless MCP Spec Audited My Compiler, and My Confirmation Gate Failed

TL;DR: I ran the 2026-07-28 MCP spec against servers that api-mcp-compiler generates. Three things failed. The servers no longer import on a fresh install. The confirmation gate on destructive tools lived in process memory and could be satisfied by the agent calling twice. And the tool schemas my evaluations fed the model were never advertised by the served artifact. I rebuilt the gate on the new multi round-trip primitive and ran it across two replicas: it works, but only with a shared key the SDK does not default to, and one confirmation could be replayed three times. The spec made the right call. It moved the question to the client, and the client is where it now has to be answered honestly.

The July 28 revision of the Model Context Protocol is the largest since launch. Sessions are gone. The handshake is gone. Servers no longer send requests to clients mid-call; they return a result that says “I need more input” and let the client retry. A remote MCP server is now, in the spec’s own framing, just another HTTP workload.

In August I wrote about a compiler that turns API specifications into governed MCP tool surfaces. The governance half of that post, the part I said “was never on trial,” rests on a claim about what an agent may do without a human. A protocol change of this size is a trial whether I scheduled one or not. So I took the compiler’s own example service, approved its destructive tool the way a reviewer would, generated the server, and ran it against the new world.

What actually changed

The changelog is long. These are the items that touch a generated server, and what each one means in practice:

Change What a generated server has to do about it
Sessions and Mcp-Session-Id removed Nothing may depend on “the same connection” across calls
initialize handshake removed; version and capabilities ride every request in _meta Per-request version checks; no negotiated state to lean on
Multi round-trip requests (MRTR) replace server-initiated requests Elicitation becomes a result the server returns, plus requestState the client echoes back
requestState MUST be treated as attacker-controlled Integrity protection (HMAC or AEAD) whenever it influences authorization or business logic
Tool schemas loosened to full JSON Schema 2020-12 Composition, $ref, and conditionals can be advertised as written
ttlMs and cacheScope on list results A surface that changes when approvals change needs a TTL that respects that
x-mcp-header mirrors chosen parameters into Mcp-Param-* headers Gateways can route and enforce on a parameter without parsing bodies
Roots, Sampling, Logging deprecated with a 12-month window Stop adding them; move logs to stderr or OpenTelemetry

The python SDK shipped 2.x alongside it. It serves both protocol eras from one server, which is genuinely good engineering. It also renamed things.

Failure one: the servers do not import

Every server the compiler emits begins with from mcp.server.fastmcp import FastMCP, and the compiler’s own run instructions say pip install mcp>=1.2. On a fresh machine today that resolves to SDK 2.2.0:

$ pip install "mcp>=1.2" "httpx>=0.27" && python inventory_server.py
ModuleNotFoundError: No module named 'mcp.server.fastmcp'. This is mcp 2.x, where FastMCP
was renamed to MCPServer (from mcp.server.mcpserver import MCPServer) and other APIs changed;
see the migration guide ... or pin 'mcp<2' to keep running v1 code.

The SDK’s error message is exemplary. My requirement line was not. An open-ended lower bound in generated code is a promise that nobody is around to keep: a hand-written service has an owner who notices when a major version lands, a generated one is often deployed by someone who never read it. I now think generated artifacts should pin the major version of everything they import, and treat the upgrade as a recompile, not a surprise. That is a small fix. The next two are not.

Fixed in 0.12.0: generated servers now require mcp>=1.2,<2. The line serve prints to install them was also unquoted, so a shell read > as a redirection; it is quoted now.

Failure two: the agent could approve its own destructive call

Here is how the confirmation gate worked. A destructive tool, called for the first time with some arguments, refuses and returns a token bound to a digest of those arguments. Calling again with identical arguments inside the expiry window runs the operation. The token is single use and it expires. I was proud of that design in August.

I ran the generated server as two replicas, the way any team deploys a remote service behind a load balancer, and drove them with an ordinary MCP client on SDK 1.30:

1. replica A, first call      -> confirmation_required
2. replica B, confirming call -> confirmation_required
3. replica A, confirming call -> [gate passed; delete attempted against the upstream]
4. replica A, third call      -> confirmation_required

Line 2 is the load balancer problem. The token lives in a dictionary in one process, so a confirmation that lands on the other replica is a first call again, forever. It fails safe, which is the only kind thing I can say about it.

Line 3 is the problem I should have seen without a spec change. Nothing in that sequence involved a person. The “confirmation” is the agent calling the same tool twice, and the refusal message literally tells it to: call again with identical arguments within 300 seconds to proceed. An agent that wants to finish its task will do exactly that. The gate I described as a human approval was a speed bump for the model. The real human approval in the compiler happens at compile time, when a reviewer enables the destructive tool at all. The runtime gate added friction, not oversight, and I described it as more than it was.

Sessions never made this correct. They made it look correct on one process.

Fixed in 0.14.0: on the SDK 2.x target a person now confirms a destructive call. The server asks through an MCP elicitation, the person retypes the identifying argument, and the pending call travels in sealed request state, much like the rebuild below. A client that cannot show a person the question is refused rather than falling back to call-twice. A replica set sharing a key still refuses to start for a destructive tool that is not idempotent, until there is a shared record of spent confirmations.

Rebuilding the gate on the new primitive

MRTR is the right shape for this, and I think it is the best thing in the revision. The server does not remember anything. It answers the first call with an input_required result carrying an elicitation, packs what it needs into requestState, and the client retries with the answer and the state. The spec is specific about that state: servers MUST treat it as attacker-controlled, MUST protect its integrity when it influences business logic, and SHOULD bind it to the authenticated principal, a short expiry, and a digest of the originating request.

sequenceDiagram
    participant H as Person
    participant C as Client host
    participant A as Replica A
    participant B as Replica B
    C->>A: tools/call remove(wh-7)
    A-->>C: input_required: elicitation + sealed requestState
    C->>H: "Type the warehouse id to confirm"
    H-->>C: wh-7
    C->>B: tools/call remove(wh-7) + inputResponses + requestState
    B-->>C: complete: deleted

The python SDK 2.x has this built in. A tool parameter can be resolved by an elicitation, and a RequestStateBoundary seals and verifies the state, stamping expiry, request binding, and principal. So I rebuilt the destructive tool on it, about thirty lines, with one design choice of my own: the person does not click “yes,” they retype the warehouse id. Then I ran it as two replicas and drove the raw protocol by hand, so I controlled which replica received each request.

Scenario Result
Confirm on the same replica, SDK default key Deleted
Confirm on the other replica, SDK default key -32602 Invalid or expired requestState
Confirm on the other replica, shared key Deleted
Reuse the state for a different warehouse Rejected
Tamper with the state Rejected
Person declines Not confirmed
Agent retries without answering Asked again
Replay one confirmation three times, across both replicas Deleted, three times

Three rows are worth dwelling on.

The default key recreates the bug I was escaping. Unless you pass keys, the SDK seals state with a random per-process key. Its own docstring says multi-instance deployments must share one. That is documented, and it is also exactly the kind of default that passes every local test and fails the day you scale to two pods. The protocol went stateless. The default configuration did not. If I were writing the SDK I would refuse to start a streamable HTTP server with an ephemeral key unless someone said so explicitly, the same way my compiler refuses to emit an executable destructive tool without a recorded approval.

“Asked again” is the row that makes it a gate. Under the old design, retrying was the confirmation. Under the new one, retrying without an answer gets the same question back. The agent cannot get past it by persistence.

The replay row is a trade I would not have noticed without running it. Sealed state is valid until it expires, on any replica holding the key. The spec says this plainly: these measures “do not by themselves guarantee single-use,” and servers that need at-most-once MUST enforce it server-side. My old gate was single use because it lived in one process. The stateless gate is not, unless every replica shares a record of spent confirmations. Deleting a warehouse’s records twice is harmless. Issuing a refund twice is not. So the compiler now has a decision to make per tool, and it already knows the input it needs: operations the policy marks non-idempotent get a shared single-use record, and the generated server refuses to start without one configured.

You can replay all of this below. The first three designs reproduce the responses the real replicas gave me; the fourth is the single-use design, which I have modelled but not run.

Audit replay / MCP 2026-07-28

Who actually approved the delete?

A destructive tool behind two replicas, confirmed four different ways. Pick a gate design and how the load balancer routes, then run a scenario. The first three designs replay what I ran against real servers. The fourth is the fix I am proposing, and it is modelled, not measured.

#ReplicaRequestResponse
Deletes executed
—
A person was asked
—
Verdict
—

What each design does
  • Call twice. The first call returns confirmation_required and remembers a digest of the arguments in that process. An identical call to the same process inside 300 seconds runs the delete. Nobody else is involved.
  • MRTR, default key. The first call returns input_required with an elicitation for a person and a sealed requestState. The SDK seals with a random per-process key unless you configure one, so only the replica that minted the state can open it.
  • MRTR, shared key. Every replica holds the same key, so the retry can land anywhere. The state is bound to the arguments and expires, but it is not single use.
  • Shared key + single-use record. Adds a shared record of spent confirmations, which the spec requires when a request must run at most once. Proposed; I have not run this one.

MCP 2026-07-28, python SDK 1.30 for the first design and 2.2 for the others. Details in the audit post.

The limit the protocol cannot fix

There is a sentence in the SDK’s docstring for elicit that I keep rereading: if the client is an agent, “it might decide how to handle the elicitation, either by asking the user or automatically generating a response.”

That is correct, and it is the whole game. The server sees an elicitation result. It cannot see who produced it. MRTR moves the question from “did the agent call twice” to “did the client host show this to a person,” and the answer to the second question lives entirely outside the protocol. The spec says clients SHOULD keep a human in the loop and SHOULD prompt for confirmation on sensitive operations. SHOULD, on the party the server cannot observe.

I do not think this is a flaw in the revision. It is an honest description of where the boundary is. But it means the type-to-confirm choice is doing real work: a client that auto-answers “accept” still has to produce the right warehouse id, which it can, because the agent knows it. So even that is friction, not proof. What I would like to see, probably as an extension rather than in core, is a way for a client host to declare that an elicitation was rendered to a person, signed by something the server can verify. Until then, a server that gates a destructive operation on elicitation is trusting its client’s user interface, and should say so in its documentation.

Failure three: I evaluated a surface I never shipped

While reading the generated tools off the wire, I found something unrelated to the spec change and worse for my August post. Every generated tool takes a single parameter called arguments of type dict, and validates it against the planned schema inside the function. So what the server advertises over MCP is this, with titles trimmed:

{"type": "object", "properties": {"arguments": {"type": "object", "additionalProperties": true}}, "required": ["arguments"]}

The planned schema, with warehouse_id described and required, never leaves the process. An agent talking to the served artifact has to infer parameter names from the description.

My evaluation harness does not go through the served artifact. Its model driver hands the model each tool’s planned input_schema directly. So the four pre-registered comparisons in the August post measured a surface with real schemas, and the server a user deploys advertises an opaque object. The registrations are still valid for what they measured. They say nothing about what I shipped.

I wrote in August that “reading a file proves a value was written into it; it never proves anything reads it,” after finding four policy values that were emitted and ignored. This is the same defect one layer up. The harness audited the plan, not the wire. The fix is not only to advertise the schema. With 2020-12 now the default dialect in the spec, the compiler’s schemas can go out verbatim. The fix is that the evaluation harness has to drive the generated server over MCP, the way an agent would, or it is evaluating my intentions.

Half fixed in 0.12.0: tools/list now advertises each tool’s planned schema as written, and a new test asks a running server over MCP, with the real SDK, what it advertises; three of its five tests fail against the old emitter. Driving the evaluation harness through the served server, and registering a new comparison against it, is still to do.

Where I think the spec is right, and where I would push

Stateless is right. Sessions were always a fiction at the load balancer, and every team running MCP at scale was already pinning connections or bolting a session store onto something that should not need one. The explicit handle guidance, where a server returns a basket id and the model carries it forward, is how HTTP services have worked for twenty years.

Deprecating Sampling is right. A tool server that borrows the client’s model to do its work had an unclear cost owner and an unclear trust boundary. If the server needs a model, it can call one and pay for it.

Header mirroring is a governance feature disguised as a routing feature. x-mcp-header lets a gateway see a parameter without parsing the body, which means a gateway can enforce policy on it. That makes which parameters get mirrored a compiler decision, not something to hand-annotate. My rule for the compiler: mirror only identifiers the policy already scopes on, such as a tenant or a warehouse, never free text, never anything the redaction pass would flag. The spec warns against mirroring sensitive parameters; a generator can enforce it.

Cache hints need to respect approvals. List results can now be cached for ttlMs, and the spec allows the tool list to vary by the caller’s authorization. A governed surface changes when a reviewer approves or revokes a tool, and a cached list outlives that decision by up to its TTL. For a surface whose contents depend on the caller’s scopes, cacheScope has to be private, and the TTL should be short enough that a revocation lands before anyone would be surprised by it. That is a policy output, and it belongs in the governance manifest next to the rate budgets.

Annotations are still hints, and that is correct. Clients MUST treat annotations as untrusted unless the server is trusted. A destructiveHint tells a well-behaved client to be careful. It does nothing against a client that is not. Enforcement has to live in the server, which is where the compiler put it, and it now needs to be enforcement that survives two replicas.

What changes in the compiler

In order of how much each one bothers me:

  1. Advertise the planned schema on every tool and drive the evaluation harness through the generated server over MCP, so the thing measured is the thing shipped. Rerun the registrations against the served surface. The schema half shipped in 0.12.0; the harness half is next.
  2. Replace the call-twice gate with an MRTR elicitation, type-to-confirm, sealed state bound to principal, arguments, and expiry. Refuse to start without a shared key. Require a shared single-use record for destructive operations the policy marks non-idempotent. Shipped in 0.14.0, except the shared record: until it exists, a non-idempotent destructive tool refuses to start with a shared key and runs on one process.
  3. Emit for SDK 2.x and pin the major version. Keep a 1.x target for deployments that cannot move yet, since the 12-month window exists for exactly them. The pin shipped in 0.12.0, and the 2.x target in 0.13.0: it is now the default, and a generated server serves the 2026-07-28 protocol alongside the earlier ones.
  4. Compile x-mcp-header and cache hints from policy, so gateway enforcement and list caching are reviewed artifacts rather than defaults.

None of this was shipped when I published this. I would rather publish the audit with the defects in it than wait until the fixes make it a cleaner story. The day after, 0.12.0 fixed the opaque schema and the unbounded requirement, and the project’s deployment guide now says plainly that the runtime confirmation is friction, not a person. 0.13.0 then made SDK 2. 0.14.0 replaced the call-twice gate with a person confirming through an elicitation.x the default target, so generated servers now speak the revision this post audits against. If you are generating MCP servers from specifications, by any means, it is worth running the three checks I ran: install your generated server fresh, run two replicas and confirm something across them, and compare the schema you evaluate against the schema tools/list actually returns. Each took me under ten minutes. Each found something.

Cite this article

Mitra, Subhadip. (2026, September). The Stateless MCP Spec Audited My Compiler, and My Confirmation Gate Failed. Subhadip Mitra. Retrieved from https://subhadipmitra.com/blog/2026/mcp-stateless-spec-audit/

@article{mitra2026the-stateless-mcp-spec-audited-my-compiler-and-my-confirmation-gate-failed,
  title   = {The Stateless MCP Spec Audited My Compiler, and My Confirmation Gate Failed},
  author  = {Mitra, Subhadip},
  journal = {Subhadip Mitra},
  year    = {2026},
  month   = {Sep},
  url     = {https://subhadipmitra.com/blog/2026/mcp-stateless-spec-audit/}
}
Share this article

Get More Like This

Strategic insights on Data, AI, and Cloud transformation delivered to your inbox.

Free insights. No spam. Unsubscribe anytime.

Subhadip Mitra