uptime · 1413 days · 25 posts published · last deploy 3 hours, 42 minutes ago build:passing rss
~ / linux / mcp-server-deploy-sse-vs-streamable-http.md
linux · 14. June 2026 · ~10min · 092e92d

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

>
devmaker.net
author · 092e92d · 2026-06-14
MCP SSE vs Streamable-HTTP Header.jpg 1024×1024
MCP SSE vs Streamable-HTTP Header
After every deploy, my self-hosted MCP server rejected every tool call with „-32602 Invalid request parameters” – even though the parameters were correct. This article walks through the full debugging from real operation: why the deprecated SSE transport can't survive container restarts, how to tell server errors from client errors, and how migrating to stateless Streamable-HTTP fixes it for good – with FastMCP and without giving up server push.

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:

MCP error -32602: Invalid request parameters

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 cleanlyinitialize 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.

The proof in the log

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

An honest scope note

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.

// Recommended hardware & tools

Ad · Affiliate link – if you buy through it, I may earn a commission. It doesn’t change the price for you.

// responses (0)
> echo "your thoughts" >> mcp-server-deploy-sse-vs-streamable-http.responses

Post your comment

Required for comment verification