# MCP went stateless in 2026-07-28 — what that changes for gateways

> The 2026-07-28 revision drops the initialize handshake, session IDs and server-initiated requests. Here is what an MCP server now looks like as an HTTP workload, and the three places a gateway in front of one can quietly break.

Published: 2026-08-14 · 10 min read · Tags: MCP, gateway, protocol
Canonical: https://federavia.lausbuam.com/blog/mcp-2026-07-28-stateless-gateways/

---

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:

```json
{
  "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-Method` carries the request's `method`.
- `Mcp-Name` carries `params.name` for method calls that name a tool or prompt, and
  `params.uri` for 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:

```http
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:

1. **Rewrite headers and body together.** Any place that changes `method`, `params.name` or
   `params.uri` must change `Mcp-Method` / `Mcp-Name` in the same step. Namespacing is the
   common case; so is any aliasing or version pinning you do on tool names.
2. **Decode `=?base64?…?=` before comparing.** On both the check and the rewrite side.
3. **Implement `-32020` yourself 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.
4. **Handle `InputRequiredResult` as 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.
5. **Propagate stream closure upstream.** A cancelled call should not leave an upstream
   request running.
6. **Retire the session infrastructure.** Sticky routing, session stores, `Mcp-Session-Id`
   header 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](/#how-it-works).
