MastertheMesh
Blog · 2026-08-03

MCP went stateless: the 2026-07-28 spec on the wire

TO
Tom O'Rourke
EMEA Field CTO · Solo.io

The final spec shipped on 28 July and removes the session machinery from the protocol. This is what the payloads actually look like now, and why the infrastructure between agents and MCP servers suddenly has a much better job.

MCP 2026-07-28 Stateless MRTR Tasks extension Streamable HTTP agentgateway

MCP's biggest revision since launch went final on 28 July. Sessions are gone, the handshake is gone, server push is gone, and every request now stands on its own. In June I wrote about where the roadmap was pointing. This post is about what actually shipped, payload by payload.

Run any ordinary HTTP service on Kubernetes and scaling it is routine: replicas behind a Service, round-robin routing, instances restarting whenever they like and nobody noticing. MCP has always behaved differently. Every conversation opened with an initialize handshake. The server minted an Mcp-Session-Id and every later request had to carry it, which pinned the client to whichever instance issued it. Interactive features like elicitation held Server-Sent Events streams open so the server could reach back into the client mid-call. And when the instance holding your session restarted, every conversation it was carrying died with it.

The 2026-07-28 specification removes all of that machinery from the protocol. Nothing breaks on day one, older protocol versions stay supported through a twelve-month deprecation window and the SDKs shipped with backwards compatibility, but new implementations get the model described below, and the maintainers' announcement calls it the protocol's biggest revision since launch. I covered the release candidate and the roadmap back in June, so I am not going to re-tread the why. Now the ink is dry I want to walk the actual wire shapes, because that is where this release earns its keep: what a request looks like, how a server asks a question when it can no longer push, and how a 45-minute job survives without a connection.

Every request stands on its own

Here is a complete tools/call under the final spec:

POST /mcp HTTP/1.1
Content-Type: application/json
Accept: application/json, text/event-stream
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: get_weather

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "get_weather",
    "arguments": { "location": "Seattle, WA" },
    "_meta": {
      "io.modelcontextprotocol/protocolVersion": "2026-07-28",
      "io.modelcontextprotocol/clientInfo": {
        "name": "example-client",
        "version": "1.0.0"
      },
      "io.modelcontextprotocol/clientCapabilities": {}
    }
  }
}

Three things worth noticing:

  1. The headers mirror the body. SEP-2243 requires every Streamable HTTP POST to carry Mcp-Method and Mcp-Name, duplicating the JSON-RPC method and target name, and the server rejects the request with a HeaderMismatchError if the two disagree. So the routing key cannot lie about what is inside, and anything in the traffic path can route, rate-limit and allow-list per tool off a header match instead of deep-parsing JSON-RPC bodies. Half the gateway ecosystem had already invented this privately. Now it is the standard, and there is even x-mcp-header for promoting tool parameters into custom headers.
  2. The protocol metadata rides in _meta. With the handshake gone, every request must carry its protocol version and client capabilities inline (SEP-2575), and clients should identify themselves on each request too. A version mismatch is a typed error, UnsupportedProtocolVersionError. Each request is self-describing, so any instance can serve it cold.
  3. initialize is dead and server/discover replaces it. Every server must implement a dedicated RPC that advertises its supported versions, capabilities and identity. Clients may call it up front to pick a version, or never. Discovery became something you do when you need it, not a toll you pay before every conversation.
Diagram: one request, any instance
One request, any instance MCP client Instance A Instance B BEFORE · 2025-11-25 · SESSION PINNED initialize handshake before anything Mcp-Session-Id: abc123 tools/call · Mcp-Session-Id: abc123 sticky: must land on A result Instance A restarts and every conversation it held dies with it. AFTER · 2026-07-28 · STATELESS tools/call · Mcp-Method + Mcp-Name + _meta any instance, cold resultType: complete No handshake, no session, nothing pinned.

Figure 1: the session is gone. The 2025-11-25 flow pins a client to whichever instance minted its session ID, so a restart takes the conversation with it. Under 2026-07-28 the request carries everything it needs and any instance can answer.

