
Almost two years ago, when I went through the MCP specification for the first time, one thing stood out to me immediately. MCP was a stateful protocol.
Coming from an integration background, this concerned me, a protocol that carries state across requests also carries additional complexity, and MCP was going to inherit all of it.
This changed with the 2026-07-28 revision. MCP is now a stateless protocol, and according to my point of view, this was the right call. It should have happened sooner, the longer a protocol remains stateful, the more implementations get built around that assumption, and each of them eventually has to pay the migration cost.

Why Statefulness Was Always Going to Be a Problem for MCP
Let’s look at what a stateful protocol requires in practice, and the operational problems it creates.
-
Load balancing breaks down: A stateful protocol ties a client session to a specific server instance, once that instance is selected, every subsequent request from the client has to return to it, and a normal round-robin load balancer is no longer sufficient. This is the same sticky session problem that WebSocket-based services have dealt with for many years.
-
Fault tolerance and reliability suffer: When the server instance holding a session goes down, the client cannot simply retry its last request. It first has to detect the disconnected session, reconnect, and rebuild the state that was already established, in MCP’s case, that meant repeating the initialization handshake before the client could send another meaningful request.
-
Session lifecycle management becomes your responsibility: Someone has to create, track, expire, and eventually clean up the session state, and in practice, this does not always happen as cleanly as expected.
-
Clients also inherit complexity The server is not the only component paying for statefulness, clients need reconnection and resynchronization logic to recover from a dropped connection, logic a normal request-and-response model never requires.
-
Standard infrastructure can no longer sit in front of it easily A reverse proxy, API gateway, or security control normally assumes it can process each request independently. A stateful protocol breaks that assumption, the infrastructure in front of the server has to understand the session and route requests accordingly.
-
Testing and reproducibility become harder With a stateful protocol, a single request can be difficult to understand or reproduce in isolation, you often need to recreate the entire sequence of interactions that came before it just to reach the state it depends on.
How MCP Previously Handled State
Establishing a session started with the initialization handshake, and it required exactly three interactions. The client sent a request to the server, the server returned a response, and the client completed the handshake with a final notification, client to server, server to client, client to server, in that order, and nothing else could begin until all three were done.

The first message was the initialize request. This was where the client declared the protocol version it wanted to use and the capabilities it supported, one of which was elicitation, which allows a server to pause an operation and ask the user for information through the client. The request also carried the client’s identity, including its name and version, so the server knew exactly which client it was talking to.
The server returned the same kind of information in the other direction, its own name, version, and supported capabilities. Finally, the client sent notifications/initialized to confirm the initialization process was complete.
Client and server capabilities do not become unnecessary simply because MCP is now stateless. Both sides still need to understand what the other supports before they can work together, that need never disappears, only the mechanism for meeting it changes.
The handshake also gave a server the opening to return a session identifier, an Mcp-Session-Id header in its initialize response, and the client then had to attach the same header to every request that followed.
What Changed in MCP [from version 2026-07-28]
The main change is the removal of the entire initialize request, response, and notifications/initialized handshake. The Mcp-Session-Id header is gone too, there is no header carrying a session identifier between requests, because there is no protocol session left to identify.
It looks like a relatively small change. It isn’t, and this is the part I would check your own implementation against, four other important capabilities were removed or changed along with it:
- Servers can no longer initiate their own requests. Previously, a server could send requests such as
roots/list,sampling/createMessage, orelicitation/createto the client over an SSE stream. This pattern is no longer supported. - The HTTP GET stream endpoint is gone, and
resources/subscribeandresources/unsubscribewent with it. Long-lived server-to-client notifications now use a singlesubscriptions/listenrequest, the client declares the notification types it wants, and the server keeps the response stream open to deliver them. - SSE streams are no longer resumable.
Last-Event-IDand SSE event IDs have been removed from the Streamable HTTP transport. If a response stream is interrupted, the in-flight request is lost, and the client must issue it again with a new request ID.
Roots, Sampling, and Logging are also now formally deprecated. They still work, but new implementations should not start depending on them.
The old and new versions can coexist
If you already have an implementation built on any of this, there is no need to panic. Different MCP protocol versions can coexist, a server can keep supporting an older, stateful version for existing clients while it adopts the new stateless version for newer ones.
This revision also introduced a formal feature lifecycle and deprecation policy. A feature marked deprecated needs a minimum twelve-month deprecation period before it can be removed, and the specification maintains a published registry of what is currently in that state. That gives implementers a reasonable window, and for once the timeline is documented rather than assumed.
Even so, I would treat version coexistence as a migration bridge, not a permanent architecture. Maintaining a stateful version and a stateless version side by side gets expensive, the two versions make different assumptions about sessions, connections, and infrastructure, and you end up maintaining two different operating models for the same server.
My suggestion is to start planning the migration now. There is no need to rush the new version into production, it is reasonable to let the specification and SDK support mature first, but I would not postpone the planning either.
A new discovery method replaces the handshake
The new version introduces server/discover, and every server must implement it. It lets a server advertise the protocol versions it supports, its capabilities, and its identity.

