MCP Went Stateless. Your Server Still Holds Sessions.
The 2026-07-28 MCP spec dropped the initialize handshake and the session id, which means any request can now land on any replica behind a plain load balancer. But only if your server stopped holding session state. Here is how to make an MCP server truly stateless, migrate held-open elicitation to Multi Round-Trip Requests, and route on the new headers.
The 2026-07-28 MCP spec did something quieter than a model launch and more consequential for anyone running a server in production: it deleted the session. No more initialize/initialized handshake, no more Mcp-Session-Id header. Every request now carries its own protocol version and client identity inline, and the spec is explicit that "any request can now land on any server instance behind a plain round-robin load balancer without needing shared storage."
That last sentence is the whole point, and it is also a trap. The protocol became stateless. Your server probably did not. If you built anything against the old spec, you almost certainly have an in-memory session map, capabilities you stashed at handshake time, and at least one feature that holds a connection open to ask the user something. The protocol will happily route requests round-robin across your replicas now. Your server will just return the wrong thing, or nothing, when it does.
This post is about closing that gap: what stateless actually requires of your code, how to move held-open elicitation to the new Multi Round-Trip Request pattern without losing the ability to ask the user for input, and how to use the new headers so your gateway can do its job.
The business problem: a stateful server cannot autoscale
Under the old spec, an MCP session was a living thing. The client called initialize, you handed back a session id, and you kept a bag of state keyed by that id: negotiated capabilities, the client's protocol version, in-flight elicitation prompts waiting on an answer. Every subsequent request had to come back to the same process that held that bag.
That single fact poisons horizontal scaling. To run more than one replica you need sticky routing so a session always returns to the instance that owns it, which means your load balancer has to understand MCP sessions instead of just spreading load. When a pod restarts during a deploy, every session it held dies mid-call, and the client sees a tool vanish halfway through. To avoid pinning, teams reach for a shared session store in Redis, which turns every request into a network round trip to fetch and rewrite session state, and now Redis is a availability dependency for a protocol that is mostly stateless request/response anyway.
The cost shows up as overprovisioning. You cannot scale to zero because a cold replica has no sessions. You cannot drain a node cleanly because draining means dropping live sessions. You keep headroom you do not need because scaling up does not help requests pinned to a hot instance. None of this is exotic; it is the ordinary tax of stateful services, and the new spec exists specifically to let you stop paying it.
Stateless MCP lets you treat the server like any other stateless HTTP service: a plain load balancer, N identical replicas, scale on CPU, drain in seconds, scale to zero when idle. But you only get that if you actually remove the state.
Step one: stop trusting the handshake
Here is the pattern that has to go. It looks harmless and it is the thing pinning you to one instance.
// DON'T: session state that lives in one process's memory.
// Every follow-up request must return to THIS replica or it breaks.
const sessions = new Map<string, SessionState>();
function handleInitialize(req: InitializeRequest): InitializeResult {
const sessionId = crypto.randomUUID();
sessions.set(sessionId, {
protocolVersion: req.params.protocolVersion,
clientCapabilities: req.params.capabilities,
clientInfo: req.params.clientInfo,
});
return { sessionId, capabilities: SERVER_CAPS };
}
function handleToolCall(sessionId: string, req: ToolCallRequest) {
const session = sessions.get(sessionId); // undefined on any other replica
if (!session) throw new Error("unknown session");
// ...uses session.protocolVersion, session.clientCapabilities
}
The fix is to read what you need from the request itself, because the spec now puts it there. Protocol version comes in on the MCP-Protocol-Version header, and client identity rides in _meta under a namespaced key. There is nothing to look up and nothing to store.
// DO: derive per-request context from the request. No map, no lookup.
import type { IncomingHttpHeaders } from "node:http";
interface RequestContext {
protocolVersion: string;
clientName: string;
clientVersion: string;
}
function contextFromRequest(
headers: IncomingHttpHeaders,
body: { _meta?: Record<string, unknown> },
): RequestContext {
const protocolVersion = String(headers["mcp-protocol-version"] ?? "");
// Client identity is namespaced in _meta, not negotiated at handshake time.
const clientInfo =
(body._meta?.["io.modelcontextprotocol/clientInfo"] as
| { name?: string; version?: string }
| undefined) ?? {};
if (!protocolVersion) {
// Fail loud: a request with no version is a client that has not migrated.
throw new Error("missing MCP-Protocol-Version header");
}
return {
protocolVersion,
clientName: clientInfo.name ?? "unknown",
clientVersion: clientInfo.version ?? "0",
};
}
If a client genuinely needs to know your capabilities before it calls anything, the spec gives you an optional server/discover RPC for that. It is a normal request that returns your capabilities. It does not open a session, and you do not have to remember that it happened. Treat capability discovery as a cache-warming read, not a stateful handshake.
Step two: move elicitation to Multi Round-Trip Requests
This is the part that actually takes thought. Under the old model, when a tool needed to ask the user something mid-execution, the server initiated an elicitation/create back to the client and held the stream open while it waited. That is a stateful, long-lived connection by definition, and it is exactly what a stateless core cannot support.
Multi Round-Trip Requests (SEP-2322) replace it. The shape is: when your tool needs input it cannot proceed without, you return a normal result with resultType: "input_required", include the questions you need answered, and hand back an opaque token that captures where you were. The client collects the answers and calls the same tool again, this time with inputResponses attached. The critical design rule is that the pending state goes in the token, not in your process, so the retry can land on any replica.
The clean way to build this is to sign the pending state into the token so any instance can verify and resume it. No shared store, no memory.
import crypto from "node:crypto";
const SECRET = process.env.MRTR_SECRET!; // rotate like any signing key
// Encode the paused work into a signed token the client echoes back.
// Any replica can verify and resume it because the state travels in the token.
function sealState(state: object): string {
const payload = Buffer.from(JSON.stringify(state)).toString("base64url");
const sig = crypto.createHmac("sha256", SECRET).update(payload).digest("base64url");
return `${payload}.${sig}`;
}
function openState<T>(token: string): T {
const [payload, sig] = token.split(".");
const expected = crypto.createHmac("sha256", SECRET).update(payload).digest("base64url");
// timingSafeEqual guards against forged resume tokens.
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
throw new Error("invalid MRTR token");
}
return JSON.parse(Buffer.from(payload, "base64url").toString());
}
// A booking tool that needs the user to confirm a date it could not infer.
function bookMeeting(args: { title: string }, inputResponses?: Record<string, string>, token?: string) {
// First call: we have a title but no confirmed date. Ask, then stop.
if (!inputResponses) {
return {
resultType: "input_required",
requests: [{ name: "date", prompt: `What date should "${args.title}" be booked for?` }],
requestState: sealState({ title: args.title, step: "await_date" }),
};
}
// Retry call: verify the sealed state and continue from where we paused.
const state = openState<{ title: string; step: string }>(token!);
const date = inputResponses.date;
return {
resultType: "complete",
content: [{ type: "text", text: `Booked "${state.title}" for ${date}.` }],
};
}
Two things to get right here. First, do not put anything secret or large in the token; it round-trips through the client, so it is visible to the client and it costs you on every request. Keep it to the minimum needed to resume, and reference heavy data by id. Second, because the client retries the same tool call, the retried call has to be safe to run again. That is the same idempotency discipline you should already have on side-effecting tools, and it matters more now that a retry is a first-class part of the protocol. If bookMeeting actually writes to a calendar, that write needs an idempotency key so a client that retries after a network blip does not double-book. I wrote up that pattern separately in idempotency keys for retry-safe tool side effects, and MRTR makes it non-optional.
It is worth saying what MRTR is not. It is not a general approval workflow. If your tool needs a human to sign off on a risky action rather than supply a missing parameter, that is a different concern with different auditing needs, closer to human-in-the-loop approval gates. MRTR is the transport for "I need one more input to finish this call." Do not overload it into a full workflow engine.
Step three: let the gateway route on headers
The old world made your edge layer blind. To know whether a request was a cheap tools/list or an expensive tools/call to a specific tool, a gateway had to buffer and parse the JSON body. That is slow, it is fragile, and it means your rate limiting and billing logic lived deep in the application instead of at the edge where it belongs.
The new spec requires two headers on every streamable HTTP request: Mcp-Method carries the method, like tools/call, and Mcp-Name carries the specific tool or resource name (SEP-2243). Now the edge can route and meter on cheap header reads.
# Per-tool rate limiting at the edge, no body parsing.
# Meter the expensive write tool harder than read-only listing.
map $http_mcp_name $mcp_limit_key {
default $binary_remote_addr;
"book_meeting" "write:$binary_remote_addr"; # tighter bucket for writes
}
limit_req_zone $mcp_limit_key zone=mcp_tools:10m rate=5r/s;
location /mcp {
limit_req zone=mcp_tools burst=10 nodelay;
proxy_pass http://mcp_backend; # plain round-robin, no sticky sessions
}
The same headers let you meter usage for billing and emit clean per-tool metrics without cracking open payloads, which pairs well with the tracing approach in MCP code execution and agent token costs. And because tools/list, prompts/list, and resources/list now return ttlMs and cacheScope, you can cache those responses at the edge or in the client and stop re-fetching a tool catalog that changes maybe once a deploy. Set cacheScope honestly: a catalog that varies by authenticated user is not shared, and getting that wrong leaks one tenant's tools to another.
Tradeoffs and the places this bites
Sticky routing did not fully disappear, it moved. The protocol no longer needs affinity, but your infrastructure might still benefit from it for cache locality, for instance keeping a warm connection to a downstream database. The difference is that affinity is now an optimization you can lose without breaking correctness, not a requirement. Design so a cold replica serves any request correctly, then add locality for speed if you measure a reason to.
Token bloat is a real cost. Every MRTR round trip carries your sealed state back and forth, and if you stuff it with context it inflates every paused call. Keep resume state small and reference large objects by id from your own store. The token is a claim check, not a suitcase.
Signing keys are now on the hot path. The instant your resume state lives in a client-held token, that token is a forgery target. Sign it, verify in constant time, and rotate the key. A stateless design that trusts an unsigned token is worse than the stateful design it replaced, because now anyone can hand you a "resume" and you will run it.
Migration is not all or nothing. Most SDKs accept the new protocol version alongside the old one during a transition. Advertise support for the new version, read context from the request when it is present, and keep the handshake path working until your clients move. The one thing you cannot half-migrate is elicitation: a held-open stream and MRTR are different flows, so pick per tool and cut over cleanly.
The takeaway
The 2026-07-28 spec did not make your server scalable. It made it possible for your server to be scalable, and handed the rest of the work to you. The work is mostly subtraction: delete the session map, read protocol version and client info from each request, and stop holding connections open. The one addition worth doing carefully is Multi Round-Trip Requests, because that is where a naive port either loses the ability to ask the user for input or reintroduces exactly the server-side state you were trying to shed. Put the pending state in a signed token, keep it small, make the retried call idempotent, and you get an MCP server that runs behind a plain load balancer, drains in seconds, and scales to zero when nobody is calling it.
If you are staring at a stateful MCP server and a spec that says it should be stateless, and you are not sure which of your tools quietly depend on a held-open connection, book a consultation call and we can map the migration before a round-robin load balancer starts routing requests to a replica that does not know what they mean.
Viral Ruparel
Generative AI consultant helping teams ship reliable LLM and agent systems in production.
Contact Viral about your AI project →Frequently Asked Questions
What changed in the 2026-07-28 MCP specification?+
The protocol core became stateless. The initialize/initialized handshake and the Mcp-Session-Id header are gone. Each request now carries its own protocol version and client identity inline in _meta, so any request can land on any server instance behind a plain round-robin load balancer with no shared session storage. Server-initiated calls that used to require a held-open stream, elicitation and sampling among them, are replaced by Multi Round-Trip Requests. Streamable HTTP requests must now send Mcp-Method and Mcp-Name headers, and list results can carry ttlMs and cacheScope so clients can cache them.
Does stateless MCP mean my server cannot ask the user for input mid-call?+
It can, it just cannot do it by holding a connection open. Instead of a server-initiated elicitation over a live stream, the server returns a result with resultType "input_required" plus the questions it needs answered and an opaque token that captures where it was. The client collects the answers and calls the same tool again with inputResponses attached. The important part is that the pending state lives in the token, not in server memory, so the retry can land on a different replica and still resume correctly.
Do I have to rewrite my existing MCP server?+
If you want it to scale horizontally, yes, but the rewrite is mostly deletion. You remove the in-memory session map, stop relying on the initialize handshake to stash capabilities, and read protocol version and client info from each request instead. The larger change is any feature that depended on a held-open stream, which you move to the Multi Round-Trip Request pattern. Servers with no elicitation or sampling are often a few hours of work.
What are the Mcp-Method and Mcp-Name headers for?+
They let a gateway, rate limiter, or WAF route and meter traffic without parsing the JSON body. Mcp-Method carries the method such as tools/call, and Mcp-Name carries the specific tool or resource name. Because they sit in the HTTP headers, your edge layer can apply per-tool rate limits, billing, and routing rules cheaply, which was awkward when the only way to know what a request did was to read and re-serialize its body.
Related Articles
Your Model Advertises 1M Tokens. It Starts Forgetting Around 600K.
A million-token context window landed in half the models this week, and the reflex is to stop retrieving and just paste everything in. The window is real. The quality across all of it is not. Here is how to measure your model's actual effective context, then spend the window with a token budget instead of filling it and paying linearly for output that quietly gets worse.
Content Filters Watch What Your Agent Says. Nothing Watches What It Does.
Your output guardrails inspect the words an agent produces. They say nothing about the refund it just issued, the deploy it just triggered, or the email it just sent. Agent guardrails became their own discipline this year for a reason. Here is the action-layer gate that decides allow, deny, or escalate on every tool call, evaluated as policy-as-code before the call runs, not logged after it already happened.
A New Model Dropped This Week. Don't Just Bump the String.
Four frontier models shipped in the last 72 hours and your instinct is to change one env var and redeploy. That one-line model swap is the riskiest change you will make this quarter, because tool-calling, output format, cost, and the prompt you tuned all shift at once and nothing throws. Here is how to treat a model upgrade like the dependency change it actually is: pin it behind an adapter, gate it on an eval run against real traffic, and shadow it before you flip.