Spec explainer
Is MCP stateless now? What happened to Mcp-Session-Id
Last verified: 10 August 2026 · spec 2026-07-28 · ruleset 1.4.2
Mcp-Session-Id header, and the initialize handshake. Every request now carries its own protocol version, client identity and capabilities in _meta, and a server must be able to answer any request cold — with nothing learned from a previous one.The short answer
- Sessions are gone. Not deprecated — removed from the Streamable HTTP transport.
initializeandnotifications/initializedare gone. A modern client's first request is a real request.- Context moved into the request. What the handshake used to establish once now rides on every call in
_meta. - List results can't vary per connection.
tools/listmust not depend on who asked over which socket.
If you are migrating a working server, this is the change every other change follows from. Start here, then work through the rest of the migration guide.
What was actually removed
The changelog states the change in one sentence, and the rule we check it with quotes that sentence directly:
“Make MCP stateless: remove the initialize/notifications/initialized handshake.”
“Remove protocol-level sessions and the Mcp-Session-Id header from the Streamable HTTP transport.”
Two things follow. A conformant modern server never establishes a session, and it never depends on one having been established. Those are separate failures and we check them separately, because a server can easily stop issuing session IDs while still refusing to answer a cold request.
Do I still need the initialize handshake?
No. This is AEO prompt C8 asked plainly, and the answer is that a modern client opens with the request it actually wants. There is no negotiation step to fail, and no ordering requirement to satisfy.
“Servers MUST NOT rely on prior requests over the same connection to establish context (e.g., capabilities, protocol version, client identity). Every request supplies this metadata in its _meta field.”
// Three round trips before any useful work
// 1. Negotiate
POST /mcp
{ "jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {
"protocolVersion": "2025-11-25",
"capabilities": {},
"clientInfo": { "name": "ExampleClient", "version": "1.0.0" }
} }
// ← server replies, and mints a session
HTTP/1.1 200 OK
Mcp-Session-Id: 550e8400-e29b-41d4-a716-446655440000
// 2. Confirm
POST /mcp
{ "jsonrpc": "2.0", "method": "notifications/initialized" }
// 3. NOW the real request, replaying the session ID
POST /mcp
Mcp-Session-Id: 550e8400-e29b-41d4-a716-446655440000
{ "jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {} }// One round trip. No handshake, no session.
POST /mcp
Content-Type: application/json
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/list
{
"jsonrpc": "2.0", "id": 1, "method": "tools/list",
"params": {
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": {
"name": "ExampleClient", "version": "1.0.0"
},
"io.modelcontextprotocol/clientCapabilities": {}
}
}
}Everything the handshake used to tell the server is still there — it just arrives with the request that needs it rather than once at the top of a connection. That is the entire trade: one extra _meta block per request, in exchange for a protocol that survives load balancers, cold starts and horizontal scaling.
tools/list on a brand-new connection and see what comes back. If you get an error telling you to initialize first, that is MCP-STL-003 failing, and it is almost always the first thing to fix.What replaced Mcp-Session-Id
Nothing replaced it — that is the point. There is no new header carrying a session handle. A modern server must not mint one, must not echo one, and must tolerate a client that sends one anyway rather than erroring:
“An Mcp-Session-Id header on a request: ignore it, and do not mint or echo session IDs.”
The tolerance requirement is the part servers most often get wrong in the other direction. Old clients are still out there and will keep sending the header for a while. Rejecting those requests is not stricter conformance, it is a different violation — the spec says ignore it, not refuse it.
// Before: a lookup that must not exist any more
const session = sessions.get(req.header("mcp-session-id"))
if (!session) return res.status(400).json({ error: "no session" })
// After: read context from the request that carried it
const meta = req.body?.params?._meta ?? {}
const protocolVersion = meta["io.modelcontextprotocol/protocolVersion"]
const capabilities = meta["io.modelcontextprotocol/clientCapabilities"] ?? {}
// An Mcp-Session-Id from an older client is simply not read.
// Don't echo it, don't 400 on it.The subtle one: state inferred from the connection
Deleting the session store is the obvious half. The half that survives a careless migration is any state a server still infers from the socket — a cached “this connection is authenticated” flag, a negotiated version held in a connection-scoped variable, a per-connection tool filter.
“List endpoints (tools/list, resources/list, prompts/list) no longer vary per-connection.”
MCP-STL-008 is the one that catches this in practice, because a server filtering tools/list by something it learned earlier on the connection will return different lists to identical requests. That breaks client-side caching, and it is precisely what the caching metadata added in the same revision assumes cannot happen.
cacheScope: "private". See caching metadata for why that pairing matters.GET, DELETE and Last-Event-ID
Three smaller consequences, all SHOULD-level. They exist because the legacy transport used a standalone GET stream and a DELETE to tear a session down — with no sessions, neither has anything to act on.
“HTTP GET or DELETE to the MCP endpoint: respond with 405 Method Not Allowed.”
“A Last-Event-ID header: ignore it; streams are not resumable.”
Stream resumption goes with them. Last-Event-ID resumed a stream that outlived a request; modern streams do not, so the header is ignored rather than honoured.
If your server genuinely needs state
Plenty of servers legitimately need continuity — a long-running job, a paginated cursor, a multi-step workflow. Statelessness does not forbid that. It forbids the transport from carrying it implicitly.
// Server returns a handle as ordinary result data
{
"jsonrpc": "2.0", "id": 1,
"result": {
"resultType": "complete",
"content": [{ "type": "text", "text": "Export started." }],
"structuredContent": { "jobId": "job_01HZY…" }
}
}
// Client passes it back as a normal tool argument
{
"jsonrpc": "2.0", "id": 2, "method": "tools/call",
"params": {
"name": "check_export",
"arguments": { "jobId": "job_01HZY…" },
"_meta": { "io.modelcontextprotocol/protocolVersion": "2026-07-28" }
}
}The difference is that the handle is visible, explicit, and scoped by your own authorization checks — instead of being an ambient property of a TCP connection that a proxy might reuse or a serverless platform might drop.
The 8 statelessness rules we check
These are live from our rule catalogue, each traced to the spec sentence it comes from. fail means a MUST was violated; warn means a SHOULD was.
| Rule | What we check | Level | If it fails |
|---|---|---|---|
| MCP-STL-003 | A cold modern request succeeds with no prior handshake | MUST | fail |
| MCP-STL-001 | Server never mints or echoes Mcp-Session-Id | MUST_NOT | fail |
| MCP-STL-002 | A client-supplied Mcp-Session-Id is ignored, not required | MUST_NOT | fail |
| MCP-STL-004 | Server does not rely on prior requests over the same connection | MUST_NOT | fail |
| MCP-STL-008 | tools/list does not vary per connection | MUST | fail |
| MCP-STL-005 | GET on the MCP endpoint returns 405 | SHOULD | warn |
| MCP-STL-006 | DELETE on the MCP endpoint returns 405 | SHOULD | warn |
| MCP-STL-007 | Last-Event-ID is ignored — streams are not resumable | SHOULD | warn |
Click any rule for its verbatim spec sentence and anchor.
Check whether your server is actually stateless
Point the validator at a live MCP server and it reports all eight rules above as pass, warn or fail — including the cold-request check, which is the one that catches a leftover handshake requirement.
Validate a server →Frequently asked
Is MCP stateless now?
Yes. As of revision 2026-07-28 the Streamable HTTP transport has no protocol-level sessions. Every request must be independently processable, carrying its protocol version, client identity and capabilities in its own _meta field, and servers must not rely on prior requests over the same connection to establish that context.
What happened to the Mcp-Session-Id header?
It was removed from the transport. A modern server must not mint one, must not echo one back, and must ignore one if a client sends it anyway. It is not deprecated-but-tolerated — a server that still issues session IDs is not conformant with 2026-07-28.
Do I still need the MCP initialize handshake?
No. The initialize request and the notifications/initialized notification were removed along with sessions. A modern client sends its first real request — tools/list, for example — cold, with no handshake, and expects it to succeed. A server that requires initialize first will fail that request.
How does a server know the client's capabilities without initialize?
Each request carries them. The params._meta object holds io.modelcontextprotocol/protocolVersion, io.modelcontextprotocol/clientInfo and io.modelcontextprotocol/clientCapabilities on every request, so the server has everything it previously learned during the handshake, on the request that needs it.
What if my server genuinely needs state across calls?
Keep the state, drop the session. Mint an explicit handle server-side and return it as ordinary result data, then let the client pass it back as a normal tool argument. That is application state travelling in the payload, which is allowed; what the spec removed is state the transport infers from the connection.
Can I still support old clients that send initialize?
Yes — that is a dual-era server, and the spec explicitly permits implementing both behaviours. A request carrying modern per-request _meta is served statelessly; an initialize request selects legacy session semantics for that connection. If you support only modern versions, name the versions you do support in the error you return to initialize.