Build-it

How to make an MCP server stateless (2026-07-28 spec)

Make an MCP server stateless for the 2026-07-28 spec by setting the Streamable HTTP transport's sessionIdGenerator to undefined: drop sticky sessions, keep your tools.

Mark

Head of Marketing, MCPOrbit

Published
Updated
· Updated
Read time
· 9 min read
A client sending three independent requests that each route to a different stateless MCP server replica (A, B, C), with the Streamable HTTP transport's sessionIdGenerator set to undefined and a crossed-out session store.

To make an MCP server stateless for the 2026-07-28 spec, stop issuing session IDs: set the Streamable HTTP transport's sessionIdGenerator to undefined and build a fresh server plus transport for each request. That is the whole migration. With no session to pin, any replica can serve any request behind a plain round-robin load balancer, with no sticky sessions and no shared session store, and your tools and resources do not change at all.

The 2026-07-28 revision of the Model Context Protocol (MCP) makes a stateless protocol core the default for remote servers. The stateful Streamable HTTP transport most remote servers shipped with mints a per-client Mcp-Session-Id and keeps that session's transport in the process's memory, which forces every follow-up request onto the same instance: the operational tax we call sticky sessions. Statelessness removes that constraint. We took the read-only Postgres MCP server from our earlier build-it tutorial and migrated it; below is the real diff, the tested request/response behaviour, and a two-replica load balancer you can run.

What changed in the 2026-07-28 MCP spec?

The 2026-07-28 revision (release candidate at the time of writing, with beta SDKs shipped) lands four practitioner-facing changes. First, a stateless protocol core: remote servers should not require a sticky session or a shared session store to be correct. Second, authorization hardening aligned with OAuth 2.1 / OIDC, so each request presents and independently validates its own token, which is exactly what makes statelessness safe, because the auth state lives in the token, not in server memory. Third, routable headers and explicit multi-round-trip handling so intermediaries and load balancers can forward requests without understanding MCP internals. Fourth, a formal deprecation policy so protocol changes arrive on a predictable schedule.

You do not need a brand-new SDK to get the mechanism: the stateless Streamable HTTP transport already exists in @modelcontextprotocol/[email protected], the version this post pins and tests against. The 2026-07-28 spec makes stateless the recommended default; the code below is how you adopt it today. Pin whichever beta SDK your fleet standardises on and keep the version in your package.json.

The stateful server you are migrating from

Here is the "before": a Streamable HTTP server that mints a session ID on initialize and stashes the live transport in a module-level Map. Every later request must carry that session ID and land on this exact process, because the transport for the session exists only in this instance's memory. Behind more than one replica, that is what forces sticky sessions.

// http-stateful.ts: the "before". Needs sticky sessions behind a load balancer.
import { randomUUID } from "node:crypto";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
import { buildServer } from "./server-core.js"; // your tools + resources, unchanged

const transports = new Map<string, StreamableHTTPServerTransport>();

async function handleMcp(req, res, body) {
  const sessionId = req.headers["mcp-session-id"] as string | undefined;

  if (sessionId && transports.has(sessionId)) {
    // Only works if this request is routed back to the instance that owns the session.
    await transports.get(sessionId)!.handleRequest(req, res, body);
    return;
  }

  if (!sessionId && isInitializeRequest(body)) {
    const transport: StreamableHTTPServerTransport = new StreamableHTTPServerTransport({
      sessionIdGenerator: () => randomUUID(),                 // issues Mcp-Session-Id
      onsessioninitialized: (id) => { transports.set(id, transport); }, // in-memory state
    });
    const server = buildServer();
    await server.connect(transport);
    await transport.handleRequest(req, res, body);
    return;
  }

  res.writeHead(400).end(JSON.stringify({
    jsonrpc: "2.0", id: null,
    error: { code: -32000, message: "No valid session ID for a non-initialize request" },
  }));
}

The one change that makes an MCP server stateless

Setting sessionIdGenerator to undefined puts the transport in stateless mode: it issues no session ID and performs no session validation. Instead of a session map, you build a fresh McpServer and transport per request and tear them down when the response ends, so nothing is retained between requests. That is the entire diff.

- const transport = new StreamableHTTPServerTransport({
-   sessionIdGenerator: () => randomUUID(),   // issues Mcp-Session-Id, needs a session map
-   onsessioninitialized: (id) => transports.set(id, transport),
- });
+ const transport = new StreamableHTTPServerTransport({
+   sessionIdGenerator: undefined,            // stateless: no session id, no validation
+   enableJsonResponse: true,
+ });
// http-stateless.ts: the "after". Safe to run as N identical replicas.
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { buildServer } from "./server-core.js"; // same tools + resources as before

async function handleMcp(req, res, body) {
  const server = buildServer();
  const transport = new StreamableHTTPServerTransport({
    sessionIdGenerator: undefined, // <- the whole migration
    enableJsonResponse: true,
  });

  // Nothing survives the request. Close both when the client disconnects.
  res.on("close", () => { transport.close(); server.close(); });

  await server.connect(transport);
  await transport.handleRequest(req, res, body);
}

Why statelessness lets you drop sticky sessions

The difference is observable on the wire. The stateful server returns an Mcp-Session-Id on initialize and rejects any later call that does not carry it, which is precisely the request a round-robin load balancer would drop onto a different replica:

# STATEFUL: initialize mints a session id you must echo on every later request
$ curl -sD- http://localhost:3000/mcp \
    -H 'content-type: application/json' -H 'accept: application/json, text/event-stream' \
    -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{
         "protocolVersion":"2025-06-18","capabilities":{},
         "clientInfo":{"name":"probe","version":"1"}}}'
HTTP/1.1 200 OK
mcp-session-id: 19746205-910f-4396-b456-0aff6818b7c7   # <- every later request must carry this

