Skip to content
Federavia Blog

Blog

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.

10 min read
  • MCP
  • gateway
  • protocol

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-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:

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.

This post is also available as plain markdown.