Clients are treated differently. A client may call server/discover before making any other request if it wants to select a version in advance, or use it as a backward-compatibility probe on stdio, but it is not required to. If the client already knows which protocol version it is using, it can go straight to a method such as tools/list.
The request carries the client’s capabilities using io.modelcontextprotocol/clientCapabilities, along with io.modelcontextprotocol/clientInfo to identify the client.
Client capabilities describe what the client can support, as an example, a client declares elicitation support as follows.

The server/discover response carries the server’s capabilities, much like the information the old initialize response used to hand over, whether it supports tools, whether it notifies the client when the tool list changes, and so on.

The server’s identity comes back separately, through io.modelcontextprotocol/serverInfo in the result’s _meta.
Every request carries its own capabilities
This is the part that actually replaces the session.
Because there is no handshake and no session remembering what was agreed earlier, every request has to carry the same _meta information the old initialize request sent only once. Three fields do that work:
io.modelcontextprotocol/protocolVersionio.modelcontextprotocol/clientInfoio.modelcontextprotocol/clientCapabilities
This applies to tools/list, resources/list, tool invocations, everything.

Take elicitation again. If a client wants the server to be able to request information from the user during a tool call, it can no longer rely on a capability declared once at the start of the connection, it has to declare elicitation support in the _meta of that exact request. The server has no stored session information left to check against.
This is not just bookkeeping. A server is not allowed to request a capability the client has not declared for that request, so with no session, the declaration and the ask have to travel together.
Carrying the protocol version and capabilities in the JSON-RPC body makes the request stateless, but an ordinary reverse proxy or gateway still cannot use that information for routing unless it parses the full body. So for Streamable HTTP, selected values are also mirrored into HTTP headers.
Every POST carries an MCP-Protocol-Version header, Mcp-Method mirrors the JSON-RPC method name, and for requests such as tools/call, resources/read, and prompts/get, Mcp-Name mirrors the tool name or resource URI. These are required for protocol compliance, not offered as a convenience, and a missing required header is itself a validation failure. As an example, a request to call a weather tool now looks as follows.
POST /mcp HTTP/1.1
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: get_weather
{ "method": "tools/call", "params": { "name": "get_weather", ... } }
A gateway can now make routing and policy decisions by reading these headers alone, which backend pool should take a weather-tool request, whether a tenant is even allowed to call it, without ever parsing the JSON-RPC body.
Removing Sessions Did Not Remove the Need for State
MCP has removed the protocol-level session. Does that mean an application using MCP can no longer carry information between calls?
No. Some workflows still need information to survive across requests, the difference is that the application now has to make that state explicit rather than borrowing it from a session. There are two main situations here, and they call for different answers.
Consider a shopping cart. A model calls one tool to add an item, calls it again to add another, and then calls a third tool to check out. These are separate tool calls, but all of them need to operate on the same cart, and that requirement comes from the application workflow, not from MCP.
Under the old model, the session absorbed this requirement for you, whatever cart the session referenced was the cart every call touched. Take the session away, and the requirement does not go with it, you still need an answer to “which cart”, MCP just no longer answers it for you automatically.
The answer here is an ordinary tool result. A create_basket tool can return a basket_id, and every call after that takes it as a normal argument, as follows.

