From 601e4ac56974bbd68637fe0839d8b7ea872613ed Mon Sep 17 00:00:00 2001 From: hylin Date: Sun, 30 Aug 2026 02:24:55 +0800 Subject: [PATCH] Handle client disconnects while reading POST body --- src/mcp/server/streamable_http.py | 5 +++- tests/shared/test_streamable_http.py | 40 ++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/src/mcp/server/streamable_http.py b/src/mcp/server/streamable_http.py index 416dd9e2b4..72562f8f2a 100644 --- a/src/mcp/server/streamable_http.py +++ b/src/mcp/server/streamable_http.py @@ -37,7 +37,7 @@ from mcp_types.version import is_version_at_least from pydantic import ValidationError from sse_starlette import EventSourceResponse -from starlette.requests import Request +from starlette.requests import ClientDisconnect, Request from starlette.responses import Response from starlette.types import Receive, Scope, Send @@ -714,6 +714,9 @@ async def _handle_post_request(self, scope: Scope, request: Request, receive: Re finally: await sse_stream_reader.aclose() + except ClientDisconnect: + logger.debug("Client disconnected while reading POST request body") + return except Exception as err: logger.exception("Error handling POST request") response = self._create_error_response( diff --git a/tests/shared/test_streamable_http.py b/tests/shared/test_streamable_http.py index 2e678a5c14..27814f6a99 100644 --- a/tests/shared/test_streamable_http.py +++ b/tests/shared/test_streamable_http.py @@ -656,6 +656,46 @@ async def send(message: Message) -> None: return next(message["status"] for message in sent if message["type"] == "http.response.start") +@pytest.mark.anyio +async def test_streamable_http_post_client_disconnect_is_not_reported_as_500( + caplog: pytest.LogCaptureFixture, +) -> None: + caplog.set_level(logging.ERROR, logger="mcp.server.streamable_http") + transport = StreamableHTTPServerTransport(mcp_session_id="valid-id") + assert transport.mcp_session_id is not None + scope: Scope = { + "type": "http", + "method": "POST", + "path": "/mcp", + "query_string": b"", + "headers": [ + (b"content-type", b"application/json"), + (b"accept", b"application/json, text/event-stream"), + (MCP_SESSION_ID_HEADER.encode(), transport.mcp_session_id.encode()), + ], + } + sent: list[Message] = [] + + async def receive() -> Message: + return {"type": "http.disconnect"} + + async def send(message: Message) -> None: + sent.append(message) + + async with transport.connect(): + with anyio.move_on_after(1) as cancel_scope: + await transport.handle_request(scope, receive, send) + + assert not cancel_scope.cancel_called + assert not sent + assert [ + record.getMessage() + for record in caplog.records + if record.name == "mcp.server.streamable_http" and record.levelno >= logging.ERROR + ] == [] + await transport.terminate() + + @pytest.mark.anyio async def test_transport_whose_idle_period_ran_out_answers_as_terminated() -> None: """Once the idle scope has fired, a request that still reaches the transport is answered 404 and the