The MCP spec ships its stateless core on 2026-07-28. The RC locked May 21, giving implementers a 10-week window to validate before that date — and the change isn't cosmetic. The initialize handshake is gone. The Mcp-Session-Id header is gone. Any request can now land on any server process, which means the load balancer you've been fighting with sticky-session hacks can finally just round-robin.
That sounds like good news, and it is. But it also means every place your server quietly assumed "the next request comes from the same client I just talked to" is now a bug. This is the walkthrough for finding and fixing those places — not another explainer of what the spec says. If you've never shipped a server at all, start with how to build an MCP server and come back.
1. Audit what your server keeps in memory#
Grep your codebase for anything keyed by session id: in-memory dicts, connection-scoped state, a self.sessions[session_id] pattern. This is the actual migration checklist.
# BEFORE: state lives in a dict keyed by the session the client got at initialize()
class MyServer:
def __init__(self):
self.sessions: dict[str, dict] = {}
def handle_tool_call(self, session_id, name, args):
state = self.sessions.setdefault(session_id, {"cart": []})
if name == "add_to_cart":
state["cart"].append(args["item"])
return {"cart": state["cart"]}
If a second instance behind your load balancer picks up the next call, state["cart"] doesn't exist there. That's the entire failure mode you're migrating away from.
2. Move state into explicit Task handles, not implicit sessions#
For anything long-running or stateful, the RC's answer is the Tasks extension (SEP-2663): a tools/call can return a durable task handle instead of blocking or streaming, and the client drives it forward with tasks/get, tasks/update, and tasks/cancel. Note tasks/list was intentionally removed — don't build server logic that assumes it can enumerate a client's tasks.
# AFTER: the shape you're migrating toward (see SEP-2663) —
# state is persisted externally and addressed by task id, not session id.
def handle_tool_call(self, name, args, meta):
if name == "generate_report":
task_id = self.task_store.create(kind="generate_report", args=args)
self.queue.enqueue(task_id)
return {"resultType": "task", "task": {"taskId": task_id, "status": "working"}}
def handle_tasks_get(self, task_id):
return self.task_store.read(task_id) # any instance can serve this
The task store is a database row or Redis key, not a process-local dict. That's the whole trick: durability moves from "which process is alive" to "which row exists."
3. Drop the session-id assumption in every handler#
Per-request identity — protocol version, client capabilities, auth context — now travels in _meta on every call instead of being negotiated once at initialize and cached against a session. Read it fresh, every time.
// BEFORE: version/capabilities read once at initialize, cached on the connection
server.onInitialize((params) => {
this.clientCapabilities = params.capabilities; // stale the instant you scale out
});
// AFTER: read from _meta per request; no connection-scoped cache
function handleRequest(req: JsonRpcRequest) {
const clientMeta = req.params?._meta ?? {};
const protocolVersion = req.headers["mcp-protocol-version"]; // e.g. "2026-07-28"
// make every handler idempotent — assume a retry might hit a different process
}
Idempotency is the real bar here: if a client retries a call (a real possibility once there's no sticky routing), your handler should produce the same effect, not a duplicate one.
4. Advertise cache TTLs on tools/list#
Clients previously re-fetched tools/list defensively, every turn, because there was no freshness signal. The RC adds ttlMs and cacheScope, modeled on HTTP Cache-Control, so clients can cache instead of re-asking.
def handle_tools_list(self):
return {
"tools": self.tool_definitions,
"ttlMs": 300_000, # cache for 5 minutes
"cacheScope": "server", # not per-session — there is no session
}
If your tool set changes per-user, say so with a narrower scope rather than disabling caching outright — that's the whole point of the field.
5. Move auth to OAuth 2.1 with issuer validation#
Six SEPs harden the OAuth/OIDC story in this release: clients must validate the iss parameter (RFC 9207), credentials get bound to their issuing authorization server, and OIDC clients declare application_type. If your server trusts a bearer token without checking who issued it, that's now a spec-level gap, not just a best practice you skipped.
def validate_token(token, request_iss):
claims = jwt.decode(token, ...)
if claims["iss"] != request_iss:
raise AuthError("issuer mismatch") # SEP-2468
if claims.get("aud") != self.expected_audience:
raise AuthError("token not bound to this server") # SEP-2352
6. Test behind a real load balancer, not localhost#
Stand up two server processes with no shared memory and a plain round-robin balancer in front — no sticky sessions, no session affinity rule. Route on the new Mcp-Method/Mcp-Name headers if your gateway supports operation-level rate limiting; that's exactly what those headers exist for. Then run your full tool-call → task-poll → task-complete flow and confirm it survives being served by a different instance at every step. If it does, you're not "stateless-ready" as a checkbox — you've actually deleted the assumption.
That's the insight worth keeping: statelessness in the 2026-07-28 spec isn't a feature you flip on. It's an assumption — sticky sessions, connection-scoped caches, session-keyed dicts — that you have to go find and delete, one handler at a time, before July 28.



