Stateless MCP servers
An MCP server over HTTP has, until now, opened with a handshake. The server may then hand back a session id, and if it does, the client carries that id on every later request while the server holds the matching transport in memory. I wrote my authenticated Sales Explorer that way to begin with, then took the session out, because binding a user to a session id leaked: the server routed by session and stopped checking whose token was on the request.
For a server whose whole job is taking a request and returning an answer, that session does no work. All it does is make the thing harder to deploy.
The July 2026 revision of the spec settles it by removing sessions from the protocol entirely, so every request has to stand on its own.
This post runs both servers side by side against the same tool and the same client, compares them on the wire with curl, then looks at what the spec change actually took away.
The session, and what it costs
MCP over HTTP uses the Streamable HTTP transport. A server may assign a session at initialization by putting an Mcp-Session-Id header on the response to the initialize call. Sessions were always opt-in, and the spec introduces them with “to support servers which want to establish stateful sessions”. Once a server does it, the client has to send that header back on every subsequent request. The server, meanwhile, keeps the live transport for that session in memory so it can match later requests to it.
Here’s that whole exchange, with the one call anyone actually wanted sitting at the bottom of it.
Under 2025-11-25 and earlier, initialize is the JSON-RPC method a client sends before anything else. It’s a POST to the same endpoint, and it has to carry Accept: application/json, text/event-stream or a compliant server answers 406:
{ "jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": { "protocolVersion": "2025-11-25", "capabilities": {},
"clientInfo": { "name": "demo", "version": "1.0.0" } } }
The code that handles it is the pattern the SDK’s own examples use: a lookup of session id to transport, a branch that spots that first call and mints a session for it, and a lookup for everything after. The SDK ships isInitializeRequest() so you don’t have to sniff the method name yourself.
// Session id -> its live transport. This Map is the state.
const transports = new Map<string, StreamableHTTPServerTransport>();
app.post("/mcp", async (req, res) => {
const sessionId = req.headers["mcp-session-id"] as string | undefined;
let transport = sessionId ? transports.get(sessionId) : undefined;
if (!transport && isInitializeRequest(req.body)) {
// A fresh session: mint an id, keep the transport alive in the Map.
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (sid) => { transports.set(sid, transport!); },
});
await buildServer().connect(transport);
} else if (!transport) {
// No session id on a non-initialize request: 400.
return res.status(400).json({ jsonrpc: "2.0",
error: { code: -32000, message: "Bad Request: no valid session id" }, id: null });
}
await transport.handleRequest(req, res, req.body);
});
initialize returns the id in a header:
HTTP/1.1 200 OK
content-type: text/event-stream
mcp-session-id: 4436b290-e76c-4318-b325-64a474673df8
And a tools/call that doesn’t carry it back gets nothing:
HTTP/1.1 400 Bad Request
{"jsonrpc":"2.0","error":{"code":-32000,"message":"Bad Request: no valid session id"},"id":null}
That lookup lives in the memory of one running server, because that’s how the examples build it. Nothing in the spec demands it. A session id is allowed to be a JWT, which any server can validate without storing anything.
Build it the way the examples do, though, and a second server knows nothing about the first one’s sessions. A client that initialised against one gets rejected by the other, and restarting the server does the same to every client at once.
There’s a detail worth knowing here. The spec says a server returns 404 for a session it has terminated, and a client seeing 404 MUST start a new one. The SDK’s hosting pattern rejects an unrecognised id with 400 instead, one layer above the transport, and 400 carries no such obligation. The client that could have silently re-initialised is stranded by the code meant to protect it.
Take the session out
The same SDK does stateless. You set one option, sessionIdGenerator: undefined, and instead of one long-lived transport you build a fresh server and transport for each request, answer it, and drop both when the response closes.
app.post("/mcp", async (req, res) => {
// Fresh server + transport per request. `sessionIdGenerator: undefined` is the
// switch: no session is assigned, and no `Mcp-Session-Id` header comes back.
const server = buildServer();
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
res.on("close", () => { transport.close(); server.close(); });
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
});
The Map, the initialize branch and the lookup all go. initialize itself still works, but the response comes back without a session id, because there’s no session to name:
HTTP/1.1 200 OK
content-type: text/event-stream
<- no mcp-session-id
More to the point, this server answers a tools/call cold, with no handshake in front of it and no session id attached.
$ curl :3004/mcp -d '{... tools/call get_sales EMEA ...}'
data: {"result":{"content":[{"type":"text","text":"EMEA total: 533"}], ... },"id":2}
With no session to stream to or tear down, GET (the server-to-client stream) and DELETE (end session) have nothing useful to do. The transport itself will still answer them, opening an SSE stream on GET and returning 200 on DELETE, so the official stateless example turns both away in its own route handlers with 405 Method Not Allowed. The spec allows that: on GET a server either returns an SSE stream or a 405, and on DELETE the 405 is optional and says clients can’t end sessions.
Here’s the wire contrast end to end, replayed from the actual curl runs:
The client doesn’t care
None of this reaches the client. StreamableHTTPClientTransport does the session bookkeeping either way: against the stateful server it reads the id off the initialize response and echoes it on every later request; against the stateless server there’s nothing to read and it sends nothing. Same client code, pointed at either server.
const transport = new StreamableHTTPClientTransport(url);
await client.connect(transport);
console.log(`negotiated session id: ${transport.sessionId ?? "(none)"}`);
Run it against both and the only thing that changes is that one line:
Same tools, same totals, with a UUID on one and (none) on the other.
The spec caught up
Everything above uses the transport as the current SDK implements it, where the session is optional and you switch it off. The 2026-07-28 spec went further and removed the option.
As of that revision there are no protocol-level sessions and no Mcp-Session-Id header. The initialize / notifications/initialized handshake is gone too. Every request now carries its own protocol version and client capabilities in _meta, so any server can read a request in full without having seen an earlier one. The changelog is blunt about it:
Make MCP stateless: remove the
initialize/notifications/initializedhandshake. Every request now carries its protocol version and client capabilities in_meta…
The GET stream went with it, along with resources/subscribe and resources/unsubscribe, all replaced by an opt-in subscriptions/listen for the servers that really do push notifications. Cross-call state, when a server needs it, moves into explicit server-minted handles passed as ordinary tool arguments, instead of hiding behind a session header.
Every snippet in this post is TypeScript SDK code, and the SDK hasn’t caught up. At 1.30.0 the newest protocol it implements is 2025-11-25.
Those are separate knobs, though. sessionIdGenerator: undefined controls sessions on their own, and you speak 2025-11-25 with it either way. That’s the newest revision the spec has actually released. The handshake still happens, _meta isn’t carrying capabilities, and server/discover has zero occurrences in the package.
So copy the code above and you get the deployment story today, on a revision that hasn’t formally dropped sessions. Work on 2026-07-28 lives in the unreleased v2 packages, where it’s opt-in even then.
What stateless costs you
Identity is the first thing people assume they’ll lose, and they don’t. The Sales Explorer argues the stronger version of this: it dropped its session because keeping one was actively worse for auth. MCP over HTTP checks an Authorization: Bearer token on every request either way, so a stateless server validates on the tool call itself rather than trusting a binding made back at initialize. The per-user server object gets rebuilt per request, the same way the stateless server above rebuilds buildServer().
The genuine losses are elsewhere, and the spec names them itself.
The first is resumability. The old transport let a client reconnect with Last-Event-ID and have the server replay what it missed on that one stream, and every step of it was optional: servers chose whether to emit event ids at all, and whether to honour the header. Now a broken stream loses the request that was in flight and the client has to send it again with a new id.
The bigger change is how a server asks the client for something part-way through a request. Sampling, elicitation and roots used to travel as server-initiated requests down the open stream.
They still work, as a retry. The server returns an InputRequiredResult, the client collects what’s needed, then re-sends the call with the answer in inputResponses and a fresh JSON-RPC id, because the two are independent requests. The spec is explicit that elicitation can still happen while a request is being processed.
Server-to-client reach did shrink, though. InputRequiredResult is only allowed on prompts/get, resources/read and tools/call, so a server can no longer elicit out of the blue the way it could on the old standing stream. The notifications/elicitation/complete signal is gone, which leaves URL-mode flows with no direct notice that the out-of-band step finished. And a server MUST NOT assume the client will retry at all, where the old in-flight request was guaranteed a response or an error.
What it costs is a round trip, and a server that can pick up where it left off. That usually means encoding what it needs in requestState and reading it back when the retry lands. Roots, sampling and logging are all deprecated on top of this, though they stay functional through the deprecation window. Elicitation isn’t on the list.
Anything a server used to hang off the session now has to be an explicit handle. The server mints it, and the client sends it back on the next call.
None of that touches a plain request-and-reply tool, which covers most of them, and those are better off keeping nothing between one call and the next.
Old clients, new servers
The client is doing its half of all this, so the fair question is what happens to one that hasn’t been updated. 2026-07-28 breaks the wire format outright. An old client opens with the initialize handshake, and a modern-only server can only answer that with an error the client has no way to act on. A modern server is told to ignore the session header outright, so what actually breaks it is the missing protocol version and method headers the new revision requires. The spec is blunt that legacy clients get no fall-forward mechanism, so that pairing simply fails.
Servers are where you fix this. One can run “dual-era,” accepting both, and pick behaviour per request from how the client opens: an initialize gets the old sessioned semantics, a request carrying the new per-request _meta gets served statelessly. Same endpoint, either kind of client. No official page prescribes a migration order, but the compatibility matrix only leaves one that never breaks: a modern client against a legacy server fails, while a legacy client against a dual-era server works. So servers go first, and a client that never updates keeps working for as long as some server still speaks the old dialect.
The sample app is both servers and one client in about 100 lines, so you can run the curl above yourself and watch the session id show up in one and go missing in the other.