MCP Goes Stateless: What Changed and Why It Matters
The Streamable HTTP transport replaced HTTP+SSE and quietly made MCP servers stateless. Here is what that means, how the new transport works, and why it unlocks serverless and edge deployments.
I have been building and deploying MCP servers for a while now, and if you have tried to put one into production behind more than a single process, you have probably hit the same wall I did. The old transport fought you every step of the way. Load balancers needed sticky sessions, serverless platforms flat out refused to cooperate, and every deploy meant severing live connections and hoping clients reconnected cleanly.
Then the spec changed, and a lot of that pain quietly went away.
The headline is that MCP went stateless. That is not quite accurate, and the gap between “went stateless” and what actually happened is worth understanding, because it is the difference between a server you can run on a single box and one you can scale across an entire edge network. Let me walk you through what really changed, how the new transport works under the hood, and why I think it is one of the more important updates the protocol has shipped.
First, a quick reminder of what MCP is
If you are new here, I have written about what MCP servers are and why you should care and how to build your first one, so I will keep this short.
Model Context Protocol is an open standard that lets AI assistants talk to your tools and data through a consistent interface. You expose tools, resources, and prompts; the AI discovers and calls them. Under the hood it is just JSON-RPC messages flowing between a client and a server. The interesting question, and the one this article is about, is how those messages actually get from one side to the other. That is the transport.
The old world: HTTP+SSE
The original remote transport (protocol version 2024-11-05) was called HTTP+SSE, and it used two separate endpoints to do its job.
Here is the shape of it. The client opened a long-lived connection with a GET request:
GET /sse HTTP/1.1Accept: text/event-streamThe server held that connection open and, as its very first message, sent back an endpoint event telling the client where to POST its messages:
event: endpointdata: /messages?sessionId=abc123From then on, the client sent every request as a POST to that second endpoint, and the server pushed every response back down the still-open SSE stream from the first request.
Read that again, because the problem is hiding in plain sight. There are two endpoints, and there is a single long-lived connection that has to stay open for the entire life of the session. The responses to your POSTs do not come back on the POST. They come back on that separate, persistent GET stream.
That design has a consequence that bites the moment you try to scale. The connection is stateful and pinned. The server instance holding that SSE stream is the only instance that knows how to answer the client, because it holds the session in memory and it owns the open socket. So:
- You cannot freely load balance. Every request from a client has to route back to the exact instance holding its stream, which means sticky sessions and session affinity.
- Serverless and edge runtimes are largely off the table. Platforms like Cloudflare Workers or Lambda are built around short request/response cycles, not sockets you hold open for an hour.
- Deploys are disruptive. Rolling a new version means tearing down every open SSE connection, and every client has to notice and reconnect.
- A crashed instance takes its clients’ sessions down with it.
None of this is a knock on the people who designed it. SSE was a pragmatic way to get bidirectional-ish communication over plain HTTP. But it baked statefulness into the transport itself, and that turned out to be the wrong default for anything you wanted to run at scale.
The new world: Streamable HTTP
The 2025-03-26 revision replaced HTTP+SSE with a transport called Streamable HTTP, and it is still the transport in the current 2025-06-18 spec. The core idea is deceptively simple: collapse everything down to one endpoint and make streaming optional instead of mandatory.
The server exposes a single MCP endpoint, say https://example.com/mcp, that handles both POST and GET. Every message the client sends is a fresh POST to that one endpoint:
POST /mcp HTTP/1.1Accept: application/json, text/event-streamContent-Type: application/json
{ "jsonrpc": "2.0", "id": 1, "method": "tools/list" }Two things to notice. The client must advertise that it accepts both application/json and text/event-stream, and it sends its request as a normal POST body. No pre-opened stream required.
Now here is the part that changes everything. When the server receives that request, it gets to choose how to answer:
HTTP/1.1 200 OKContent-Type: application/json
{ "jsonrpc": "2.0", "id": 1, "result": { "tools": [ ... ] } }That is it. Request in, JSON out, connection closed. For a huge number of MCP interactions - list the tools, call a tool, read a resource - this is all you need. It is an ordinary HTTP request/response, the kind every load balancer, proxy, and serverless platform on earth already knows how to handle.
The server can still choose to stream when it makes sense. If the response might take a while, or the server wants to push progress notifications or make its own requests back to the client along the way, it responds with an SSE stream instead:
HTTP/1.1 200 OKContent-Type: text/event-stream…and then sends a sequence of SSE events, eventually including the JSON-RPC response to the original request before it closes the stream. Same endpoint, same POST, the server just upgrades the answer to a stream when it needs to. Streaming became a capability you reach for, not a tax you pay on every single call.
There is also a GET on that same endpoint, which the client can use to open an SSE stream purely so the server can send it unprompted messages. But crucially, you only need that if your server actually initiates communication. Plenty of servers never do.
So where does the “stateless” part come from?
Here is the nuance I promised at the top. Streamable HTTP did not remove sessions. It made them optional, and it moved the state out of the transport layer and into an explicit, opt-in header.
Sessions now work through a single header, Mcp-Session-Id. The lifecycle is clean and worth knowing exactly:
- During initialization, the server may assign a session by including an
Mcp-Session-Idheader on the HTTP response that carries theInitializeResult. - If the server did that, the client must include that same
Mcp-Session-Idheader on every subsequent request. - A server that requires sessions responds to any non-initialization request that is missing the header with
400 Bad Request. - The server can terminate a session whenever it likes; after that it answers requests carrying the dead session ID with
404 Not Found. - When a client sees that
404, it starts fresh by sending a newInitializeRequestwith no session ID attached. - A client that is done can send an HTTP
DELETEwith the session header to explicitly tear the session down.
The initialization handshake for a stateful server looks like this. Note the header coming back on the response:
POST /mcp HTTP/1.1Content-Type: application/json
{ "jsonrpc": "2.0", "id": 0, "method": "initialize", "params": { ... } }HTTP/1.1 200 OKContent-Type: application/jsonMcp-Session-Id: 1868a90c84d9ae56c5da7c67f2b6dc67
{ "jsonrpc": "2.0", "id": 0, "result": { ... } }And now the important observation: if the server simply never sends that header, there is no session. Every request stands entirely on its own. There is no in-memory state tying a client to a particular process, no socket to keep alive, nothing to lose when an instance restarts. That is the stateless mode, and it falls out naturally from the design rather than being bolted on.
If you use the official TypeScript SDK, this distinction is a single option. Give it a session ID generator and you get stateful sessions:
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";import { randomUUID } from "node:crypto";
// Stateful: the server tracks a session per clientconst transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(),});Set that generator to undefined and the server refuses to hand out session IDs at all. Every request is treated in isolation:
// Stateless: no sessions, every request is independentconst transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined,});The same server code, the same tools, the same handlers. One line decides whether you are running something you can only scale with sticky sessions, or something you can throw at a fleet of stateless workers.
Why stateless is the good part
Let me connect this back to that wall I hit at the start. When a server holds no per-client state, a whole category of operational problems just evaporates.
You can scale horizontally without thinking about it. Any request can be answered by any instance, because no instance is special. Put ten of them behind a plain round-robin load balancer and it works. No affinity rules, no session replication, no shared session store to babysit.
Serverless and edge deployments become viable. This is the one I care about most. A stateless MCP server is just an HTTP handler that takes a request and returns a response, which is exactly the shape that Cloudflare Workers, Lambda, and every edge runtime want. Since I deploy this very site to Cloudflare Workers, the idea that I can run an MCP server the same way, close to users, scaling to zero when idle, is genuinely exciting.
Deploys stop being disruptive. There are no long-lived connections to drain and no sessions to migrate. Ship a new version, let old requests finish, done. Rolling deploys become boring, which is the highest praise I can give a deploy.
You get resilience for free. An instance falls over? No client loses a session, because there were no sessions to lose. The next request routes somewhere healthy and nobody notices.
None of these are exotic. They are the ordinary benefits of stateless HTTP services that we have relied on for years. The point is that MCP servers can now be one of them, and before this transport landed, they largely could not.
What this looks like in Laravel
If you have read my guide to building an MCP server with Laravel, this next part will click immediately, because the Laravel MCP package leans into exactly this model.
When you register a server for remote access, you use the web transport in routes/ai.php:
<?php
use App\Mcp\Servers\TaskServer;use Laravel\Mcp\Facades\Mcp;
Mcp::web('/mcp/tasks', TaskServer::class);That single line is the Streamable HTTP transport, and the important thing is what it is not doing. It is not opening a socket and holding it. It registers an ordinary route, and every message from an AI client arrives as a normal HTTP request that flows through Laravel’s router, hits your middleware stack, and returns a response. There is no long-lived connection sitting in memory between calls.
Because it is just a route, everything you already know about scaling a Laravel app applies unchanged. Your auth, rate limiting, and any other middleware compose the way they always do:
Mcp::web('/mcp/tasks', TaskServer::class) ->middleware(['auth:sanctum', 'throttle:mcp']);Each request authenticates itself with its own token, gets throttled on its own, and stands entirely on its own. Nothing about request number two depends on which server instance happened to handle request number one. That is the stateless story told in Laravel’s vocabulary, and it means an MCP server is deployable exactly like the rest of your app - behind a load balancer, across a fleet of containers, or on the edge - with no special connection handling.
Contrast that with the local transport you would use for a desktop client:
Mcp::local('tasks', TaskServer::class);local speaks stdio to a single process on the same machine, which is perfect for tools like Claude Desktop but is inherently one process, one client. The moment you want to serve many clients over the network and scale, web and its stateless HTTP model are what you reach for.
What you give up, and when to keep sessions
I am not going to pretend stateless is a free lunch for every use case. Some things genuinely need a session, and it is worth being honest about them.
If your server needs to push messages to the client without being asked - server-initiated requests, sampling calls back to the model, long-running subscriptions that emit notifications over time - then you need that persistent GET stream, and that means a session and an instance that owns it. The same goes for anything that has to resume a broken stream. The spec supports resumability through per-stream event IDs and the Last-Event-ID header, where the client reconnects and the server replays what it missed, but that machinery only makes sense inside a session where there is a stream to resume.
So the honest guidance is this. If your MCP server is a request/response affair - here are my tools, call them, get answers - go stateless and enjoy the operational simplicity. If it is genuinely conversational in the sense that the server drives some of the interaction, reach for sessions, and accept that you are back in sticky-session territory for those clients. The beauty of Streamable HTTP is that this is now your decision to make per server, rather than a constraint the transport forces on you.
Wrapping up
The move from HTTP+SSE to Streamable HTTP looks, on the surface, like a boring plumbing change. One endpoint instead of two, an optional header instead of a mandatory socket. But that plumbing change is what turns an MCP server from something you nurse on a single instance into something you can deploy the way you deploy everything else: stateless, horizontally scaled, and sitting happily on the edge.
MCP did not exactly “go stateless.” It stopped forcing you to be stateful. And if you have ever fought a load balancer over a stubborn SSE connection at the worst possible time, you will understand why that is the update I am most glad they shipped.
If you are building on MCP, go check which transport your server and SDK are actually using. If you are still on the old HTTP+SSE transport, moving to Streamable HTTP is very likely the highest-leverage change you can make to how you run it.
Keep Reading
Eighteen Files And One Nix Module: How My Brain Works
The Obsidian vault I use as a system of record for every project, why the checkouts inside it are gitignored, and why half of it is generated from my NixOS flake.
Aug 2026 · 10 min read
DevOpsOne Host, Twenty-One Files: A NixOS Flake That Stays Out Of Your Way
How auto-imported flake-parts modules, wrapped desktop packages, and a single base16 palette file shape one NixOS config - and where each one bites.
Aug 2026 · 13 min read
LaravelSeven Days in Ten Milliseconds
A workflow that sleeps for three days is not a workflow you can test by waiting. Owning the clock, asserting on absence, and the races you only get one shot at.
Aug 2026 · 10 min read