If you last read the Model Context Protocol spec a revision or two ago, the mental model you
are carrying is probably this: a client opens a connection, sends initialize, receives the
server's capabilities, replies initialized, and from then on the two ends share a session
identified by Mcp-Session-Id. Everything after that — tool calls, resource reads, the
server asking the client for an LLM completion — happens inside that session.
The 2026-07-28 revision takes all of it out. Not deprecates: removes. There is no
handshake, no session identifier, and no server-initiated request. What is left is closer to
a plain HTTP API than to a stateful protocol, and that changes how you deploy an MCP server
and, more sharply, how you write anything that sits in front of one.
This is a walk through the parts of the change that actually alter your operational decisions, and the traps that show up when you put a gateway in the middle.
The handshake is gone; the metadata moved into every request
There used to be exactly one place where a client announced who it was and what it could do.
Now every request carries that announcement, in _meta, under reverse-DNS keys:
{
"jsonrpc": "2.0",
"id": 7,
"method": "tools/call",
"params": {
"name": "get_forecast",
"arguments": { "region": "north" },
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": { "name": "example-agent", "version": "1.4.0" },
"io.modelcontextprotocol/clientCapabilities": {}
}
}
}
Three keys replace the handshake: io.modelcontextprotocol/protocolVersion,
io.modelcontextprotocol/clientInfo and io.modelcontextprotocol/clientCapabilities. They
travel on the request rather than being negotiated once and remembered.
The cost is a few hundred bytes per call. The benefit is that a request is now self-describing: version negotiation, client identity and capability discovery are all answerable from the request in your hand, without a lookup into state established earlier by some other connection.
Consequence: a remote MCP server is an ordinary HTTP workload
This is the part worth internalising, because it deletes a category of infrastructure.
Under the old model, an MCP server behind more than one replica needed the session to be
findable. In practice that meant one of two things: sticky routing, so that every request
carrying a given Mcp-Session-Id landed on the instance that created it, or a shared session
store that every instance could read. Both are real operational weight — sticky routing
interacts badly with rolling deploys and autoscaling, and a shared store is another thing to
run, back up and reason about when it is unavailable.
With the state removed from the protocol, neither is required. Any instance can serve any request. You scale it like you scale a stateless JSON API: put replicas behind a load balancer, round-robin them, drain and replace them without draining sessions first, because there are no sessions to drain.
That does not mean an MCP server has no state — a tool that writes to a database obviously does. It means the protocol contributes none, so whatever state you have is yours, in your own store, on your own terms.
Routing without reading the body
The Streamable HTTP transport adds two headers that mirror what is inside the JSON-RPC envelope:
Mcp-Methodcarries the request'smethod.Mcp-Namecarriesparams.namefor method calls that name a tool or prompt, andparams.urifor those that name a resource.
The point of these is intermediaries. A proxy, a WAF, a rate limiter or a router can decide
what to do with a request from the headers alone, without buffering and parsing a JSON body
it does not otherwise care about. For a gateway that wants per-tool rate limits, or that
wants to route resources/read somewhere different from tools/call, this is the difference
between a cheap header match and full body inspection on every request:
POST /mcp HTTP/1.1
Host: mcp.example.com
Content-Type: application/json
Accept: application/json, text/event-stream
Mcp-Method: tools/call
Mcp-Name: get_forecast
{"jsonrpc":"2.0","id":7,"method":"tools/call",
"params":{"name":"get_forecast","arguments":{"region":"north"}}}
The trap: the headers are normative, not a hint
Because intermediaries are allowed to act on the headers, the spec cannot let them disagree
with the body. So it does not: a server that reads the body must reject a request whose
Mcp-Method or Mcp-Name does not match the corresponding field, with the error code
HeaderMismatch (-32020).
This is where gateways break, and it is a specific, predictable failure. A federating gateway
usually namespaces the tools it aggregates, so that two partners can both publish a tool
called get_forecast without collision. The client calls weather-api__get_forecast; the
gateway strips the prefix and forwards get_forecast to the upstream. If the gateway rewrites
params.name in the body and forwards the client's original Mcp-Name header untouched, a
conforming upstream now sees Mcp-Name: weather-api__get_forecast against a body saying
get_forecast, and returns -32020.
The failure mode is unpleasant because it does not look like a header bug. It appears after you have already got name mapping right, only against upstreams that read the body, and the error surfaces at the client as a call that the upstream rejected. The rule to hold onto: the header and the body are one value in two places. Any code that writes one writes the other. Rewriting them together, in the same function, is the only arrangement that does not eventually drift.
And the encoding detail
Header field values are ASCII. Tool and resource names are not necessarily. Non-ASCII values
travel encoded as =?base64?…?= — so a name that is plain UTF-8 in the body arrives on the
header wrapped in that form.
Anything comparing header against body has to decode first. A byte-for-byte comparison of the
raw header against the raw body field will report a mismatch for every non-ASCII name, and
you will have implemented -32020 as a bug rather than a check. The same applies in reverse
on the write side: a gateway rewriting Mcp-Name to a value containing non-ASCII characters
has to re-encode it, not pass the raw UTF-8 through.
Server-initiated requests are gone: sampling, elicitation and roots now invert
The old protocol was bidirectional. A server handling tools/call could turn around and send
the client a request of its own — sampling/createMessage to borrow the client's model,
elicitation/create to ask the user a question, roots/list to find out which directories it
was allowed to touch — and wait for the answer before finishing.
That required a channel from server to client, which required a session, which is exactly what
was removed. So the direction is inverted. Instead of asking, the server returns: the
response to the original call is an InputRequiredResult carrying inputRequests, describing
what it needs. The client gathers those inputs and retries the original call, this time
with inputResponses attached. The round trip repeats until the server has enough to answer.
The spec calls this MRTR — multi-round tool response.
Two things follow for anyone implementing against it.
The first is that a tool call is no longer necessarily one request. It is a loop, and the number of iterations is not bounded by the protocol. Client code that assumed one call produced one result needs restructuring, and both clients and gateways want their own cap on how many rounds they will play before giving up.
The second is that the retry is a new request, and the server has no session in which to remember the first one. Whatever the server needs to resume must be in the request. In practice this means the server hands back its own continuation state and the client returns it, so a request in a multi-round exchange carries more than the client typed. Gateways that log or size-limit request bodies should expect that.
The upside is that the awkward case is now easy. Under the old model, a gateway forwarding a
tools/call had to also be prepared to proxy a request coming back the other way,
mid-flight, and match it to the call it belonged to. Now every message travels client to
server. There is nothing to correlate backwards.
The GET stream is gone, and so is Mcp-Session-Id
Two smaller removals with practical consequences.
The GET request that opened a standing server-to-client SSE stream is gone — it existed to
carry server-initiated requests, and there are none. Streaming still exists, but only as the
response to a POST: a call that streams progress or partial results streams it on its own
response. One request, one stream, no long-lived connection sitting idle between calls. If
your gateway or load balancer had special idle-timeout handling for that GET, it is now dead
configuration.
Mcp-Session-Id is gone with it. Any header allowlist, log field or metrics dimension keyed
on it is now empty. If you were using it as a correlation ID in traces, you need a different
one — the request's clientInfo plus your own request ID is the natural replacement, and
unlike the session ID it is present on every request rather than established once.
Cancellation changes shape too. There is no notifications/cancelled message travelling on a
side channel to abort an in-flight call; cancellation is closing the response stream. The
client hangs up. Server implementations need to notice the disconnect and abandon the work,
which in most HTTP frameworks means honouring a cancellation token or the equivalent rather
than running a handler to completion after the peer has gone. Gateways need to propagate the
close upstream instead of holding the upstream request open — otherwise a client that cancels
leaves work running that nobody is waiting for.
What to check if you operate a gateway
Concretely, the audit:
- Rewrite headers and body together. Any place that changes
method,params.nameorparams.urimust changeMcp-Method/Mcp-Namein the same step. Namespacing is the common case; so is any aliasing or version pinning you do on tool names. - Decode
=?base64?…?=before comparing. On both the check and the rewrite side. - Implement
-32020yourself if you read the body. A gateway that parses the request is a server for this purpose, and a mismatch from a client is worth rejecting rather than forwarding. - Handle
InputRequiredResultas a pass-through, and bound the loop. The result travels back to the client and the retry comes forward; the gateway is not the party that answers it, but it is the party that should stop an unbounded exchange. - Propagate stream closure upstream. A cancelled call should not leave an upstream request running.
- Retire the session infrastructure. Sticky routing, session stores,
Mcp-Session-Idheader allowlists, idle timeouts tuned for the GET stream. All of it is now cost without function.
None of this is difficult. The reason it is worth being deliberate about is that the two protocol behaviours that bite — the header/body match and the multi-round retry — both fail in ways that look like something else, and both fail only against upstreams that implement the spec strictly. Getting them right against a lenient upstream tells you nothing.
Federavia is an agent gateway that puts one MCP endpoint in front of many partner servers, which means it does the namespacing described above and has to get the header rewrite right. How it works.