MRTR: the server asks by returning early

MRTR is Multi Round-Trip Requests (SEP-2322), the new pattern for how a server gets something from the client partway through handling a request: instead of pushing a question back over a held-open connection, the server finishes the call early with a result that says "I need more before I can finish", and the client retries the original request with the answers attached. It is the notable piece of engineering in the release.

Elicitation is worth pinning down first, because it is the feature that makes MRTR necessary. It is the MCP feature that lets the server ask the human a question in the middle of doing its work. Not the agent asking the user: the tool's server deciding, mid-execution, that it needs something only a person can give it. The canonical case is a confirmation gate: the agent calls delete_files and the server refuses to just do it, it wants a human to approve first. The same mechanism covers a missing credential, a choice between ambiguous matches, or a GitHub username.

Under the old protocol that question travelled over server push: the client held an SSE stream open and the server pushed its "Delete 3 files?" request back up it mid-call. Statelessness removed all of that. No session, no held stream, no push, and the spec is explicit that the old server-initiated request pattern is no longer supported, a breaking change. This is what replaces it:

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "resultType": "input_required",
    "inputRequests": {
      "confirm_delete": {
        "method": "elicitation/create",
        "params": {
          "mode": "form",
          "message": "Delete 3 files?",
          "requestedSchema": {
            "type": "object",
            "properties": { "confirm": { "type": "boolean" } },
            "required": ["confirm"]
          }
        }
      }
    },
    "requestState": "AEAD-protected blob"
  }
}

inputRequests is a map of ordinary server-to-client requests, elicitation or sampling shaped, keyed by identifiers the server picks. requestState is an opaque string the server uses to parcel up its own continuation state. The client gathers the answers, from the user or from its model, then retries the original call with the responses keyed to match and the state echoed back untouched:

{
  "jsonrpc": "2.0",
  "id": 2,
  "method": "tools/call",
  "params": {
    "name": "delete_files",
    "arguments": { "paths": ["a", "b", "c"] },
    "inputResponses": {
      "confirm_delete": {
        "action": "accept",
        "content": { "confirm": true }
      }
    },
    "requestState": "AEAD-protected blob",
    "_meta": { "io.modelcontextprotocol/protocolVersion": "2026-07-28" }
  }
}

The normative rules are tight, and they are what make this work at scale:

The consequence is the whole argument for the redesign: the retry can land on a different instance from the one that asked the question, and it still works, because the continuation state rode in the payload.

Diagram: the retry can land on a different instance
MRTR: pause on A, resume on B MCP client Instance A Instance B tools/call delete_files (id: 1) resultType: input_required inputRequests + requestState user says yes tools/call retry (id: 2) inputResponses + requestState echoed resultType: complete Instance B has never seen this conversation. Everything it needs to resume arrived in the retry.

Figure 2: instance A pauses the call, instance B resumes it. No shared store, no coordination, no sticky routing. The state rode through the client inside requestState.

And the spec is refreshingly blunt about the security consequence of routing state through the client: requestState is attacker-controlled input. If it influences authorisation, resource access or business logic, the server must protect its integrity with an HMAC or AEAD and reject anything that fails verification. It goes further and tells you what to bind inside the protected payload: the authenticated principal, a short expiry, and an identifier for the originating request, with single-use enforced server-side where a blob must only be redeemable once. That paragraph is the difference between "we moved the state into the payload" being an architecture and being an incident.

One more breaking change hides in this section, and almost none of the launch coverage picked it up: every result now carries a required resultType field, "complete" for ordinary responses. Clients must treat results from earlier-protocol servers that omit it as complete. If your client pattern-matches on result shapes, or your gateway inspects results, this touches every response, not just the interactive ones.

Tasks: the same idea, built to outlive the request

MRTR handles interaction measured in seconds, but the client is still driving a synchronous retry loop, so it does nothing for a 45-minute batch job. That is what the Tasks extension is for.