# tools/list WITHOUT that session id (i.e. routed to another replica) is refused
$ curl -s http://localhost:3000/mcp -H 'content-type: application/json' \
    -H 'accept: application/json, text/event-stream' \
    -d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'
{"jsonrpc":"2.0","id":null,
 "error":{"code":-32000,"message":"No valid session ID for a non-initialize request"}}

The stateless server issues no session ID, and a fresh replica answers tools/list with no prior initialize. Because every request is self-contained, it does not matter which replica the load balancer picks:

# STATELESS: no session id header at all
$ curl -sD- http://localhost:3000/mcp \
    -H 'content-type: application/json' -H 'accept: application/json, text/event-stream' \
    -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{ ...same as above... }}'
HTTP/1.1 200 OK
content-type: application/json
# (no mcp-session-id line)

# a fresh replica answers tools/list with no prior initialize -> round-robin just works
$ curl -s http://localhost:3000/mcp -H 'content-type: application/json' \
    -H 'accept: application/json, text/event-stream' \
    -d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'
{"jsonrpc":"2.0","id":2,"result":{"tools":[
  {"name":"query", ...}, {"name":"list_tables", ...}, {"name":"describe_table", ...}]}}

Run N replicas behind a plain load balancer

Once the server is stateless you can put identical replicas behind an ordinary load balancer. Note what is absent from this nginx config: no ip_hash, no sticky cookie, no session affinity. That absence is the payoff.

# nginx.lb.conf: plain round-robin. No ip_hash, no sticky cookie, no affinity.
events {}
http {
  upstream mcp_backend {
    server mcp1:3000;
    server mcp2:3000;
  }
  server {
    listen 8080;
    location / {
      proxy_pass http://mcp_backend;
      proxy_http_version 1.1;
      proxy_buffering off;         # Streamable HTTP may stream via SSE
      proxy_read_timeout 3600s;
    }
  }
}
# docker-compose.lb.yml: two identical stateless replicas + one nginx.
#   docker compose -f docker-compose.lb.yml up --build
#   # then point an MCP client at http://localhost:8080/mcp
services:
  mcp1:
    build: .
    environment: { PORT: "3000", DATABASE_URL: "${DATABASE_URL}" }
  mcp2:
    build: .
    environment: { PORT: "3000", DATABASE_URL: "${DATABASE_URL}" }
  lb:
    image: nginx:1.27-alpine
    depends_on: [mcp1, mcp2]
    ports: ["8080:8080"]
    volumes:
      - ./nginx.lb.conf:/etc/nginx/nginx.conf:ro

How do you know it actually works?

Because a build-it post is worthless if the code does not run, the migration ships with two transport tests next to the original eleven read-only SQL-guard tests: thirteen cases, all green. The transport tests assert the exact contract shown above: the stateless server issues no Mcp-Session-Id and a fresh instance answers tools/list with no prior handshake, while the stateful server mints a session ID on initialize and rejects the same call without it.

// http-stateless.test.ts (node:test): the two assertions that prove the migration
test("stateless: no session id is issued, and any request is self-contained", async () => {
  const a = await rpc(url, INIT);
  assert.equal(a.sessionId, null);                 // no Mcp-Session-Id
  const list = await rpc(url, TOOLS_LIST);         // no prior initialize, fresh instance
  assert.deepEqual(list.json.result.tools.map(t => t.name).sort(),
    ["describe_table", "list_tables", "query"]);
});

test("stateful: initialize mints a session id, and calls without it are rejected", async () => {
  const init = await rpc(url, INIT);
  assert.ok(init.sessionId);                        // Mcp-Session-Id present
  const orphaned = await rpc(url, TOOLS_LIST);       // routed to the "wrong" replica
  assert.equal(orphaned.status, 400);
  assert.equal(orphaned.json.error.code, -32000);
});

Frequently asked questions

How do I make an MCP server stateless?
Set the Streamable HTTP transport's sessionIdGenerator to undefined and construct a fresh server and transport per request instead of keeping a session map. The transport then issues no Mcp-Session-Id and performs no session validation, so every request is self-contained.
Do I still need sticky sessions with the 2026-07-28 MCP spec?
No. A stateless MCP server keeps no per-session state in process memory, so any replica can serve any request. A plain round-robin load balancer works with no ip_hash, sticky cookie, or session affinity.
Does going stateless change my MCP tools or resources?
No. Statelessness is purely a transport and deployment concern. The code that registers your tools and resources is unchanged; only the boot file that constructs the transport differs between the stdio, stateful HTTP, and stateless HTTP versions.
How does a stateless MCP server handle initialize?
Each request is handled by a fresh transport, so there is no session to carry an initialize across requests. In stateless mode the server answers calls like tools/list without a prior handshake, which is exactly why load balancing across replicas works without affinity.
What do I give up by making an MCP server stateless?
Server-side resumability. Stateless mode drops the SSE event store and any per-session in-memory state, so you cannot replay a stream after a disconnect. If you need resumability, keep sessions or back them with an external event store; otherwise carry all per-request context (including auth tokens) in the request itself.
Which SDK version supports stateless MCP?
The stateless Streamable HTTP transport exists in @modelcontextprotocol/[email protected], the version this tutorial pins and tests against. The 2026-07-28 spec makes stateless the recommended default; pin whichever beta SDK your fleet standardises on.

About the author

Mark

Head of Marketing, MCPOrbit

Mark runs content and devrel at MCPOrbit, where we monitor MCP servers in production. The code in this post was built and tested end to end before publishing.

Share this post

MCPOrbit

Test an MCP server in 60 seconds.

Download MCPOrbit for free. No signup, no telemetry. Hear about a server and test it before the curiosity wears off.

macOS 14+ · Apple Silicon & Intel · No account needed