Skip to content

Writing

MCP Just Went Stateless: What That Means If You're Building AI Agents

6 min readOriginally published on Medium

Say you're running an MCP server behind a load balancer, with a few instances for redundancy. A client connects and gets a session ID back, and every request after that depends on the server infrastructure being able to recover the session state tied to that ID, which in the simplest setup means keeping the client pinned to the exact instance that created it, since that's where the session lives, in memory. What happens when the load balancer doesn't know that, and routes the next request somewhere else? What happens when that instance restarts for a routine deploy, mid-session?

That's the exact class of problem the Model Context Protocol's newest spec revision removes, by removing the thing that causes it.

Why Sessions Existed

MCP (Model Context Protocol) is the emerging standard for how AI agents call tools and read data from external servers. Think of it as an API contract, but designed for a model to discover and call, not a human reading documentation. A client (an agent, an IDE, a chat app) connects to an MCP server and gets a list of tools it can invoke.

Under the transport MCP servers commonly use (Streamable HTTP), the 2025-11-25 revision and earlier made sessions optional but common: a server could assign an Mcp-Session-Id during initialization. If it did, every subsequent request had to carry that same ID, and the server was expected to keep that session's state alive in memory until it was explicitly closed. In practice, that's what pins a client to one server instance: the session lives wherever it was created.

What Changed on 2026-07-28

The 2026-07-28 revision removes that mechanism from the protocol entirely, not just discourages it. Per the official changelog: it removes "protocol-level sessions and the Mcp-Session-Id header from the Streamable HTTP transport," and separately removes the initialize/notifications/initialized handshake, so the protocol version and client capabilities now travel with each request instead of being negotiated once during that handshake. A server that needs state across calls now has to pass an explicit, server-minted handle as an ordinary tool argument; state becomes something the application manages on purpose, not something the transport gives you for free.

Before vs. After

MCP request flow: stateful, 2025-era transport (opens full size in a new tab)

The stateful flow has a step the stateless flow simply doesn't: something has to hold the session in memory between requests, and every later request depends on reaching the instance holding it.

MCP request flow: stateless, 2026-07-28 spec (opens full size in a new tab)

The stateless flow has no equivalent step: each request carries what it needs and gets a fresh transport. It's not that the stateless version does the session-pinning step faster; it doesn't do that step at all.

What My Server Was Already Doing

My own MCP server, at /api/mcp on this site, has run this way since before the 2026-07-28 revision existed, not because I predicted the spec change, but because the server has nothing that needs to persist between calls:

const server = createMcpServer();
const transport = new WebStandardStreamableHTTPServerTransport({
  sessionIdGenerator: undefined,
  enableJsonResponse: true,
});

await server.connect(transport);
const response = await transport.handleRequest(request);

await transport.close();
await server.close();

sessionIdGenerator: undefined tells the SDK never to mint a session ID: no Mcp-Session-Id header goes out, so there's nothing for a client to carry back in, and nothing pinning it to this instance. A fresh McpServer and transport are created per request and torn down right after. enableJsonResponse: true returns a plain JSON response instead of opening an SSE stream, which fits here because every tool this server exposes is a fast, non-streaming lookup, so there's no application data here that benefits from streaming.

The server exposes three tools, get_profile, list_skills, and get_project_experience, all read-only lookups against static data: a profile object and a JSON file of featured projects. None of them need to remember anything about a previous call. Given that, keeping a session alive in memory would have added a failure mode (what happens to an orphaned session if a client disconnects mid-flow) for a benefit the server never uses.

The code comment in mcp-server.js states the other deliberate choice directly: this endpoint does not expose the conversational chat model that lives at /api/chat (the same one behind the guardrail layers I wrote about last week). Every tool here returns fixed, non-model-generated data, which removes the model-based prompt-injection surface from this endpoint specifically: there's no model call here for an injected instruction to reach. It doesn't say anything about other kinds of attack surface a read-only API can have; it's a narrow claim about one specific risk, not a general security guarantee for the endpoint.

What This Doesn't Mean

Worth being precise here: I'm running SDK version 1.30.1, whose newest supported protocol version is 2025-11-25; it doesn't speak the 2026-07-28 wire format yet, so there's no per-request _meta versioning or server/discover call happening here. Upgrading to that wire format isn't a config flag either; the SDK's own migration docs point v1.x users to the v2 package line first, because the 2026-07-28 support lives there, not in a 1.x release.

What I actually built is the optional no-session mode that the older transport already allowed. It happens to be the same shape the new revision now makes mandatory for everyone, but I'd be overstating it to call my server spec-compliant with a revision its own SDK hasn't shipped support for. "I use a pattern the new spec also uses" and "I implement the new spec" are different claims, and only the first one is true here.

Should You Change Anything?

If your MCP server's tools are stateless lookups like mine (no per-call memory needed), there's nothing urgent to do. The pattern this revision mandates is one you can already opt into today, on the current SDK, the same way I did: sessionIdGenerator: undefined. Migrating to a v2 package for the actual 2026-07-28 wire format becomes worth it when you need what that revision adds (the per-request _meta envelope, server/discover), not automatically the moment it ships.

If your server does keep state across calls (a multi-step workflow, a long-running job), this revision is the more useful prompt: state now has to be explicit, passed as a server-minted handle in a tool argument, instead of living implicitly in whatever instance happens to hold the session. That's a real migration, not a configuration tweak.

Either way, the numbers behind my own answer here are small enough to state plainly: the whole feature (route handler, server/tools definition, and the profile data it serves) comes to 168 lines across three files (34, 68, and 66). There's no session table, no in-memory map of client state, no cleanup job for expired sessions, because there was never anything to expire. The tradeoff is real, not free: every request pays the cost of constructing a new McpServer and transport instance, connecting them, then closing both down afterward. That's a bad tradeoff if your tools do something expensive to set up, like a persistent database connection pool or a loaded model. For three tools that each do one cheap in-memory lookup, the construction cost is small enough not to matter.

Final Thoughts

  • Session state isn't the default you get for free; it's a specific tradeoff, and it's worth asking whether your server actually needs it before reaching for it.
  • A spec revision that removes a feature is sometimes just formalizing what already worked for servers that didn't need it; check whether you were already doing the simpler thing before assuming a spec change requires you to rewrite anything.
  • Know exactly which protocol version your dependency actually implements before describing your own server as compliant with a revision.
  • Deciding what not to expose through an interface (here, the conversational model) is as much a design decision as picking what to expose.

Statelessness didn't make my server simpler because I optimized for it. It's simple because the server never had state to manage in the first place, and the newest revision of the spec just stopped treating that as the unusual case.