The 2026-07-28 MCP spec deletes protocol-level sessions. Here's the before/after for rewriting your MCP server stateless without throwing away app state.
If you run a remote MCP server today, there's a good chance it leans on a session: the client opens a connection, you both run the initialize handshake, the server hands back an Mcp-Session-Id, and every later request carries that header. The 2026-07-28 spec release candidate deletes all of that (SEP-2567). No session header, no protocol-level session, no sticky routing. The final spec publishes July 28, so the ten-week window we're in right now is the time to find out whether your server quietly assumed a session existed.
This isn't a cosmetic change. It rewrites how state, scaling, and even your load balancer config work. Here's what actually breaks, what to do instead, and the one mental trap I watched a team fall into within a day of reading the RC.
The pre-2026 Streamable HTTP flow started every connection with a two-step handshake. The client sent initialize with its protocol version and capabilities, the server replied with its own, and from then on the session ID glued the conversation together. A typical Node server looked roughly like this:
// BEFORE: session-bound server
const sessions = new Map(); // sessionId -> { cart, cursor, auth }
app.post('/mcp', (req, res) => {
const sid = req.header('Mcp-Session-Id');
if (req.body.method === 'initialize') {
const newSid = crypto.randomUUID();
sessions.set(newSid, { cart: [] });
res.setHeader('Mcp-Session-Id', newSid);
return res.json({ result: { capabilities, protocolVersion: '2025-06-18' } });
}
const state = sessions.get(sid); // dies if request hits another instance
if (!state) return res.status(404).json({ error: 'unknown session' });
// ...handle tools/call using state.cart
});
That sessions Map is the problem. It pins a conversation to one process. The moment you scale past a single instance you need sticky sessions at the load balancer, or a shared Redis store, or a gateway that inspects the body to route. Anyone who has run MCP behind more than one pod has paid this tax.
In the new spec, the session header and the initialize/initialized handshake are both gone (SEP-2575). The protocol version, client info, and client capabilities now ride in _meta on every request. A new server/discover method lets a client pull server capabilities on demand instead of receiving them once at connect time. And the transport now requires two new headers, Mcp-Method and Mcp-Name (SEP-2243), so a load balancer or rate-limiter can route on the operation without parsing JSON.
The same server, rewritten, stops caring which instance a request lands on:
// AFTER: stateless server, any instance handles any request
app.post('/mcp', (req, res) => {
const { protocolVersion, clientInfo } = req.body.params?._meta ?? {};
const method = req.header('Mcp-Method'); // routing without body inspection
if (req.body.method === 'server/discover') {
return res.json({ result: { capabilities, protocolVersion: '2026-07-28' } });
}
if (req.body.method === 'tools/call') {
// state arrives as an ordinary argument, not from a session
const { basketId, items } = req.body.params.arguments;
const basket = loadBasket(basketId); // your DB, not in-process memory
// ...do the work
}
});
The shift is subtle but total: there's no per-connection memory to lose, so you can put this behind a plain round-robin balancer and stop thinking about affinity entirely.
The two new headers are easy to dismiss as bookkeeping, but they're the part your platform team will care about most. Because Mcp-Method and Mcp-Name sit on the request itself, your gateway can rate-limit tools/call separately from server/discover, route expensive tools to a beefier pool, or reject an operation at the edge, all without an application-layer parser cracking open the JSON body. On Azure that means a plain Application Gateway rule or an APIM policy keyed on a header, instead of the body-inspection gymnastics the old session model forced on you. If you've ever written a custom rule to peek inside an MCP payload just to route it, you get to delete it.
Here's the thing only worth saying because people are already getting it wrong. "Stateless" describes the protocol, not your application. I saw a team read the RC and conclude they had to rip out their shopping-cart logic because "MCP is stateless now." That's backwards.
The spec's own guidance is the right instinct: when you need state across calls, do what HTTP APIs have always done. Mint an explicit handle from a tool and have the model pass it back as a normal argument. A basket_id, a browser_id, a job_id are all just strings the model carries. The state still lives in your database; what changed is that the protocol no longer pretends to manage it for you.
// Tool returns an explicit handle the model will pass back later
function createBasket() {
const basketId = crypto.randomUUID();
db.baskets.insert({ id: basketId, items: [] });
return { content: [{ type: 'text', text: `Basket ${basketId} created` }],
structuredContent: { basketId } };
}
// Later call carries the handle as a plain argument
function addItem({ basketId, sku }) {
db.baskets.update(basketId, { $push: { items: sku } });
}
If you came from a SQL Server or MongoDB background this should feel completely ordinary. It's the same pattern as a stateless web API issuing a row ID. The only difference is that the "client" holding the ID is a model, and the ID travels in tool arguments instead of a URL path. The win: those baskets now survive a pod restart, which the in-memory Map never did.
If your server ever asked the client a question mid-call (elicitation) or asked the client's model to generate something (sampling), that worked by the server opening a request channel back to the client and holding a connection open. A held connection is incompatible with a stateless, horizontally-scaled server, so SEP-2322 replaces it with Multi Round-Trip Requests.
Now a tools/call can return an InputRequiredResult instead of completing. That result carries inputRequests (the questions the client must answer) plus an opaque requestState the client must echo back unchanged. The client gathers answers and re-issues the original call with inputResponses keyed identically, plus that echoed state. Because all the resume state rides in the payload, any instance can pick the work back up.
// First call: server needs input, returns instead of blocking
function bookFlight({ from, to }) {
const options = search(from, to);
return {
resultType: 'input_required',
inputRequests: {
pick: { type: 'elicitation', schema: { /* which flight? */ } }
},
// opaque, signed blob: encode everything needed to resume
requestState: sign({ from, to, options })
};
}
// Second call: client re-issues original call with answers + echoed state
function bookFlight_resume({ inputResponses, requestState }) {
const { options } = verify(requestState); // no server memory needed
return finalize(options[inputResponses.pick.index]);
}
The practical cost: requestState is now a thing you have to design. Treat it like a signed token. Encode what you need to resume, sign it so a client can't tamper, and keep it small because it round-trips on every step. Don't stuff a whole result set in there; store that in your DB keyed by an ID and put the ID in the state. This is the part of the migration that needs real thought, not a find-and-replace.
It would be easy to read "breaking changes" and panic-migrate before July 28. Don't. Features are deprecated in the RC (SEP-2577), not removed, and the new lifecycle policy (SEP-2596) says a feature must stay deprecated for at least twelve months before it's eligible for removal. Your session-based server will keep working with clients that still speak the old transport for a long time.
So the sane order of operations: get your server reading _meta and answering server/discover first, since those are additive and let new clients talk to you. Move per-session memory into explicit handles next, because that's the change that actually buys you horizontal scaling. Save the elicitation/sampling rewrite for last, since MRTR is the most involved and the fewest servers use it.
If I owned a production MCP server right now, I'd spend an afternoon grepping for three things: any read of Mcp-Session-Id, any in-process Map or object keyed by session, and any server-initiated sampling or elicitation call. Those three are your entire migration surface. The first is a header rename plus reading _meta. The second is the cart-to-handle pattern above. The third is MRTR.
The deeper point is that MCP is converging on how the rest of the web already works: stateless requests, explicit handles, route on headers, sign your resume tokens. If you've built a stateless REST API behind a load balancer, you already know how to build a 2026 MCP server. The protocol finally stopped being the special case. That's less exciting than a shiny new feature, and far more useful, because the boring choice that makes everything downstream simpler is usually the right one. Ship the additive changes now, move state into handles when you can, and let the deprecation clock buy you the rest.