fix(server): register SSE GET as raw ASGI so BaseHTTPMiddleware does not see a second response start - #3381
Conversation
…not see a second response start Starlette wraps function endpoints in request_response. After EventSourceResponse has already finalized the SSE stream, that wrapper sent a second http.response.start (empty Response), which BaseHTTPMiddleware asserts against on client disconnect (modelcontextprotocol#883).
|
Thanks for the contribution. This repository only keeps pull requests open when they're linked to an issue that a maintainer has assigned to the author — CONTRIBUTING.md explains why and how we work. This PR has been closed for now because you aren't currently assigned to #883. If a maintainer would like this change as a PR from you, they'll assign you to #883 and this PR will reopen automatically — there's nothing more you need to do. (If you opened the issue, this PR already shows up on its timeline.) There's no need to open a new PR — this one will be reopened. While it's closed, please push any updates as new commits rather than force-pushing, since GitHub can't reopen a PR whose branch has been rewritten. Maintainers: reopening this PR, removing the |
Fixes #883.
What I hit
I reproduced this on current
main(56af447) by mountingMCPServer.sse_app(host="0.0.0.0")under a Starlette app that uses a no-opBaseHTTPMiddleware, opening/sse, reading the firstendpointevent, then closing the client. SameAssertionError:Stashing only
src/mcp/server/mcpserver/server.pyafter this change makestest_sse_app_under_base_http_middleware_survives_client_disconnect_without_second_responsefail with that same error.Why
sse_app()registered the unauth SSE GET as a Starlette function endpoint. Starlette wraps those inrequest_response.EventSourceResponsealready finalized the stream; the wrapper then sent a secondhttp.response.start(emptyResponse()).BaseHTTPMiddlewareasserts it only seeshttp.response.bodyafter the first start. Kludex already called this on the issue: the SDK must not send two starts.The auth path already passed a class instance (
RequireAuthMiddleware) toRoute, which Starlette treats as raw ASGI. The unauth path did not.Fix
_RawASGIEndpoint) instead of a function endpoint.Response()afterconnect_ssehas already sent the complete response.SseServerTransportmodule example so it no longer tells users to return a secondResponse().No new public API. Route shape is unchanged (one
Routeat/sse, oneMountat/messages).Tests
test_sse_app_under_base_http_middleware_survives_client_disconnect_without_second_response— in-processStreamingASGITransport, no-opBaseHTTPMiddleware, asserts exactly onehttp.response.startafter client disconnect.uv run --frozen pytest tests/shared/test_sse.py tests/server/test_sse_security.py tests/interaction/transports/test_sse.py tests/server/mcpserver/test_server.py→ 227 passed.uv run --frozen pyrighton the three changed files → 0 errors.Notes
#2016 / #2029 tried a similar ASGI-callable approach and were closed unmerged. #1222 is still an open draft. This is a fresh patch against current
main, not a rebase of those.AI-assisted implementation (Claude Code). I reproduced the failure on this tree, reviewed the diff, and reran the SSE / MCPServer tests before opening this.
I have not asked to be assigned. If the auto-close bot parks this until a maintainer wants an outside PR, that is understood.