How MCP v2 Goes Stateless and Drops Sticky Sessions for Horizontal Scaling
Problem
I run an MCP server behind a load balancer with more than one instance, and in MCP v1 every session is pinned to the backend that started it. When I scale up, restart, or lose an instance, all the active sessions on that instance die.
The symptom is easy to recognize:
client ----[init]----> instance A (session lives in A's memory)client ----[tool call]----> instance B (B has never seen this session)instance B -> 404 / "unknown session_id"OP on r/mcp frames the v1 pain plainly: “no more sticky sessions feels like a real weight off if you’re running anything horizontally scaled.” That sentence is the whole story for me.
Environment
- MCP v1 server, multiple instances behind an L7 load balancer
- Load balancer configured for sticky routing (cookie or IP hash)
- Per-session state kept in server process memory (dicts, caches, subscription handles)
What happened?
In v1, a session lives on a single server instance. The load balancer has to keep routing the same client to the same backend, or the session state is gone.
Here is a trimmed-down version of what my server looked like:
class MyMCPServer: def __init__(self): self._session_cache = {} # per-session, lives in THIS process only
def handle(self, session_id, request): ctx = self._session_cache[session_id] # only works on the instance that owns it return ctx.process(request)I can explain the key parts:
_session_cacheis per-process. Whichever instance raninitowns it.- Any later request routed to a different instance throws
KeyError.
But when I add a second instance for capacity and a client’s tool call lands on the “wrong” backend, I get:
KeyError: 'ctx:9f3c-...session-id...'That is the sticky-session trap. It looks fine on a single instance and breaks the moment real horizontal scaling starts.
How to solve it?
MCP v2 makes the protocol stateless and removes the sticky-session requirement. Each request is self-contained, so any instance can serve any request. The hard part is not the protocol change, it is the state that used to hide in my process.
I tried the smallest possible rewrite first: move per-session state to a shared store and stop assuming affinity.
import redis
r = redis.Redis.from_env()
class MyMCPServer: def handle(self, session_id, request): ctx = r.get(f"ctx:{session_id}") # any instance can read this if ctx is None: ctx = self._init_context(session_id) r.set(f"ctx:{session_id}", ctx, ex=3600) return self._process(session_id, ctx, request)What changed and why:
- The session cache left the process. Now any instance can read it.
- I added a TTL. Sessions that go away clean themselves up without a shutdown hook.
- The load balancer in front no longer needs sticky routing. Round-robin works.
The picture after the move:
v1 (sticky): client -- sticky --> [ A only ] (B idle, useless for this session)
v2 (stateless): client -- round-robin --> [ A ] +--> [ B ] (both serve any request)
shared state: [ Redis / DB ]You can see that I succeeded to scale without pinning. If instance A restarts, B handles the next request and nothing breaks because neither owns the session.
The reason
I think the key reason for the change is that sticky sessions conflict with the way people actually scale today. In v1, the protocol contract and the deployment topology were coupled — the protocol assumed one instance per session, so my load balancer had to enforce that. v2 decouples them:
- Protocol level: a request carries enough information to be served by anyone.
- Deployment level: any L7 load balancer, round-robin, least-connections, or a CDN in front, is valid.
- Server implementer level: I am now responsible for moving anything I want to persist out of the process.
A common mistake is to read “stateless” as “your server can hold no state at all.” That is wrong. You can still keep state, it just cannot live attached to one instance. Durable state goes to Redis or a database; ephemeral state (cursor positions, short-lived UI hints) goes to the client.
Two things to plan before upgrading that are easy to forget:
- Subscriptions and resources — if you kept resource handles in memory, they are now per-instance and will die on failover. Decide whether to externalize them or accept that they do not survive instance churn.
- Sampling context — if you cached model-call context that was meant to be reused with the host, that also has to move. See my post on sampling deprecation for why this is doubly relevant.
Summary
In this post, I showed how MCP v2’s stateless model removes sticky sessions and makes horizontal scaling straightforward, as long as I externalize per-session state to a shared store. The key point is that the protocol no longer assumes instance affinity, so the deployment can finally use a plain round-robin balancer.
Final Words + More Resources
My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me
Here are also the most important links from this article along with some further resources that will help you in this scope:
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments