The Forge
MCP is going stateless. The release candidate for the 2026-07-28 spec is the largest revision of the protocol since it launched, and the headline change is that MCP no longer holds a session for you. The initialize handshake is gone. The Mcp-Session-Id header is gone. As of the final spec on July 28, any request can land on any server instance, because the protocol stops pinning a client to the box that first answered it.
That's a real break, and it matters most to anyone running a remote MCP server. If your server leans on an in-memory session, a sticky route, or a shared session store to remember what a client was doing, that assumption is on the clock. The maintainers were direct about it: this release contains breaking changes.
The common read on "stateless" is that your server can't remember anything anymore, so every useful workflow that spans two tool calls is dead. That's wrong, and it's worth killing early. The spec is stateless at the protocol layer. Your application can be as stateful as it wants. What changes is where the state lives and who can see it. Instead of the transport hiding a session id in a header, you mint an explicit handle from a tool, and the model passes that handle back as an ordinary argument on the next call. The maintainers found this pattern is often better than the session it replaces, because the state is visible to the model instead of buried in transport metadata the model never sees.
The short version: the protocol stops carrying your state for you, so you carry it in the open, as a handle the model threads from one call to the next.
The Blueprint
Three pieces: see exactly what the wire looks like now, refactor one stateful tool to the handle pattern, and add the headers that let any instance take the call.
Step 1: look at what the request actually became. In the old spec, calling a tool meant opening a session first, then carrying its id on everything after.
POST /mcp HTTP/1.1
Mcp-Session-Id: 1868a90c-3a3f-4f5b
Content-Type: application/json
{
"jsonrpc":"2.0",
"id":2,
"method":"tools/call",
"params": {
"name":"search",
"arguments":{
"q":"otters"
}
}
}That Mcp-Session-Id pins the client to whichever instance issued it. In 2026-07-28, the same call is one self-contained request that any instance can handle.
POST /mcp HTTP/1.1
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: search
Content-Type: application/json
{
"jsonrpc":"2.0",
"id":1,
"method":"tools/call",
"params":{
"name":"search",
"arguments":{
"q":"otters"
},
"_meta":{
"io.modelcontextprotocol/clientInfo":{
"name":"my-app",
"version":"1.0"
}
}
}
}No session, no handshake. The protocol version and client info that used to be exchanged once at connect time now ride in _meta on every request.
Step 2: move your state out of the session and into a handle. Here's the shape most stateful servers have today. It works only because every request from a client lands on the same process, which is exactly the guarantee going away.
# BEFORE: state hidden in a per-connection session.
# Works only while every request from this client hits THIS process.
session_baskets = {} # keyed by Mcp-Session-Id, which no longer exists
def create_basket(session_id):
session_baskets[session_id] = []
return "Basket ready."
def add_item(session_id, sku):
session_baskets[session_id].append(sku)
return f"Added {sku}."The fix is to mint an explicit id, put the state in a store every instance can reach, and hand the id back to the model so it can pass it to the next call.
# AFTER: state in a shared store, keyed by a handle the model threads back.
import uuid
from your_store import store # Redis, a database, anything all instances share
def create_basket() -> dict:
basket_id = f"bskt_{uuid.uuid4().hex[:12]}"
store.set(basket_id, [])
return {"basket_id": basket_id} # the model gets the handle, not a hidden session
def add_item(basket_id: str, sku: str) -> dict:
items = store.get(basket_id)
items.append(sku)
store.set(basket_id, items)
return {"basket_id": basket_id, "count": len(items)}The only real change is that basket_id is now a tool argument the model reasons about, not a header your transport smuggles around. The model can compose it across tools, hand it off between steps, and carry it through a plan. That's the upside the maintainers were pointing at.
Step 3: let the router see the request. The Streamable HTTP transport now requires Mcp-Method and Mcp-Name headers on the request, so a load balancer, gateway, or rate limiter can route on the operation without cracking open the JSON body. One rule to respect: servers reject a request where the headers and the body disagree. If the header says Mcp-Name: search and the body calls tools/call for add_item, that's an error, on purpose. Set the headers from the same values you put in the body and they'll never drift.
With state in a shared store and the routing headers in place, you can retire the sticky sessions and the shared session store the old deployment needed, and run the whole thing behind a plain round-robin load balancer.
The Anvil
Now the part the migration guides skip: where the handle pattern goes wrong.
Keeping the handle's state in one process's memory. Moving from Mcp-Session-Id to a basket_id buys you nothing if the basket still lives in a local dict on one box. The next call carrying that id can land on a different instance, find nothing, and fail. The handle is only half the pattern. The other half is a store every instance can read: Redis, a database, a cache. If the state can't outlive the process, the handle is a session with extra steps.
Letting the headers and body drift. The new Mcp-Method and Mcp-Name headers exist so infrastructure can route without parsing the body, and the spec makes servers reject any request where they disagree. If you set those headers by hand in a client and hardcode one while the body sends another, every call fails with a mismatch that looks like nothing until you diff the two. Derive the headers from the request you're already sending, never from a constant.
Treating the handle like a secret. A handle is an argument. The model sees it, your logs see it, and anything reading the conversation sees it. That's fine for a basket_id. It is not fine to stuff a customer id, an auth token, or anything sensitive into the handle and lean on it for access control. Authorize every call against the caller's real credentials, not against the fact that they're holding an id you handed out earlier. Statelessness means "no session to trust," so don't rebuild an implicit trust boundary out of a string.
Forgetting the new cache controls. List and resource results now carry ttlMs and cacheScope, modeled on HTTP Cache-Control. Skip them and clients either never cache your tools/list and hammer you, or cache a per-user result and share it across users. Set ttlMs to how long the list is actually good for, and set cacheScope to say whether it's safe to share. This is free performance you only get if you fill it in.
The rule of thumb: if a piece of state has to survive two tool calls, give it a name and hand that name to the model. If it has to survive two server instances, put the state behind that name in a store both instances can reach. Anything you can't do with those two moves is a sign you're still leaning on the session that's about to disappear.
Sparks
A few more things from the same release worth your attention:
Tasks graduated from an experimental core feature to an official extension. A tool can answer
tools/callwith a task handle, and the client drives the work withtasks/get,tasks/update, andtasks/cancel. It's the clean way to model work that outlives a single request, and it pairs naturally with the scheduled agents from Issue #12. We may build one in a future issue.MCP Apps is now an official extension in this release too, the same server-rendered UI we built back in Issue #11. If you shipped one against the earlier draft, it's worth rereading the extension spec before July 28.
Roots, Sampling, and Logging are deprecated under a new feature-lifecycle policy. These are annotation-only deprecations, so they keep working for at least twelve more months, but the writing's on the wall. Logging's replacement is
stderrfor stdio and OpenTelemetry for structured observability.Tool
inputSchemaandoutputSchemaare lifted to full JSON Schema 2020-12, so you finally getoneOf,anyOf, conditionals, and$ref. One gotcha: implementations must not auto-dereference external$refURIs. Separately, the error for a missing resource changed from the MCP-custom-32002to the standard-32602. If your client matches on-32002, update it.Authorization got six hardening SEPs. The one to act on now: clients must validate the
issparameter on authorization responses per RFC 9207, and a future version will reject responses that omit it, so authorization servers should start sending it.
The Smith's Take
The protocol used to babysit your state, and the price was a server that only scaled with sticky routing and a shared session store bolted underneath it. The 2026-07-28 spec takes the babysitting away and hands you something better in the trade: state the model can see, name, and thread through a plan, on a server any load balancer can fan out. Stateless protocol, stateful application. Those were never in conflict, the old transport just made it feel that way.
The migration is smaller than the word "breaking" makes it sound. Take one MCP server you run, find the single tool that quietly depends on a session or an in-memory dict, and refactor it to mint one explicit handle and read its state from a shared store. Do that one tool this week, before July 28 turns it from a warning into an outage. The first time you watch the model carry that handle across three calls on its own, you'll stop missing the session.
Build agents that actually work.
