I Tried Redis. It Didn't Fix Stateful MCP on ECS Fargate

13+ Years of experienced as Full Stack Developer. Also worked as architect for building solutions and product to help for automation. Solution-oriented and hands-on technical utility player. Having experience of more than 4 years of experience in E commerce and finance in each domain. Experience in having driving business automation, marketing using technology. Strong follower of open source technology. Used PHP, Python, AWS and Angular as technology stack to build product
In my previous post, I showed how a stateful MCP server running on ECS Fargate loses its entire session when a rolling deployment replaces the task. The failure was invisible — ALB healthy, ECS healthy, CloudWatch clean — but the client was getting Session not found errors from call 39 onward. The fix everyone reaches for is Redis. I ran the experiment. It did not work.
This post explains exactly why Redis failed, what is actually happening at the transport layer, and why the MCP 2026-07-28 spec release candidate just solved this problem at the protocol level.
What I Expected Redis to Fix
The hypothesis was straightforward. When a Fargate task dies during a rolling deployment, in-memory session state dies with it. If I move session state to ElastiCache Redis, any replacement task can read it. Session survives.
I set up the experiment with two phases:
FastMCP Python server (Streamable HTTP transport,
mcpSDK,protocolVersion: 2025-03-26)ECS Fargate service, 2 tasks, behind ALB with sticky sessions enabled
ElastiCache Redis (
cache.t3.micro) in the same VPC, private subnetTest client running locally on my laptop, hitting the public ALB
Structured JSON logging on every tool call:
task_id,backend,session_idPhase 1:
USE_REDIS=false— baseline, in-memory session statePhase 2:
USE_REDIS=true— Redis-backed session state
The diagram below shows how the components were connected.
The two Fargate tasks share the same ElastiCache Redis instance. The ALB routes traffic — first to the old task via sticky session, then to the replacement task after rolling deployment. The test client sits outside AWS, hitting the public ALB.
What to look for: The green task (c272a920) is the original task writing to Redis. The red task (38aabc01) is the replacement — it connects to Redis successfully on startup but never actually reads or writes from it during the experiment. That gap between "Redis connected" and "Redis consulted" is what this post explains.
Phase 1 confirmed the baseline: calls 1–46 succeeded, call 47 returned Session not found, calls 47–50 all 404. Expected.
Phase 2 was supposed to be the fix.
What the Logs Actually Showed
CloudWatch logs for the Phase 2 task (c272a920) at startup:
{"event": "redis_connected", "endpoint": "mcp-fargate-redis.xzj9gr.0001.aps1.cache.amazonaws.com", "port": 6379}
{"event": "session_backend_selected", "backend": "redis"}
Every tool call for the next 44 calls:
{"event": "tool_call", "tool_name": "set_session_value", "session_id": "8d302da7-828e-4e16-b62a-7b3b3a327d97", "task_id": "c272a920a3434f9b9446d837c21ecb7f", "backend": "redis"}
{"event": "tool_call", "tool_name": "get_session_state", "session_id": "8d302da7-828e-4e16-b62a-7b3b3a327d97", "task_id": "c272a920a3434f9b9446d837c21ecb7f", "backend": "redis"}
Redis was active. Data was being written on every set_session_value call. The task was healthy.
Then I triggered a rolling deployment. The ALB started routing to a replacement task (38aabc01). Here are that task's startup logs:
{"event": "redis_connected", "endpoint": "mcp-fargate-redis.xzj9gr.0001.aps1.cache.amazonaws.com", "port": 6379}
{"event": "session_backend_selected", "backend": "redis"}
{"message": "StreamableHTTP session manager started"}
And then the requests started arriving from the client:
INFO: 10.0.1.212:11140 - "POST /mcp HTTP/1.1" 404 Not Found
INFO: 10.0.1.212:11140 - "POST /mcp HTTP/1.1" 404 Not Found
INFO: 10.0.1.212:4724 - "POST /mcp HTTP/1.1" 404 Not Found
INFO: 10.0.1.212:35924 - "POST /mcp HTTP/1.1" 404 Not Found
I searched the replacement task's CloudWatch logs for tool_call. Zero results.
Redis was connected. The task was healthy. The client's requests were arriving. Not a single tool was invoked. The 404 happened before my application code ran.
Why Redis Could Not Help
The failure is in FastMCP's StreamableHTTPSessionManager, one layer above your application code and one layer below where Redis lives.
In mcp/server/streamable_http_manager.py (mcp SDK, as of the version used in this experiment):
class StreamableHTTPSessionManager:
def __init__(self, ...):
self._server_instances: dict[str, StreamableHTTPServerTransport] = {}
async def _handle_stateful_request(self, request):
request_mcp_session_id = request.headers.get(MCP_SESSION_ID_HEADER)
if request_mcp_session_id not in self._server_instances:
# Returns 404 here. Before transport. Before your tool code. Before Redis.
return Response(status_code=404)
_server_instances is a plain Python dict. It lives in the process on that specific container. When the container stops, the dict is gone. When the replacement container starts, the dict is empty.
The client presents Mcp-Session-Id: 5b9cde86056f474eb461a4fa3620bc7b — the session ID the old task issued. The new task has never seen it. The session manager returns 404 immediately. Your tool code is never called. Redis is never consulted.
This is not a bug. It is the spec behaving correctly. A session ID is a handle to a live transport instance — an anyio.MemoryObjectStream pair, an active coroutine, live TCP socket references. None of that can be serialized to Redis. The session is always bound to the process that created it.
The sequence diagram below shows exactly what happens at each step — from a working session to the moment Redis is bypassed.
Follow the arrows from top to bottom. Calls 1–44 succeed because the ALB routes to the old task, which recognises the session ID in its _server_instances dict and reaches Redis. After the rolling deploy, the new task's dict is empty. The next client request hits the new task, gets rejected at the session manager, and Redis is never reached. The data in Redis at that point is intact — it is just unreachable.
What to look for: The critical moment is after the rolling deploy note. The new task's StreamableHTTPSessionManager starts with an empty _server_instances dict. When the client's next request arrives carrying the old session ID, the dict lookup fails and the 404 fires on that line alone — before anything downstream (your tools, your Redis calls) is ever reached. This is why Redis being connected made no difference.
Three Compounding Problems
The transport gate is the primary failure. But even if you patched around it, two more problems would remain independently.
The diagram below shows all three failure layers as a decision tree.
Read it from top to bottom. Each layer represents an independent point of failure. The experiment hit Layer 1 and stopped there — the 404 at the session manager gate. But even if you modified FastMCP to skip that gate, Layer 2 would lose your data due to wrong Redis key design, and Layer 3 would leave the client with no way to retrieve it.
What to look for: The Phase 2 experiment only ever reached Layer 1 — the red box at the top right. That is as far as the request got. Layers 2 and 3 are what would have failed next if Layer 1 had been patched, which is why fixing Redis alone is not enough. All three need addressing for state to actually survive a rolling deployment.
Wrong Redis key. The implementation keyed Redis by the MCP transport session ID (ctx.session_id → mcp:session:5b9cde86...). That ID is ephemeral — it changes every time a new session is established. On reconnect, the new session gets a new ID, creates a fresh empty Redis hash, and the old data at mcp:session:5b9cde86... is orphaned. State lost even if Layer 1 were fixed.
No stable client identity. The test client tracked only the MCP session ID returned by the server. After reconnecting to a new task and receiving a new session ID, the client had no stable identifier to use for retrieving the orphaned Redis data. No anchor. No recovery path.
Three independent failures, each enough to lose the session on its own.
What About EventStore?
FastMCP provides a pluggable EventStore with a Redis backend. I investigated this as a potential fix.
EventStore is designed for a different failure mode: same-task SSE reconnections — a network blip drops the client briefly, the task is still alive, the session ID is still in _server_instances. The transport can replay missed events from the store when the client reconnects.
When the task is gone, EventStore cannot help. The 404 fires at the session manager before the transport exists. The replay path in event_store.replay_events_after() is only reachable when the session ID is already found in _server_instances. It never gets called.
There is also a second blocker. EventStore priming events — the SSE events that give the client a Last-Event-ID to reconnect with — are only sent to clients using protocolVersion >= 2025-11-25. My test client negotiated 2025-03-26. No priming events. No Last-Event-ID. Even if the task survived, replay would not work with this client version.
What the Spec Just Did
The MCP 2026-07-28 release candidate, published May 21, 2026, removes Mcp-Session-Id and the initialize/initialized handshake from the protocol entirely. Both gone. Not deprecated — removed.
The gate in _server_instances that caused the 404 no longer needs to exist in the new protocol. Any request can land on any server instance. No sticky sessions. No shared session store. Plain round-robin load balancer.
The before and after from the spec blog is direct:
Before (2025-11-25):
POST /mcp HTTP/1.1
Mcp-Session-Id: 1868a90c-3a3f-4f5b
Content-Type: application/json
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"search","arguments":{"q":"otters"}}}
After (2026-07-28):
POST /mcp HTTP/1.1
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: search
Content-Type: application/json
{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"search","arguments":{"q":"otters"},"_meta":{"io.modelcontextprotocol/clientInfo":{"name":"my-app","version":"1.0"}}}}
No session ID in the second request. Client info travels in _meta on every request. The server does not need to remember anything about the client between requests.
The Explicit Handle Pattern
Removing the protocol session does not mean your application cannot be stateful. The spec introduces the explicit handle pattern as the replacement.
The server mints a stable handle from a tool — a cart_id, a browser_session_id, whatever is meaningful for your domain — and returns it to the model. The model passes that handle back as a plain argument on subsequent calls. The server uses it to look up state in Redis or any other store.
@mcp.tool()
async def create_session() -> dict:
session_id = str(uuid.uuid4()) # stable, client-meaningful, NOT the transport session
await redis.hset(f"session:{session_id}", "created_at", time.time())
return {"session_id": session_id}
@mcp.tool()
async def set_value(session_id: str, key: str, value: str) -> dict:
await redis.hset(f"session:{session_id}", key, value)
return {"ok": True}
@mcp.tool()
async def get_state(session_id: str) -> dict:
state = await redis.hgetall(f"session:{session_id}")
return state
The sequence diagram below shows how the model threads the handle across calls, and why a task replacement no longer causes any failure.
Compare this to the failure sequence diagram earlier. The key difference: there is no _server_instances lookup. The session ID arrives as a plain tool argument — the new task just reads it, queries Redis, and returns state. No gate. No empty dict. No 404.
What to look for: After the rolling deploy note, the new task receives session_id as a plain argument — not as an HTTP header that needs to match an internal registry. It goes straight to Redis. The data comes back intact. This is the architectural shift the new spec enforces: state identity moves from the transport layer into the tool argument, where it is visible to the model and survives any infrastructure event.
The model holds session_id as a variable and passes it to every subsequent call. When the task is replaced, the new task has no session to look up — because there is no transport session anymore. It just receives session_id as a tool argument, queries Redis, and returns the state. No 404. No orphaned data. No transport gate.
This is what my Phase 2 experiment was trying to achieve manually — a stable application identifier separate from the transport session ID, keyed correctly in Redis. The new spec makes it the only pattern at the protocol layer.
What to Do Now
If you are running stateful MCP on ECS Fargate today (2025-03-26 or 2025-11-25):
The session loss problem in this post still applies until your SDK ships 2026-07-28 support. Tier 1 SDKs are expected to ship support by July 28, 2026.
Shortest path before then:
Generate a stable
app_session_idon the client side (a UUID the client owns, not the server-assignedMcp-Session-Id)Pass it as an explicit argument to every stateful tool call
Key Redis by
app_session_id, notctx.session_idAdd client-side reconnect logic: on
Session not found, callinitializeto get a new transport session, then immediately call your state-retrieval tool with the stableapp_session_id
This is user-space code. No library changes needed.
If you are building new today:
Target 2026-07-28 and use the explicit handle pattern. The transport no longer manages session state, so sticky sessions and shared session stores are no longer required at the protocol layer. ECS Fargate rolling deployments stop being a session-loss problem — any task can handle any request because every request carries everything the server needs. Redis is still the right state store; what changes is the key. You mint a stable handle from a tool, the model passes it back as an argument, and you key Redis by that handle — not by an ephemeral transport session ID. One caveat: clients still need to handle connection retries when a task is replaced mid-request. The protocol cannot survive a TCP drop for you. But a retried request lands safely on any available task, which was never true before
What This Experiment Proved
Redis is necessary for state durability across Fargate task replacements. Redis is not sufficient, and for current protocol versions, it is not even reachable when the failure occurs.
The failure happens at _server_instances — a Python dict check that fires before any external store is consulted. The 404 is correct spec behavior. The session ID is intentionally bound to the process that created it.
The MCP 2026-07-28 spec eliminates the problem at the protocol level by eliminating the concept of a protocol-level session. The explicit handle pattern puts state management where it belongs: in your application, visible to the model, keyed by identifiers that you control and that survive infrastructure events.
The experiment ran against protocolVersion: 2025-03-26. The spec has moved. The CloudWatch logs explaining why are above.
Code and experiment infrastructure: github.com/AvinashDalvi89/stateful-mcp-on-ecs-fargate-example
MCP 2026-07-28 Release Candidate: blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate
Part 1 of this series: The stateful MCP failure mode on ECS Fargate




