MCP server doesn't survive a deploy: SSE vs. Streamable-HTTP
Why SSE sessions die on a container restart – and the stateless Streamable-HTTP fix
The symptom: -32602 after every deploy
After every production deploy my MCP server was dead. Not „slow” or „flaky” – every single tool call, even a trivial read, failed:
The parameters were perfectly fine. The real reason was in the server log: Failed to validate request: Received request before initialization was complete. Before the deploy everything had worked flawlessly.
My MCP server runs as a separate sidecar container next to the web app – same image, its own process, secured with a bearer token. The client (a coding agent) connects over the SSE transport. That turned out to be the problem – I just didn't know it yet.
The setup
The server is built on FastMCP from the official Python SDK and started in SSE mode. Simplified:
# docker-compose.yml (trimmed)
mcp:
image: my-app:production
command: python -m mcp_server # starts FastMCP in SSE mode
environment:
- MCP_HOST=0.0.0.0
- MCP_PORT=8090
restart: unless-stopped
The client connects natively via SSE:
{
"mcpServers": {
"my-tools": {
"type": "sse",
"url": "http://INTERNAL:PORT/sse",
"headers": { "Authorization": "Bearer <token>" }
}
}
}
The wrong lead: „the sidecar is stale”
My first reflex: the container is stuck, just restart it. A docker restart of the MCP container – and it was still broken. A client-side reconnect helped sometimes, sometimes not. Only one thing was reproducible: after every deploy, -32602 again.
Diagnosis: server healthy, client not
Instead of guessing further, I tested the server directly – a raw MCP handshake against the freshly deployed container, bypassing the client:
import asyncio, httpx, json
async def handshake():
async with httpx.AsyncClient() as c:
# 1) open the SSE stream; the "endpoint" event yields the messages URL
async with c.stream("GET", BASE + "/sse", headers=AUTH) as r:
msg_url = await read_endpoint_event(r)
# 2) initialize -> notifications/initialized -> tools/list
await c.post(msg_url, json=INITIALIZE)
await c.post(msg_url, json=INITIALIZED_NOTIFICATION)
await c.post(msg_url, json=TOOLS_LIST)
# responses come back over the SSE stream
asyncio.run(handshake())
Result: the handshake completed cleanly – initialize ok, tools/list returned every tool. So the server is perfectly healthy after a restart. That made it clear: the bug lives on the client side.
Root cause: SSE sessions live in RAM
The SSE transport (now deprecated) keeps per-connection session state in memory. A deploy restarts the container → all sessions are gone. But the native SSE client didn't notice the drop cleanly and kept POSTing to the old, dead session_id instead of opening a fresh GET /sse with a new session.
After the deploy, requests with the pre-deploy session ID showed up: POST /messages/?session_id=<old> 202 followed by Received request before initialization was complete. A session the fresh server had never seen.
The second factor: stateless skip-init
A second issue piled on: modern clients can run in a stateless mode that skips the initialize handshake. That fits a stateless HTTP server – but the SSE transport is stateful and requires initialization. Every skipped init = a rejected request. That also explained the „worked for hours, then suddenly broke after a client update”.
The fix: Streamable-HTTP alongside SSE
SSE has been deprecated in the MCP ecosystem since 2025; the successor is Streamable-HTTP. FastMCP supports both. I now serve both transports on one port: legacy /sse for old clients and /mcp (Streamable-HTTP) for everything new. The key is stateless_http=True.
from mcp.server.fastmcp import FastMCP
mcp = FastMCP(
"My Tools",
host=HOST, port=PORT,
# /mcp needs no session ID / no initialize -> restart-proof
# and compatible with stateless-skip-init clients
stateless_http=True,
)
To run both transports with their lifespans (the Streamable-HTTP session manager must be started inside its lifespan), I combine them in one Starlette app:
import contextlib
from starlette.applications import Starlette
sse_app = mcp.sse_app() # /sse + /messages (legacy)
http_app = mcp.streamable_http_app() # /mcp (Streamable-HTTP)
@contextlib.asynccontextmanager
async def lifespan(app):
async with contextlib.AsyncExitStack() as stack:
await stack.enter_async_context(sse_app.router.lifespan_context(app))
await stack.enter_async_context(http_app.router.lifespan_context(app))
yield
app = Starlette(
routes=[*sse_app.routes, *http_app.routes],
lifespan=lifespan,
)
On the client side, just switch the transport:
{
"my-tools": {
"type": "http",
"url": "http://INTERNAL:PORT/mcp",
"headers": { "Authorization": "Bearer <token>" }
}
}
Why stateless_http is the key
In stateless mode every request is self-contained: no session ID, no initialize required, no state in RAM. That makes the server restart-proof (there is nothing to lose) and compatible with clients that skip the handshake. Verified: a tools/list with no init at all returns every tool right away.
What I left out
Stateless means: no server-initiated notifications/streams over a persistent session. For classic request/response tools (exactly my case) that's irrelevant – if you need server push, stay on stateful and handle session/reconnect logic properly. OAuth and true multi-user concurrency aren't the topic here either; this server is single-user behind a bearer token.
Conclusion
If your self-hosted MCP server greets every deploy with -32602, it's almost certainly the SSE transport plus the container restart – not your parameters. The way out: Streamable-HTTP with stateless_http=True, optionally legacy /sse in parallel for old clients. SSE is on its way out anyway – so the migration pays off twice.
Ad · Affiliate link – if you buy through it, I may earn a commission. It doesn’t change the price for you.