MCP-Server überlebt keinen Deploy: SSE vs. Streamable-HTTP
Warum SSE-Sessions beim Container-Restart sterben – und der stateless Streamable-HTTP-Fix
Das Symptom: -32602 nach jedem Deploy
Nach jedem Produktiv-Deploy war mein MCP-Server tot. Nicht „langsam” oder „flaky” – jeder Tool-Aufruf, selbst ein simples Read, schlug fehl:
Die Parameter waren völlig korrekt. Im Server-Log stand der wahre Grund: Failed to validate request: Received request before initialization was complete. Vor dem Deploy lief alles einwandfrei.
Der MCP-Server läuft bei mir als eigener Sidecar-Container neben der Web-App – gleiches Image, eigener Prozess, per Bearer-Token abgesichert. Der Client (ein Coding-Agent) verbindet sich über das SSE-Transport. Genau das war das Problem – nur wusste ich das anfangs noch nicht.
Das Setup
Der Server basiert auf FastMCP aus dem offiziellen Python-SDK und wird im SSE-Modus gestartet. Vereinfacht:
# docker-compose.yml (gekürzt)
mcp:
image: meine-app:production
command: python -m mcp_server # startet FastMCP im SSE-Modus
environment:
- MCP_HOST=0.0.0.0
- MCP_PORT=8090
restart: unless-stopped
Der Client verbindet sich nativ per SSE:
{
"mcpServers": {
"meine-tools": {
"type": "sse",
"url": "http://INTERN:PORT/sse",
"headers": { "Authorization": "Bearer <token>" }
}
}
}
Die falsche Fährte: „der Sidecar ist stale”
Mein erster Reflex: Der Container hängt, einfach neu starten. Ein docker restart des MCP-Containers – und es war trotzdem kaputt. Auch ein Reconnect im Client half mal, mal nicht. Reproduzierbar war nur eins: nach jedem Deploy wieder -32602.
Diagnose: Server gesund, Client nicht
Statt weiter zu raten, habe ich den Server direkt getestet – ein roher MCP-Handshake gegen den frisch deployten Container, am Client vorbei:
import asyncio, httpx, json
async def handshake():
async with httpx.AsyncClient() as c:
# 1) SSE-Stream öffnen, "endpoint"-Event liefert die 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)
# Antworten kommen über den SSE-Stream zurück
asyncio.run(handshake())
Ergebnis: Der Handshake lief sauber durch – initialize ok, tools/list lieferte alle Tools. Der Server ist nach einem Restart also kerngesund. Damit war klar: Der Bug sitzt auf der Client-Seite.
Root Cause: SSE-Sessions leben im RAM
Das SSE-Transport (inzwischen deprecated) hält pro Verbindung einen Session-State im Arbeitsspeicher. Ein Deploy startet den Container neu → alle Sessions sind weg. Der native SSE-Client merkte den Abbruch aber nicht sauber und POSTete weiter auf die alte, tote session_id, statt einen frischen GET /sse mit neuer Session zu öffnen.
Nach dem Deploy tauchten Requests mit der Pre-Deploy-Session-ID auf: POST /messages/?session_id=<alt> 202 gefolgt von Received request before initialization was complete. Eine Session, die der frische Server nie gesehen hat.
Der zweite Faktor: stateless-skip-init
Es kam ein zweiter Punkt dazu: Moderne Clients können in einem stateless-Modus laufen, der den initialize-Handshake überspringt. Das passt zu einem zustandslosen HTTP-Server – aber das SSE-Transport ist zustandsbehaftet und verlangt die Initialisierung. Jeder übersprungene Init = abgelehnter Request. Das erklärte auch das „lief stundenlang, dann nach einem Client-Update plötzlich kaputt”.
Der Fix: Streamable-HTTP parallel zu SSE
SSE ist im MCP-Ökosystem seit 2025 abgekündigt; der Nachfolger ist Streamable-HTTP. FastMCP kann beides. Ich fahre nun beide Transporte auf einem Port: Legacy /sse für Altclients und /mcp (Streamable-HTTP) für alles Neue. Der Schlüssel ist stateless_http=True.
from mcp.server.fastmcp import FastMCP
mcp = FastMCP(
"Meine Tools",
host=HOST, port=PORT,
# /mcp braucht keine Session-ID / kein initialize -> restart-fest
# und kompatibel mit stateless-skip-init-Clients
stateless_http=True,
)
Damit beide Transporte mit ihren Lifespans laufen (der Streamable-HTTP-Session-Manager muss in seiner Lifespan gestartet werden), kombiniere ich sie in einer 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,
)
Auf der Client-Seite nur noch den Transport umstellen:
{
"meine-tools": {
"type": "http",
"url": "http://INTERN:PORT/mcp",
"headers": { "Authorization": "Bearer <token>" }
}
}
Warum stateless_http der Schlüssel ist
Im stateless-Modus ist jeder Request self-contained: keine Session-ID, kein initialize nötig, kein State im RAM. Das macht den Server restart-fest (es gibt nichts zu verlieren) und kompatibel mit Clients, die den Handshake überspringen. Gegengetestet: ein tools/list ganz ohne Init liefert direkt alle Tools.
Was ich weggelassen habe
Stateless bedeutet: keine server-initiierten Notifications/Streams über eine dauerhafte Session. Für klassische Request/Response-Tools (genau mein Fall) ist das egal – wer Server-Push braucht, muss bei stateful bleiben und das Session-/Reconnect-Handling sauber lösen. OAuth und echte Mehrnutzer-Parallelität sind hier ebenfalls nicht das Thema; der Server ist Single-User hinter einem Bearer-Token.
Fazit
Wenn dein selbst gehosteter MCP-Server jeden Deploy mit -32602 quittiert, liegt es mit hoher Wahrscheinlichkeit am SSE-Transport plus Container-Restart – nicht an deinen Parametern. Der Weg raus: Streamable-HTTP mit stateless_http=True, optional Legacy-/sse parallel für Altclients. SSE ist ohnehin auf dem Abstellgleis – der Umstieg lohnt doppelt.
Anzeige · Affiliate-Link – kaufst du darüber, erhalte ich ggf. eine Provision. Für dich ändert sich am Preis nichts.