The stateless revision¶
MCP has two generations in the field, and passmcp runs all nine phases on either. This page is about the newer one — what changed, how passmcp works out which you are speaking, and what it checks that the older revision has no equivalent of.
You do not need to read this to use passmcp. You need it when a finding
mentions _meta, Mcp-Method or an error code in the -320xx range, and
you want to know whether the tool or the server is wrong.
What changed¶
The 2026-07-28 revision removed the session.
| Handshake revisions | 2026-07-28 |
|
|---|---|---|
| Opening exchange | initialize, then notifications/initialized |
none — the first request is the work |
| Session | Mcp-Session-Id header, server-issued |
gone |
| Protocol version | negotiated once | per request, in _meta |
| Client identity | sent once at initialize |
per request, in _meta |
| Capabilities | negotiated once | per request, in _meta |
| Routing | parse the body | Mcp-Method and Mcp-Name headers mirror it |
| Liveness | ping |
ping removed; server/discover |
| Server discovery | initialize result |
server/discover, a MUST |
The point of it is that a server becomes an ordinary stateless HTTP service. Two requests can land on two instances behind a round-robin load balancer and neither needs to know about the other.
Every request carries what the handshake used to establish:
{
"jsonrpc": "2.0",
"id": 7,
"method": "tools/call",
"params": {
"name": "find_customer",
"arguments": { "email": "[email protected]" },
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {},
"io.modelcontextprotocol/clientInfo": { "name": "passmcp", "version": "0.0.1" }
}
}
}
protocolVersion and clientCapabilities are required. clientInfo is a
SHOULD, and passmcp sends it.
How passmcp decides which you speak¶
Before first contact, and on a transport carrying no credentials.
The shape of the very first request depends on the answer, so guessing wrong means every protocol finding afterwards describes passmcp's probe rather than your server. passmcp asks, then commits:
- It tries
server/discover, which the new revision requires. - A useful answer means the stateless revision, and
handshake.protocol_erarecords it. -32601 Method not foundmeans the older generation — with one distinction that matters: HTTP 404 means modern-but-absent, HTTP 200 with-32601means legacy. Conflating them misreports both.
Once settled, the era drives the whole run. handshake.initialize and
handshake.session only make sense on the older revision;
handshake.stateless and protocol.get_stream only on the newer.
What passmcp checks that only exists here¶
Routing headers are validated, not just present¶
Mcp-Method and Mcp-Name mirror the body so a gateway can route without
parsing JSON. Mirrored headers only buy a gateway anything if the server
checks they agree with the body — otherwise the gateway routes on the
header and the server acts on the body, and they can be different requests.
protocol.routing_headers sends a request whose header disagrees with its
body and expects -32020 HeaderMismatch. Refusing with a bare HTTP status
is a warning rather than a pass: a client cannot tell that apart from an
ordinary rejection.
Statelessness is tested, not believed¶
resilience.stateless sends the same request over two independent
connections and compares the answers. A server that quietly keeps state
will pass every other check and then fail the first time it sits behind a
load balancer.
GET should be refused¶
There is no event stream to open, so protocol.get_stream expects 405.
What was removed should be gone — or declared¶
initialize and ping are not part of this revision. A server that still
answers them is in one of two situations, and from outside they look
identical: either it serves both generations deliberately, or it is carrying
handlers no current client will ever call.
supportedVersions in the server/discover result is how a server says
which. protocol.deprecated_features reads it before deciding, so declared
compatibility is a pass — passmcp is not in the business of telling a
deliberately compatible server to break itself. Undeclared, it is a warning:
the code is there and nothing tells a client it may be used, so the callers
left are stale software and whoever is enumerating the endpoint.
Declaring a revision the server does not actually serve is also a warning, and the worse of the two: a client that reads the declaration negotiates down, and the failure lands on its first real call rather than at discovery.
A request for client input has to be answerable¶
The revision removed server-initiated sampling, elicitation and roots and
replaced them with Multi Round-Trip Requests: a server that needs something
mid-call answers resultType: input_required with a list of client-side
methods, and the client retries the original call with the answers attached.
passmcp does not answer one — it has no user to elicit from and no model to
sample, and inventing either would mean reporting on a conversation it made
up. What it does is judge the request, and protocol.mrtr is that judgement.
An input_required that names nothing, or names a method with no correlation
id, leaves the client with no retry it can construct. The failure that follows
is the worst kind available: the call does not return an error, it never
returns at all. The agent waits, and nothing is reported to anyone. That is
invisible from inside the agent and obvious from here, which is the whole
reason to measure it.
Two consequences worth stating, because both were wrong in passmcp once:
- A tool that answers
input_requiredis not failing. It is implementing the current revision. passmcp used to count it as a protocol error, which failed the execution phase and took the category score down with it — the same mistake thepingcheck made. It is now reported as a call that needed input and was not exercised further. - A liveness call that answers
input_requiredis failing.pingexists to be answerable with nothing and nobody present, so a version that needs a user turns every liveness probe into a conversation and monitoring reads the server as down.
The check is observational. passmcp cannot make a server ask for input, so it judges what a run happened to receive and skips, by name, when nothing did.
Extensions are enumerated¶
server/discover carries the server's extensions in
capabilities.extensions, keyed by identifier with each extension's
settings as the value, and everything there is interface. protocol.extensions reports what is advertised, separating the
specification's own io.modelcontextprotocol/… extensions from an author's,
because "this server speaks Tasks" and "this server speaks something only
its own client knows about" are different facts.
The identifiers are reverse-DNS for the same reason _meta keys are: it is
a global namespace with no registry behind it, and the domain is what stops
two authors meaning different things by the same word. A bare billing is
reported. So is a server that lists its extensions in a top-level
extensions field instead: no specification defines that field, so a
client reading capabilities.extensions sees a server with none.
passmcp does not test an extension's semantics. Naming it is the point: the rest of the report describes the base protocol, and an operator should know what sits beside it.
The error codes¶
| Code | Meaning | When |
|---|---|---|
-32020 |
HeaderMismatch | Mcp-Method/Mcp-Name disagree with the body |
-32021 |
MissingRequiredClientCapability | the request needs a capability _meta did not declare |
-32022 |
UnsupportedProtocolVersion | the server does not speak the version in _meta |
Header values that HTTP will not carry¶
RFC 9110 restricts a field value to visible ASCII. A tool name is only
SHOULD-constrained to that, and a resource URI is not constrained at all —
so a name with an accent in it cannot go in Mcp-Name as-is. The revision
defines a Base64 sentinel form, =?base64?…?=, and passmcp uses it
automatically. See
transport.EncodeHeaderValue.
ping is gone¶
Worth stating plainly, because passmcp got this wrong once and it is the kind of error that costs somebody a day.
The revision removed ping. A stateless server answering -32601 to a
ping is behaving correctly. passmcp used to report that as a failure,
which meant telling a conformant implementation to break itself — worse
than no diagnostic at all. Liveness now uses server/discover on this
revision, and two registry servers that had been failed went from 83 and 81
to 90 and 88 once it was fixed.
If you see a tool reporting a missing ping as a fault on 2026-07-28,
that is the tool.
Forcing the era¶
passmcp settles this itself, and should be left to. If you need to override — testing a gateway that speaks one revision to you and another upstream:
passmcp check https://mcp.example.com/mcp --skip-era-check
That skips detection and uses the handshake path. There is no flag to force
the stateless path, because a server that does not answer server/discover
is not speaking it.