MCP stateful sessions fail with every free-tier load balancer — and neither major client recovers automatically
MCP stateful sessions fail with every free-tier load balancer — and neither major client recovers automatically
What you expect
The MCP Streamable HTTP spec defines a session recovery path: when a client receives HTTP 404 for a session ID it holds, it MUST send a new InitializeRequest without a session ID, restoring functionality automatically. Horizontal scaling should work like any other HTTP service — add containers, point a load balancer at them, route traffic.
What actually happens
Session state is per-process, not per-server
MCP’s StreamableHTTPSessionManager stores session state in individual worker process memory. There is no external persistence interface in the SDK. Any request routed to a different process or container than the one that issued the session ID fails with session-not-found.
The failure sequence on a two-worker or two-container deployment:
- Client POST
InitializeRequest→ worker A → session ID issued, stored in A’s memory (200 OK) - Client POST first tool call → worker A → succeeds (202)
- Client POST second tool call → worker B (different process) → 404, session unknown
This is not a race condition — it is deterministic under any round-robin or least-connection routing without session affinity. It also affects gunicorn multi-worker on a single host: workers=4 gives four isolated session stores on one machine (python-sdk #520, labeled P1). workers=1 is the only safe gunicorn configuration for stateful mode.
No free-tier load balancer supports the required routing primitive
MCP session affinity requires routing on the Mcp-Session-Id request header. Cookie-based stickiness does not work — MCP clients use fetch() and do not forward Set-Cookie headers from CORS responses.
| Load Balancer | Header-Based Sticky Sessions | Notes |
|---|---|---|
| NGINX Plus (commercial) | Yes | sticky learn create=$sent_http_mcp_session_id lookup=$http_mcp_session_id zone=client_sessions:1m |
| HAProxy | Yes | balance hdr(Mcp-Session-Id) with fallback to roundrobin |
| Free NGINX (open source) | No | No header-based sticky session support |
| AWS ALB | No | Cookie-based stickiness only |
| GCP Cloud Load Balancer | No | Cookie-based stickiness only |
| Docker Swarm built-in | No | No sticky session support at all |
| Docker Compose Traefik (default) | No | Cookie-based by default, ignored by MCP clients |
Production-grade horizontal scaling of a stateful MCP server requires NGINX Plus or HAProxy. No cloud provider’s default load balancer offering supports the required routing primitive.
Both major MCP clients violate the spec’s session recovery clause
The MCP spec (2025-03-26, §Session Management) is explicit: when a client receives HTTP 404 with its session ID in the request headers, it MUST send a new InitializeRequest without a session ID. Neither dominant MCP client implements this.
Cursor: On 404 (or the 400 that the Python SDK returns for unknown session IDs), enters an unrecoverable error loop displaying “Error POSTing to endpoint (HTTP 400): Bad Request: No valid session ID provided.” Retries indefinitely with the stale session ID. Recovery requires manually disabling and re-enabling the MCP server in Settings → MCP (forum thread 134781).
VSCode: Logs “Error reading from async stream” (microsoft/vscode #253854) but never sends a new InitializeRequest. Same manual toggle required.
Server restarts, container recycling, rolling deployments, and session TTL expiry all trigger this. The spec’s auto-recovery path is non-functional in both tier-1 clients.
FastMCP v1.9.1 silently broke all Docker deployments
FastMCP changed its default host binding from 0.0.0.0 to 127.0.0.1 in v1.9.1, aligned with the MCP spec’s DNS rebinding mitigation recommendation. No migration notice or breaking-change entry was provided. All Docker and Docker Compose deployments using the FastMCP default binding became silently unreachable — the MCP client receives no error, the container simply does not respond (python-sdk #790).
Workaround: set FASTMCP_HOST=0.0.0.0 in the container environment. Verify this workaround is still functional on your FastMCP version before deploying.
Three frameworks fixed the same race condition within 30 days
CrewAI (1.10.2a1), Pydantic AI (v1.64.0), and Agno (v2.5.10) each shipped independent fixes for MCP session lifecycle race conditions within a 30-day window. Three separate engineering teams, same root class of failure: session state created on one coroutine is not reliably visible to a concurrent request arriving before initialization completes, producing intermittent 404s or silent tool-call failures under load. The convergence indicates the MCP Python SDK’s session abstractions are insufficient for concurrent use — any framework wrapping the SDK inherits the race until it adds its own synchronization.
What this means for you
If you are deploying a stateful MCP server with horizontal scaling on any cloud provider’s standard load balancer, your MCP clients will silently lose session context under any of: container restart, rolling deployment, load balancer re-routing, or gunicorn multi-worker configuration. Tool calls fail silently or hang — no error surfaces to the user.
When this happens, neither Cursor nor VSCode auto-recovers. Users must find the MCP server toggle in their client settings and manually restart. This is not documented in either client’s UX.
If you used FastMCP and deployed without pinning the host binding, you may already be shipping a silently unreachable server after an upgrade.
What to do
-
If scaling is required and tools are pure functions: use stateless mode (
stateless_http=Truein FastMCP). Enables Docker Compose--scalewithout any session infrastructure. Verifyctx.sample()is not called anywhere before switching. -
If stateful mode is required but scaling is not: run
workers=1in gunicorn. Single worker per container eliminates multi-process session isolation failures. -
If stateful mode + horizontal scaling: use NGINX Plus or HAProxy with header-based sticky routing on
Mcp-Session-Id. Configure session drain (allow in-flight requests to complete before removing a backend). Accept that this requires a commercial load balancer. -
For Docker deployments with FastMCP: pin
FASTMCP_HOST=0.0.0.0explicitly — do not rely on defaults across versions. -
Document the manual recovery procedure for users: Settings → MCP → toggle off → wait 2 seconds → toggle on. Neither client will prompt for this.
-
Monitor python-sdk PR #2126: proposes Redis-backed session persistence — if merged, enables true horizontal scaling of stateful servers. The MCP spec’s June 2026 update targets per-request capability metadata, which would eliminate this failure class by design.
Falsification criterion: This finding would be disproved by demonstrating that Cursor ≥ 2.2.14 or VSCode correctly sends a new InitializeRequest without a session ID after receiving a 404, or by a free-tier cloud load balancer that supports header-based sticky routing on Mcp-Session-Id.
Evidence
| Tool | Version | Evidence | Result |
|---|---|---|---|
| MCP Python SDK | v1.x | independently-confirmed | Per-process session state; no external persistence (#520, P1) |
| Cursor | pre-2.2.14 | independently-confirmed | Violates spec 404 recovery MUST — loops on stale session ID, manual toggle required (forum thread 134781) |
| VSCode MCP extension | current (Mar 2026) | independently-confirmed | No new InitializeRequest on session loss; logs error only (#253854) |
| FastMCP | v1.9.1 | source-reviewed | Host binding 0.0.0.0 → 127.0.0.1 silently broke Docker deployments (python-sdk #790) |
| AWS ALB / GCP Cloud LB | current | docs-reviewed | Cookie-based stickiness only; MCP fetch() ignores Set-Cookie from CORS |
| CrewAI / Pydantic AI / Agno | 1.10.2a1 / v1.64.0 / v2.5.10 | source-reviewed | Three independent session race fixes within 30 days; changelog-derived |
Confidence: empirical — 6 environments reviewed via source-reviewed and independently-confirmed public GitHub issues. No commands were executed against running instances. Core incompatibility is structural (in-process session state); client non-compliance confirmed via public issue trackers (#520, #253854).
Strongest case against: The failure class has a clear architectural fix: the MCP spec’s June 2026 roadmap targets per-request capability metadata that would eliminate stateful sessions entirely. Teams building for Q4 2026 deployments may reasonably wait for the spec to land rather than implement commercial load balancer workarounds now. The CrewAI/Pydantic AI/Agno race condition fixes also suggest the ecosystem is converging on robust session handling, which may reduce the blast radius of the per-process isolation problem.
Open questions: Does Cursor 2.2.14+ correctly implement the spec’s 404 recovery MUST clause? Has anyone shipped Redis-backed session persistence for the MCP Python SDK outside the official PR #2126 track? Does the June 2026 spec update ship on schedule?
Seen different? Contribute your evidence — share a repro or counter-example and we’ll review it against this finding. Reader evidence is what keeps these findings accurate.