Tasks shipped experimentally inside the 2025-11-25 core (SEP-1686). Production experience moved them out into an official extension, io.modelcontextprotocol/tasks (SEP-2663), which also swapped the old blocking tasks/result for polling. Both sides opt in: the client declares the extension in each request's _meta, the server advertises it in its server/discover capabilities, and a server must never return a task to a client that did not declare support.

{
  "params": {
    "_meta": {
      "io.modelcontextprotocol/clientCapabilities": {
        "extensions": { "io.modelcontextprotocol/tasks": {} }
      }
    }
  }
}

When a request is going to run long, the server answers tools/call with a handle instead of a result:

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "resultType": "task",
    "task": {
      "taskId": "task-7f3a",
      "status": "working",
      "ttlMs": 3600000,
      "pollIntervalMs": 5000
    }
  }
}

The task must be durably created before that response is sent, so the handle is a promise the server has already committed to. From there, three verbs and a notification drive the lifecycle:

MethodRole
tasks/getPoll current state, respecting pollIntervalMs. Terminal states carry the result (on completed) or the JSON-RPC error (on failed).
tasks/updateSupply inputResponses for a paused task's outstanding inputRequests. Acknowledged with an empty result.
tasks/cancelCooperative. The server acknowledges the intent but is not obliged to stop the work, and the task may still finish in a different terminal state.
notifications/tasksOptional push via subscriptions/listen. Each notification carries the full task state. Polling stays the default.
Diagram: the task lifecycle
The task lifecycle: five states, three terminal CreateTaskResult working input_required needs input tasks/update TERMINAL completed failed cancelled result ready JSON-RPC error tasks/cancel A tool result carrying isError: true still ends in completed. failed is reserved for JSON-RPC errors.

Figure 3: five states, three of them terminal. Once a task reaches completed, failed or cancelled its state never changes again. The semantic worth memorising is in the footnote: a tool-level error is still a completed task.

And one deliberate absence: tasks/list was removed. Without sessions there is no safe way to scope "whose tasks?", so there is no server-side enumeration at all. You hold the handle or you don't. Handle custody is now entirely the client's problem, which is worth sitting with for a moment if you build the platform those clients run on.

The status vocabulary, working, input_required, completed, failed, cancelled, is near-verbatim the A2A task lifecycle, and input_required is the tell. The layering still differs: an A2A Task wraps an entire agent-to-agent exchange with messages and artifacts, while an MCP Task is deferred execution of a single request. I went deep on where the two protocols converge and where they deliberately do not in the roadmap post, and everything there still holds.

A paused job and a mid-call question are the same object

When a running task needs input, an approval gate on a migration, say, it moves to input_required and the tasks/get response carries an inputRequests map. That is the same elicitation object MRTR delivers inline, arriving through the polling channel instead. The client answers via tasks/update with inputResponses, keyed identically, instead of retrying the call.

So a mid-call confirmation and a paused overnight job present exactly the same shape to the client, just at two durability levels. Implement the elicitation-handling path once and you get both behaviours.

Diagram: a task pausing for approval
A task pausing for approval Agent / MCP client MCP server tools/call run_pipeline tasks capability in _meta resultType: task · taskId, status: working POLL AT pollIntervalMs tasks/get status: working tasks/get status: input_required + inputRequests (elicitation) tasks/update · inputResponses ack (empty result) tasks/get status: completed + result

Figure 4: an approval gate inside a long-running job, entirely through poll and update. The elicitation the client handles here is the same object it would have handled inline under MRTR. No push channel required.

What this hands to the infrastructure layer

Everything above changes the relationship between MCP and whatever sits in the traffic path, which is of course the seat I care about, since it is where agentgateway lives.

Routing is now boring, in the best way. A remote MCP server that previously needed sticky routing, a shared session store and body parsing can sit behind plain round-robin. Per-tool policy, allow-lists, rate limits and tenant routing key off Mcp-Method and Mcp-Name.

Caching has a contract, and it is mandatory. Results from tools/list, prompts/list, resources/list, resources/read and resources/templates/list must now carry ttlMs and cacheScope (SEP-2549), so a tool catalogue declaring a five-minute public TTL can be honoured by any intermediary. Tool catalogues are the chattiest MCP traffic there is, so this is a real win. Next to it, the spec tells servers to return tools/list in deterministic order, explicitly to improve LLM prompt-cache hit rates. A wire protocol optimising for the model's KV cache is a very 2026 sentence.

Tracing is documented. W3C Trace Context keys (traceparent, tracestate, baggage) have defined homes in _meta, so one agentic workflow shows up as one distributed trace across every hop.

And the enforcement point moved to the platform. Every request now carries its own identity, capability and routing information inline, which gives a gateway one well-defined place to police it all. The flip side deserves equal weight: who may call which tool, whether a requestState blob is integrity-checked and replay-bounded, who gets to poll a task handle they did not create. The spec defines the shapes and hands the decisions to whoever operates the boundary. The protocol got simpler and the operator's job got more consequential. If you want to see that job done on live traffic, the labs are here: the 2026-07-28 spec run end to end on kind, payload by payload, enterprise MCP controls at the gateway, elicitation for upstream OAuth and gateway-enforced human approval for tool calls.

Migration notes

Where agentgateway already is with this

Given where I work, it is fair to ask how much of the new spec agentgateway actually speaks today. The answer is most of it, and the timeline is worth noting: initial 2026-07-28 support landed in the open on 1 July, tracking the release candidate, and v1.4.0 shipped on 27 July, the day before the spec went final. That release carries the core of everything this post covers: server/discover handled and forwarded, with supported versions intersected across multiplexed backends; the stateless SEP-2575 behaviour (#2417); MRTR capabilities preserved end to end for modern clients (#2559); and subscriptions/listen fanned out across multiple backends with the acknowledgements merged (#2599).

v1.4.1 followed on 29 July, the day after the spec went final, with the Tasks extension: the gateway namespaces task IDs per backend, so tasks/get, tasks/update and status notifications find their way back to the upstream that owns the task even when one route multiplexes several MCP servers. That is the handle-custody problem from earlier, solved at the gateway.

Two design choices stand out. The gateway bridges versions in both directions, so a modern client can sit in front of servers that have not migrated yet and an older client in front of new ones, with resultType and the caching fields injected or stripped as results cross the boundary. And it is deliberately conservative with the new caching contract: results that crossed gateway policy are marked non-cacheable, because an intermediary should never cache something authorisation might have filtered on this request but not the next. Conformance work continues in the open, including integrating the official MCP conformance suite so the compatibility results are published per version.

And if you would rather run it than read it: the companion lab, MCP 2026-07-28 on the wire, live, brings agentgateway v1.4.1 up on a kind cluster in front of a single-file MCP server and reproduces every payload in this post with curl, including the MRTR retry completing after the whole server deployment is replaced and the task pausing for its approval.

The short version: MCP stopped asking your infrastructure for special accommodations, and the spec now says on the wire exactly what a gateway is supposed to see and enforce. That is the least dramatic possible headline for the biggest revision since launch, and it is precisely the one I wanted to read.

Sources and further reading

  1. The 2026-07-28 specification and changelog, modelcontextprotocol.io/specification/2026-07-28. Every normative claim in this post is checked against it.
  2. The release announcement, blog.modelcontextprotocol.io (David Soria Parra and Den Delimarsky, 28 July 2026).
  3. Multi Round-Trip Requests, the MRTR pattern specification (SEP-2322), including the requestState security requirements.
  4. The Tasks extension, extension overview and the ext-tasks repository (SEP-2663, evolving SEP-1686).
  5. My June post on the roadmap and release candidate, MCP's 2026 roadmap: Tasks, stateless servers, and where it converges with A2A, for the A2A comparison this post leans on.
  6. The companion lab, MCP 2026-07-28 on the wire, live: MRTR and Tasks through agentgateway, which runs everything in this post on a kind cluster.
  7. A2A Protocol, a2a-protocol.org, for the task lifecycle the Tasks extension so clearly rhymes with.
About the author. Tom O'Rourke is EMEA Field CTO at Solo.io, working with regulated enterprises across the region on AI infrastructure, service mesh and API gateway architecture. He joined Solo on 11 May 2026.