By Shilpa Mareddy
Two copies of an MCP (Model Context Protocol) server (a server that AI applications call tools on) sit behind a round-robin load balancer (it sends each request to the next server in turn). The client's first request lands on copy A, the second on copy B, and B answers 400 Bad Request: Server not initialized. No tool was ever called.
In the demo below, that failure comes from an MCP session held in one process's memory. A session is state the server keeps for one client connection.
The 2026-07-28 revision of the MCP spec removes protocol-level sessions from its HTTP transport.
This article reproduces the failure, then serves a small basket API (create a basket, add items, read it) from NestJS with no session. It then checks, with two real processes, that either one can answer these requests.
The short version, in plain words. Picture a coat check with two desks. You hand your coat to desk A and get a ticket. If you walk to desk B, they cannot honor your ticket, because only desk A has the record. That is roughly the failure above: the session lived in one server's memory, and the second server had no matching session. The fix is a claim number that any desk can look up in one shared back room. In the demo, the "back room" is a shared database, and the "claim number" is a basket ID the client sends with every call. In this demo, either copy of the server can then serve any of its three tools. The spec removes the protocol's own ticket (the session ID); the basket ID is a new one that the tool design creates, and it is a lookup key, not proof of who you are.
What you will learn
- What the 2026-07-28 spec removed from MCP over HTTP, and what replaced it.
- How one NestJS route serves MCP with no session.
- How to test "any instance can answer any request" with two real processes.
What you need: Node 22.13 or newer (the minimum the repo needs; tested only on 22.22.2 and 24.21.0), npm, and a working knowledge of HTTP and load balancers. You do not need to know MCP. Code: shilpamareddy06-ops/ai-backend-patterns (folder
01-stateless-mcp-nestjs).Versions used: MCP spec 2026-07-28;
@modelcontextprotocol/server2.2.0,@modelcontextprotocol/client2.2.0,@modelcontextprotocol/node2.1.0; NestJS 12.1.1 (12.1.2 came out on 2026-09-30 and was not tested); zod 4.6.5; TypeScript 7.0.2.
The failure: an in-memory session pins a client to one instance
Under the 2025 protocol, a client began with a handshake, the setup exchange before the first tool call: initialize, then notifications/initialized. A server could assign a session at initialize by returning an Mcp-Session-Id header, and a client that received one had to send it on every later request. The repo's baseline server does this. Key excerpt:
src/baseline/sessionful-main.ts (lines 22-23)
const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID() });
await server.connect(transport);
The transport creates the session id and keeps it in this process's memory. The basket data sits in a shared store, so the only thing tying a client to one instance is the session.
Two baseline processes run behind a small round-robin proxy written for the tests. It has no sticky routing (also called session affinity: load balancer configuration that sends every request from one client to the same instance).
test/round-robin-proxy.ts (lines 15-24)
/**
* Plain round-robin reverse proxy: request N goes to backend N mod count. No affinity, no cookies,
* no header inspection for routing. It records every exchange so tests can assert on the wire.
*/
export async function startRoundRobinProxy(backendPorts: number[]) {
const exchanges: Exchange[] = [];
let next = 0;
const server: Server = createServer((req, res) => {
const backend = next++ % backendPorts.length;
The default v2 client connects through the proxy. Output captured from test/baseline.test.ts, as recorded in the repo README:
README.md (lines 93-95)
0 POST initialize -> 200 req=- res=0fddeeb6-e410-4729-b61c-dd701a31548d
1 POST notifications/initialized -> 400 req=0fddeeb6-e410-4729-b61c-dd701a31548d res=-
CLIENT ERROR: SdkHttpError: Error POSTing to endpoint: {"jsonrpc":"2.0","error":{"code":-32000,"message":"Bad Request: Server not initialized"},"id":null}
Columns: backend index, HTTP method, JSON-RPC method (JSON-RPC is the message format MCP uses: a method name, parameters, an id), status, session id sent, session id returned. Instance 0 created the session at initialize. The next request carried that id to instance 1, which answered HTTP 400, code -32000, "Bad Request: Server not initialized". The message comes from the SDK transport. The client never reaches a tool call. A control test shows the baseline working against one instance directly, so the proxy routing is what breaks it.
Illustrative, simplified diagram (the real test makes four calls and starts with server/discover, which is not drawn). One baseline design, one proxy: the data was shared, the session was not.
This is one design, not all sessionful servers. The baseline keeps one transport per process, so it holds exactly one session. B's error shows that B has no initialized transport, not that B checked the session id. A server with a session map would fail differently, for example with an unknown-session error. Session maps, sticky routing and shared session stores were not tested, and this article makes no claim about them.
What the 2026-07-28 spec changed
The spec was released on 2026-07-28. The idea: every request stands alone and carries what the server needs, so any instance can answer it. In plain terms, the server stops remembering who you are between calls, and the list below shows how. Readers who only want the idea can skim the codes and proposal numbers.
-
No sessions. The changelog removes protocol-level sessions and the
Mcp-Session-Idheader from the Streamable HTTP transport (MCP's transport over HTTP). List results no longer vary per connection. This comes from SEP-2567 (a SEP is a numbered design proposal for the spec). -
No handshake.
initializeandnotifications/initializedare gone (SEP-2575). The spec: "There is no negotiation handshake. Every request carries its protocol version, and the server accepts or rejects each request independently." The version and client capabilities travel in each request's_metafield, a place in the parameters for protocol metadata. - One endpoint. A single POST endpoint; every JSON-RPC message is its own POST. The GET endpoint is removed.
-
Routable headers. Every POST that carries a request has the headers
MCP-Protocol-VersionandMcp-Method. Calls totools/call,resources/readandprompts/getalso haveMcp-Name. The stated purpose is that intermediaries can route without parsing the body. Servers must check them against the body and answer HTTP 400 with-32020on a mismatch.
State that must survive between calls does not disappear. SEP-2567 says it should use explicit handles: IDs the server creates and the client passes back as ordinary tool arguments. Its example is create_basket() returning a basket_id, then add_item(basket_id, ...). It calls this a tool-design pattern with no wire format, "not a protocol change". The demo follows that example.
Serve MCP from one NestJS route, with no session
In plain words: one route accepts every MCP request and builds a fresh, empty server for each one. Nothing is remembered inside the server, and anything that must be remembered goes to the database. This is the whole controller (src/mcp/mcp.controller.ts):
src/mcp/mcp.controller.ts (lines 1-31)
import { All, Controller, Inject, Req, Res } from '@nestjs/common';
import { hostHeaderValidation, originValidation, toNodeHandler } from '@modelcontextprotocol/node';
import { createMcpHandler } from '@modelcontextprotocol/server';
import type { Request, Response } from 'express';
import { BASKET_STORE, type BasketStore } from '../basket/basket.store.js';
import { CONFIG, type AppConfig } from '../config.js';
import { buildBasketServer } from './basket-server.js';
@Controller('mcp')
export class McpController {
private readonly handle: ReturnType<typeof toNodeHandler>;
private readonly validateHost: ReturnType<typeof hostHeaderValidation>;
private readonly validateOrigin: ReturnType<typeof originValidation>;
constructor(@Inject(BASKET_STORE) store: BasketStore, @Inject(CONFIG) config: AppConfig) {
// DNS-rebinding protection. Defaults are localhost only; behind a load balancer set ALLOWED_HOSTS.
this.validateHost = hostHeaderValidation(config.allowedHosts);
this.validateOrigin = originValidation(config.allowedOrigins);
// 'reject' = 2026-07-28 only. Drop it to also serve 2025-era clients through the SDK's stateless fallback.
const mcp = createMcpHandler(() => buildBasketServer(store), { legacy: 'reject' });
this.handle = toNodeHandler(mcp);
}
// One route for every method: the SDK decides what is valid (POST only, no GET stream, no DELETE session).
@All()
async mcp(@Req() req: Request, @Res() res: Response): Promise<void> {
// Guards answer 403 themselves and return false.
if (!this.validateHost(req, res) || !this.validateOrigin(req, res)) return;
await this.handle(req, res);
}
}
createMcpHandler takes a factory. Per the SDK docs it builds a fresh server for each request, and there is no Mcp-Session-Id. toNodeHandler from @modelcontextprotocol/node mounts it on Nest's request and response objects. legacy: 'reject' accepts only 2026-07-28 traffic (more below). src/main.ts turns Nest's body parser off so the SDK reads the raw body; Nest's default parser was not tested.
The Host and Origin checks are DNS-rebinding protection (a web page in your browser tricking it into reaching a server on your own machine), not authentication. Defaults accept localhost only; behind a real load balancer set HOST, ALLOWED_HOSTS and ALLOWED_ORIGINS (see the README), or other hosts get HTTP 403.
The factory is buildBasketServer (excerpt; get_basket, lines 52-59, follows the same pattern):
src/mcp/basket-server.ts (lines 19-50)
/**
* Builds a fresh McpServer. createMcpHandler calls this once per HTTP request,
* so nothing here may hold state: everything lives in the injected store,
* and the client carries the basket_id from call to call.
*/
export function buildBasketServer(store: BasketStore): McpServer {
const server = new McpServer({ name: 'stateless-basket', version: '1.0.0' });
server.registerTool(
'create_basket',
{ description: 'Create an empty basket. Returns a basket_id to pass to the other tools.' },
async () => {
const basket = await store.create();
return text(JSON.stringify({ basket_id: basket.id }));
},
);
server.registerTool(
'add_item',
{
description: 'Add qty of an item to a basket.',
inputSchema: z.object({
basket_id: basketId,
name: itemName,
qty: z.number().int().min(1).max(1000),
}),
},
async ({ basket_id, name, qty }) => {
const basket = await store.addItem(basket_id, name, qty);
return basket ? text(JSON.stringify(basket)) : unknownBasket(basket_id);
},
);
Nothing in the factory holds state. The client carries basket_id from call to call, and the state lives behind the BasketStore interface:
src/basket/basket.store.ts (lines 10-15)
export interface BasketStore {
create(): Promise<Basket>;
/** Adds qty to the item (creating it if needed). Resolves undefined if the basket does not exist. */
addItem(basketId: string, name: string, qty: number): Promise<Basket | undefined>;
get(basketId: string): Promise<Basket | undefined>;
}
The spec removes the session, not the need for storage. In its discussion of gateways, SEP-2567 says that with handles "any replica can serve it from shared storage". Here that storage is one SQLite file both processes open, which suits a demo, not production. (The spec removed sessions; the SDK is more nuanced, as a later section explains.)
A MongoDB store: an untested design sketch (not built or run). Each basket would be one document holding ownerId, items, and an expiresAt date. A TTL index (a MongoDB index that deletes documents after a date) on expiresAt lets MongoDB delete abandoned baskets on its own. MongoDB's docs say the TTL background task runs every 60 seconds and that expired documents can linger beyond that, depending on server load, so reads should also treat a basket past expiresAt as missing. expiresAt would be pushed forward on every write, so a basket in active use does not expire. For quantity changes the sketch uses an atomic update (an $inc on the item's quantity, with items stored as a map keyed by item name) rather than read-modify-write, so two replicas adding items at once cannot lose an update, which is the same property the 20-concurrent-calls test checks here for SQLite.
To tie the handle to a user, never trust the handle alone. Every call would look up the basket by basketId and the authenticated user's id together, and return the same "not found" result whether the basket does not exist or belongs to someone else, so a leaked or guessed handle reveals nothing. A handle only identifies state; it is not a credential, so authorization still has to happen on each call.
What the tests check: two real processes behind one plain proxy
In plain words: the test starts two copies of the server, sends requests to them in turn, and checks that both copies see the same basket. The stateless test starts two separate OS processes running the compiled Nest app, behind the same round-robin proxy, and uses the official v2 client. That client defaults to the 2025 handshake, which this server answers with HTTP 400 and -32022 "Unsupported protocol version: 2025-11-25". So each test client pins the new protocol:
test/stateless.test.ts (lines 34-43)
/** A fresh client per test, so no test depends on another one having run. */
async function connect(): Promise<Client> {
const client = new Client(
{ name: 'stateless-test', version: '0.0.0' },
// The v2 client defaults to the 2025 handshake; this pins the 2026-07-28 stateless protocol.
{ versionNegotiation: { mode: { pin: '2026-07-28' } } },
);
await client.connect(new StreamableHTTPClientTransport(new URL(proxy.url)));
return client;
}
The wire-level test checks every exchange the proxy recorded:
test/stateless.test.ts (lines 77-100)
it('never uses a session: no initialize, no Mcp-Session-Id in either direction', async () => {
const mark = proxy.exchanges.length;
const client = await connect(); // includes the connect-time exchange(s)
await createBasket(client);
await client.close();
const exchanges = proxy.exchanges.slice(mark);
assert.ok(exchanges.length >= 2);
for (const e of exchanges) {
assert.ok(!('mcp-session-id' in e.requestHeaders), 'request carried a session id');
assert.ok(!('mcp-session-id' in e.responseHeaders), 'response carried a session id');
assert.notEqual(e.rpcMethod, 'initialize');
assert.notEqual(e.rpcMethod, 'notifications/initialized');
assert.equal(e.httpMethod, 'POST');
assert.equal(e.requestHeaders['mcp-protocol-version'], '2026-07-28');
}
// The first request is server/discover, the rest are self-describing calls.
assert.equal(exchanges[0].rpcMethod, 'server/discover');
// Routing headers are present on tool calls (Mcp-Method / Mcp-Name).
const call = exchanges.find((e) => e.rpcMethod === 'tools/call');
assert.ok(call, 'expected a tools/call exchange');
assert.equal(call.requestHeaders['mcp-method'], 'tools/call');
assert.equal(call.requestHeaders['mcp-name'], 'create_basket');
});
What the tests check:
- No request or response carries
Mcp-Session-Id, andinitializeis never sent. - Every request carries
MCP-Protocol-Versionset to2026-07-28, and the first request isserver/discover(servers must implement it; clients may skip it). - Four consecutive calls (create, add, add, read) land on alternating instances and see the same basket.
- 20 concurrent
add_itemcalls (quantity 1 each) sent through the round-robin proxy sum to exactly 20. This exercises the SQLite store, not the protocol.
npm test runs 11 tests (7 stateless, 2 baseline, 2 for Host/Origin configuration). In the build run all passed on Node v22.22.2, Linux. When an independent check pointed the two instances at different database files, 3 of the 11 tests failed (the cross-instance, input-validation and concurrency tests), because the second instance did not know the basket. I have not run that check myself. It shows that the tests detect unshared state.
By hand (not covered by npm test), the repo notes also record that a request with no per-request metadata gets HTTP 400, with -32022 when the version header is missing as well and -32602 when only _meta is missing. That is SDK behavior as observed; the spec's validation rules suggest -32020 for a missing required header, so check the spec before relying on it.
I also ran it myself on a MacBook (macOS) with Node v24.21.0: I ran npm ci and then npm test. All 11 tests passed (3 suites, 0 failures) in about 3.2 seconds. The "Bad Request: Server not initialized" line printed in the output is the baseline demo failing on purpose, not a test failure. I then cloned the public repository fresh from GitHub into an empty folder and ran npm ci (128 packages, 0 vulnerabilities) and npm test again, with the same result: 11 of 11 passed.
"No sessions" is true of the spec, not of every SDK code path
The v2 TypeScript SDK still contains a sessionful path, and the baseline uses it. The SDK docs describe multi-node use with a shared event store (for resuming dropped streams) or per-node sessions that must be routed by session id. Also, createMcpHandler defaults to legacy: 'stateless', which serves both 2025-era and 2026-07-28 clients. The demo sets legacy: 'reject', which refuses 2025-era traffic; the default fallback was not exercised. The SDK migration guide describes this default; the demo does not use it.
SEP-2567 describes a "clean break" with no deprecation window, while the SDK ships a compatibility shim. The SEP's own text is stale in places, so use the spec pages for normative claims.
Related changes, not covered here. Other features were deprecated or reclassified in this revision (HTTP+SSE was deprecated earlier, in 2025), with different removal rules, not one shared window. Roots, Sampling, Logging and Dynamic Client Registration have an earliest removal in the first revision released on or after 2027-07-28; actual removal is a Core Maintainer decision that may come later. The old HTTP+SSE transport has its own rule: earliest removal is three months after SEP-2596 reaches Final. SEP-2596 is marked Final, but its page gives no date, so no calendar date is given here. Sessions were removed outright. See the deprecated features registry in Sources.
What this demo does not prove
- A real load balancer, or other sessionful designs. The proxy is a 65-line Node script written for the tests. No nginx or Kubernetes was run; Host and Origin configuration was tested with simulated headers only. Session maps, sticky routing and shared session stores were not tested.
-
Header-based routing. The proxy ignores the new routing headers. The tests check that they are sent, not that a load balancer can use them; the
-32020rejection is not tested. -
Multi round-trip requests. The spec's replacement for a server asking the client something mid-call: the server returns an input-required result and the client retries with the answer. Servers must use it for
roots/list,sampling/createMessageandelicitation/create, a breaking change. Not implemented here. -
Auth. There is none: anyone holding a
basket_idcan use it, and handles never expire. SEP-2567's non-normative guidance is to validate the handle together with the auth context on every call. - Streaming. SSE (server-sent events, streaming over one HTTP response) is untested. The spec says a broken response stream loses the in-flight request, with no resume.
-
Other features and clients. Resources, prompts, tasks,
subscriptions/listenand the 2025-client fallback. Only the official v2 TypeScript client was used. -
Production storage.
SqliteBasketStoreusesnode:sqlite, which is experimental in Node 22 and prints anExperimentalWarningat startup. One SQLite file only works for processes on one machine. The store is demo-only; a real deployment would use a shared database such as MongoDB or Redis. Nothing caps baskets or items. -
Performance and environments. No benchmarks. The full build run was Linux with Node v22.22.2; there was also one
npm testrun on macOS with Node v24.21.0.
When you should not do this
- Sticky routing already meets your needs. If affinity works and its costs are acceptable, moving state into a store is extra work. Sticky routing was not tested here, so this article cannot compare them.
- Your clients still speak the 2025 protocol. The spec's compatibility table says a legacy client against a modern-only server fails. A dual-era server can serve both on one endpoint (the SDK default); not tested here.
- Your tools need to ask the client something mid-call. Server-initiated requests must move to the multi round-trip mechanism.
- You run one instance. The baseline's control test worked against a single instance. The failure here needs requests to reach different processes.
Takeaways: keep MCP server state out of memory
- If a server keeps per-client state in its own memory, it can break when a load balancer sends requests to different copies, as the baseline here did. Sticky routing is another fix this article did not test.
- Keep that state in a shared store and give the client an ID to send back with each call, as the basket ID does here.
- Treat the ID as a lookup key, not proof of identity. If your server has authentication, check who is calling on every request. The demo has none, so anyone holding a basket ID can use it.
- Test with two real server processes behind a plain round-robin proxy, not with one process.
- If the server accepts only 2026-07-28 traffic, as this demo's does, make the v2 TypeScript client pin that version (or use its
'auto'mode), because the client defaults to the 2025 handshake.
Sources
Spec and proposals:
- MCP spec 2026-07-28 changelog
- Streamable HTTP transport
- Versioning
- server/discover
- Multi round-trip requests
- Deprecated features registry
- SEP-2567: Sessionless MCP
- SEP-2575: Make MCP Stateless
- SEP-2596: Feature lifecycle and deprecation
- MCP spec 2025-11-25, transports
SDK and release notes:
- TypeScript SDK v2: supporting protocol revision 2026-07-28
- TypeScript SDK v2: serving over HTTP
- TypeScript SDK v2: sessions, state and scaling
- MCP blog: 2026-07-28 release
- MCP blog: SDK betas
- MongoDB manual: TTL indexes (checked 2026-10-01: 60-second background task, deletion not immediate).
- npm registry, checked 2026-09-28:
@modelcontextprotocol/server2.2.0,client2.2.0,node2.1.0.
Code: shilpamareddy06-ops/ai-backend-patterns, folder 01-stateless-mcp-nestjs (README.md, NOTES.md, src/, test/).
Written with AI assistance. I ran the code and tests myself and take responsibility for this article.

Top comments (0)