There is no new protocol construct here, no special handle schema anywhere in the spec. It is just a string the server returns and the client passes back, and several remote MCP servers already work this way, a create operation returns an issue ID or a pull request number, and every later call takes that identifier as an argument.
The specification calls this pattern an explicit state handle, precisely because that’s all a basket_id, an issue ID, or a PR number really is, a handle the model holds and passes back.
The second situation is the replacement for server-initiated requests, and I want to be clear that it is not a niche case. Multi Round-Trip Requests (MRTR), is now how every server-to-client interaction works, sampling, roots, and elicitation all go through it. If your MCP server currently sends its own requests to a client, this is the part of the migration that will affect you.
This is different from the shopping cart example. Here there is one logical operation, but it cannot complete in a single round trip. As an example, a tool updating a support ticket might discover partway through that a business rule requires information the original request did not include, the operation is interrupted and needs to resume once the user provides it.
The main requirement is correlation, whatever answers that question eventually has to be reconnected to the exact call that asked it, without the server keeping the incomplete operation sitting in memory the whole time.
Instead of waiting, or sending its own request over a stream, the server ends the current request immediately with an InputRequiredResult. Every result in this revision carries a resultType, and for this case, the value is "input_required" rather than "complete". The response can carry two optional fields, and at least one must be present:
inputRequests, a map of server-assigned keys to the requests that must be fulfilled. The values can beElicitRequest,CreateMessageRequest, orListRootsRequestobjects.requestState, an opaque value understood only by the server. The client must return it exactly as received, without inspecting, parsing, or modifying it.
The client gathers the requested information and retries the original operation. The new request carries an inputResponses map using the same keys, together with the original requestState value, and one detail is easy to miss: the retry has to use a different JSON-RPC id, because this is an independent request, not a continuation of the first one.
You Did Not Remove State, You Moved It
At this point, one can ask a very valid question: did MCP actually remove state, or did it just move the responsibility somewhere else?
Agent workflows are stateful by nature. Removing sessions does not change that, it just relocates some of the complexity, out of server memory and into tool arguments and the model’s context. State handles consume tokens every time they are passed, the model has to remember to include them, and models are not always reliable about it. MRTR can cause a request to execute again from the beginning, so some work gets repeated.
On that reading, the protocol did not solve the state problem, it handed part of the responsibility to a language model, which is not normally the component you would pick to guarantee correctness.
Most of that criticism is fair. Handles consume tokens, and retries can repeat work, I am not going to argue with the parts that are true.
Where I would push back is on the framing. The state in these workflows was always application state. A shopping cart belongs to the application, not to a transport session, the session was never really holding it, it was holding a pointer and creating the impression that the session owned the state. What changed is that the pointer is now visible.
That visibility is worth something, particularly for audit and access control. A basket_id sitting in a tool argument shows up in logs, passes through the gateway, and can be used in an authorization decision, an in-memory session tied to one connection gives you none of that. The same goes for requestState, it is opaque to the client, but it is still an explicit value crossing a defined boundary, which makes it something you can identify, protect, and reason about.
According to my point of view, I would rather review a system where the state is written down in the request than one that is implied by whichever server instance happened to process it.
Wrapping Up
MCP started as a stateful protocol because it grew out of local, single-process tools. Now that MCP servers are expected to run behind load balancers, gateways, and production infrastructure they were never originally built for, moving to a stateless model is the right call, and among people who have actually had to run this kind of thing at scale, that is not a controversial position.
Stateless does not mean state-free, though. The handshake is gone, the session ID is gone, but the actual work some applications do, a cart that survives several calls, a tool that has to pause and ask the user something mid-flight, has not gone anywhere. The new model just makes that state explicit through state handles and MRTR instead of borrowing it from a session.