fix(mcp): mount session manager via Starlette Mount to avoid double response.start

StreamableHTTPSessionManager is an ASGI app — it sends its own
http.response.start and http.response.body messages via the scope/receive/send
triple. Wrapping it inside a @app.api_route FastAPI handler causes FastAPI
to try to finalize the response after the handler returns, emitting a
second http.response.start. uvicorn rejects this with:

    RuntimeError: Unexpected ASGI message 'http.response.start' sent,
    after response already completed.

Every POST /mcp raises, making the server unreachable from any MCP client
(tested with Claude Code 2.x against mcp==1.27.0).

Fix: mount the session manager as a Starlette Mount so it owns the
response cycle directly. Auth middleware still applies because
add_middleware attaches at the app level, wrapping all mounted sub-apps.
This commit is contained in:
Lucas Van Vonderen
2026-04-23 15:32:50 -04:00
committed by Remy
parent 528c362633
commit d0a49d0a0d
+13 -7
View File
@@ -1,7 +1,8 @@
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI, Request from fastapi import FastAPI
from mcp.server import Server from mcp.server import Server
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
from starlette.routing import Mount
from .auth import ApiKeyMiddleware from .auth import ApiKeyMiddleware
from .backend_client import backend from .backend_client import backend
@@ -28,15 +29,20 @@ async def lifespan(app: FastAPI):
await backend.stop() await backend.stop()
app = FastAPI(title="Homelable MCP", lifespan=lifespan) # Mount the session manager as an ASGI sub-app instead of wrapping it in a
# FastAPI @app.api_route handler. Wrapping it in a route handler causes
# FastAPI to send http.response.start after the session manager has already
# started the response, raising `RuntimeError: Unexpected ASGI message
# 'http.response.start' sent, after response already completed` on every
# POST /mcp — which makes the server unreachable from any MCP client.
app = FastAPI(
title="Homelable MCP",
lifespan=lifespan,
routes=[Mount("/mcp", app=session_manager.handle_request)],
)
app.add_middleware(ApiKeyMiddleware) app.add_middleware(ApiKeyMiddleware)
@app.api_route("/mcp", methods=["GET", "POST", "DELETE"])
async def mcp_endpoint(request: Request):
await session_manager.handle_request(request.scope, request.receive, request._send)
@app.get("/health") @app.get("/health")
async def health(): async def health():
return {"status": "ok"} return {"status": "ok"}