uptime · 1413 days · 25 posts published · last deploy 3 hours, 37 minutes ago build:passing rss
~ / linux / mcp-server-deploy-sse-vs-streamable-http.md
Linux · 14. Juni 2026 · ~9min · a4b2900

MCP-Server überlebt keinen Deploy: SSE vs. Streamable-HTTP

Warum SSE-Sessions beim Container-Restart sterben – und der stateless Streamable-HTTP-Fix

>
devmaker.net
author · a4b2900 · 2026-06-14
Nach jedem Deploy quittierte mein selbst gehosteter MCP-Server jeden Tool-Aufruf mit „-32602 Invalid request parameters” – obwohl die Parameter stimmten. Dieser Artikel zeigt die komplette Fehlersuche aus echtem Betrieb: warum das deprecatete SSE-Transport Container-Restarts prinzipbedingt nicht überlebt, wie man Server- von Client-Fehlern trennt, und wie der Umstieg auf stateless Streamable-HTTP das Problem dauerhaft löst – mit FastMCP und ohne Server-Push aufzugeben.

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:

MCP error -32602: Invalid request parameters

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

Der Beweis im Log

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

Ehrliche Abgrenzung

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.

// Empfohlene Hardware & Tools

Anzeige · Affiliate-Link – kaufst du darüber, erhalte ich ggf. eine Provision. Für dich ändert sich am Preis nichts.

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

Schreibe einen Kommentar

Wird für die Bestätigung benötigt