MCP went stateless. Here's how to migrate a session-era server.

At ZeroClick I ran the MCP server that exposed our tools to AI agents, and I built it the way the session-era spec expected: around a protocol session, the server-side context a client sets up once and refers back to on every later request. That context lived in one process’s memory, and keeping requests routed to that process became its own infrastructure job.
The load balancer had to keep sending each client back to the one process holding its session. When that pinning slipped, clients saw session-not-found errors. When the balancer cut our open streams at its default timeout, they saw 502s. None of it had anything to do with what our tools did.
If your server has a map keyed by the Mcp-Session-Idheader and load-balancer settings you tuned to keep clients pinned, you are running the same shape of system. The specifics differ, but the state sits in one process’s memory, and your infrastructure’s job is reuniting every request with that process.
This is a common wall in AI engineering right now. MCP servers stopped being demos and became production infrastructure that agents call at volume, running on the same horizontally scaled web stacks as everything else. A protocol that ties a client to one process pulls against that stack, and every team scaling past a single replica feels the pull.
The MCP spec dated 2026-07-28 removes the cause. The protocol session is gone. The Mcp-Session-Id header is gone, and so is the initialize handshake, the setup call that opened every session. Protocol metadata now rides in _meta on every request. Two new headers, Mcp-Method and Mcp-Name, let a load balancer route requests without reading bodies. Any request can land on any replica.
I wrote about what sessions cost you in production before this spec landed, and the protocol adopted the conclusion. At ZeroClick I went at the problem from both ends. I tuned the pinning by hand, and I also moved state out of the process, into identifiers the client carried and a shared store for questions waiting on answers.
The second approach is the one that survives this migration, and it is the model the new spec assumes. The sections below walk the move in an order that keeps your server deployable at every step, and they cover where the pinned-process machinery bites on the way out.
First, nothing breaks today
Every client in the wild today speaks the 2025 spec versions, and the official SDKs are shipping support for both models. Your session-era server keeps working. The migration pressure is real, but it is measured in months.
Treat this like any other protocol transition: add the new path, keep the old one, and let your traffic tell you when the old one is dead. The day to delete the session code is the day your logs show old-version clients gone. That date is set by your clients, not by the spec.
Inventory what your sessions actually hold
Say you are staring at a server with a session map and wondering how big this job is. Write down, for each thing keyed by session ID, what it is. The size of the migration is exactly the size of that list. In the servers I have built and run, it comes down to three categories.
- Routing state is the open stream a follow-up request needs to find.
- Per-client working state is the account, project, or job a sequence of tool calls operates on.
- In-flight questions are elicitations, the mid-call questions a tool asks a human, still waiting on an answer.
Each category migrates differently, and the first one migrates by deletion.
Routing state migrates by deletion
If your server has a block like this, it exists so a follow-up request can find the process that created its session:
// The bookkeeping the stateless model deletes outright.
const sessions = new Map<
string, // the Mcp-Session-Id header value
{ transport: Transport; lastActiveAt: number }
>();
setInterval(() => {
const cutoff = Date.now() - SESSION_TTL_MS;
for (const [sessionId, session] of sessions) {
if (session.lastActiveAt < cutoff) sessions.delete(sessionId);
}
}, SWEEP_INTERVAL_MS);The map is only half of it. Because the state lives in one process’s memory, the infrastructure has to keep sending every request from a client back to that same process. Load balancers call this sticky sessions, and they enforce it with an affinity cookie, a cookie whose only purpose is to mark which backend process a client belongs to.
I tuned that stack by hand at ZeroClick. The cookie’s time-to-live had to mirror the 30-minute lifetime of the session it pointed at, and the pinning only worked because our client forwarded cookies. A cookie-less client would still have broken. The load-balancer bug I spent the most time on was the stream half: it severed our SSE streams, the server-sent-event connections the transport holds open, at its default 30-second backend timeout, and clients saw 502s until I raised it.
All of that existed to reunite a request with a process, and the new model makes the reunion unnecessary. When your stateless path is carrying the traffic, you delete the map, the sweep, and the affinity config, and your balancer goes back to plain round-robin. This is the rare migration where the after state has fewer moving parts than the before state.
Working state moves into handles
The session was an implicit argument to every tool call, and the migration makes it explicit. A tool that used to stash a project in the session now returns a handle, an identifier like a project ID or a job token that names the thing you were tracking. The client keeps it, and the model passes it back on later calls.
// Session era: the project was implicit state the server held.
// Stateless: the tool returns a handle, the client carries it.
async function createProject(args: CreateArgs, auth: AuthContext) {
const project = await projects.create({
...args,
ownerId: auth.userId,
});
return { projectId: project.id };
}
// Later calls take the handle back as an argument.
async function addTask(
{ projectId, task }: AddTaskArgs,
auth: AuthContext,
) {
const project = await projects.find(projectId);
if (!project || project.ownerId !== auth.userId) {
throw new ForbiddenError(); // knowing the handle is not owning it
}
return tasks.create(projectId, task);
}Handles cross a trust boundary, so treat them the way you treat any client-supplied identifier. Validate ownership on every call, because knowing the handle is not owning the resource. Handles escape easily. They show up in logs, in prompts, and in shared agent transcripts.
The flow I shipped worked this way. A status endpoint took the resource ID in the URL, but every call also had to carry a token minted when the resource was authorized, and the server re-checked ownership on each request. The ID alone read nothing.
Design the handle around the resource rather than the conversation. A project ID keeps meaning something next week. A serialized blob of conversation state does not, and it invites clients to depend on its internals.
Elicitation may already be done
Mid-call questions were the strongest reason servers held open streams. The new spec restructures them: the server returns an input-required result, and the client re-issues the call with the answers and the echoed request state attached. Any replica can pick up the resume.
// The return-and-reissue shape, simplified. The tool needs an
// answer, so it returns instead of pushing a question down a stream.
if (!args.answers?.region) {
return {
status: 'input-required',
questions: [
{ name: 'region', message: 'Deploy to which region?' },
],
state: { deploymentId }, // echoed back to you on the re-issue
};
}
// The client re-issues the original call with the answers and the
// echoed state attached. Any replica can serve the resume, because
// the request carries everything the resume needs.
return deploy(args.answers.region, args.state.deploymentId);Maybe you built elicitation as a coroutine suspended over an external store, the pattern I described in the elicitation article: the tool saves its place in a database and picks back up when the answer arrives. If so, the protocol just standardized your architecture. Your migration is mostly moving the store key from session ID to request state.
If your elicitation depends on the open stream itself, prototype that part first, because it changes the shape of your tool code rather than just its plumbing. My own server sat in both camps: the suspension lived in a shared store that survived replica restarts, but delivery still pushed the question over the open stream. The store is the half that survives this migration.
The order I would do it in
- Update the SDK and get the new headers and
_metahandling in place while the session path still carries traffic. - Convert working state to handles one tool family at a time, starting with the tools that already return a natural resource ID.
- Restructure elicitation to the return-and-reissue shape.
- Watch traffic until old-version clients are gone, then delete the session map, the affinity configuration, and the sweeps that evict idle sessions.
Every step leaves the server deployable, and the deletions come last, after the logs prove nothing needs what you are deleting. In short, the spec finally agrees with your load balancer. The migration is the last session-shaped problem you have to solve.
Questions clients ask
Do I have to migrate my MCP server right away?
No. Every client deployed today speaks the 2025 spec versions, and they will for months. The official SDKs are shipping support for both models, so the realistic plan is to add the stateless path alongside the session path and watch your traffic. The moment to delete the session code is when your logs show old-version clients gone, and that date is set by your clients, not by the spec.
What replaces the Mcp-Session-Id header?
Nothing at the protocol level, and that is the point. State the server used to look up by session ID now travels in the payload: tools return a handle (a project ID, a job token, whatever names the thing you were tracking), the client keeps it, and the model passes it back as an argument on later calls. Protocol metadata that used to be established in the initialize handshake rides in _meta on every request instead.
Does the 2026-07-28 spec fix the load-balancer problem?
Yes. That problem existed because a follow-up request had to reach the exact replica holding the session. With no protocol session, any request can land on any replica, and the new Mcp-Method and Mcp-Name headers exist so a gateway can route on them without inspecting bodies. Sticky sessions, shared session stores, and affinity cookies, all machinery for pinning a client to one replica, stop being requirements and go back to being choices.
How does elicitation work without an open stream?
An elicitation is a mid-call question a tool asks the user. The server now returns an input-required result instead of pushing that question down a stream. The client collects the answers and re-issues the original call with the answers and the echoed request state attached, so any replica can pick up the resume. If you built elicitation as suspend-over-a-store, your design just became the protocol’s design, and the migration is mostly renaming.
More guides
- Your MCP server is stateful. Your load balancer doesn't know that.Sessions, sticky routing, proxy timeouts, and what breaks at two replicas.
- How to pause an MCP tool call, ask the user, and resumeMCP elicitation end to end: suspend and resume, timeouts that ignore human thinking time, and the race everyone hits.
- Why models ignore your MCP tools, and the design that fixes itServer instructions, filtering server-side, error results over exceptions, and tool output as prompt surface.
Running a session-era MCP server that has to migrate?
I audit MCP servers and plan migrations: what your sessions actually hold, which tools need handles, what your elicitation flows become, and what infrastructure you get to delete. Fixed price, $2,000, one week, credited toward any follow-on work.