From eb5b96793e60ba15bef46bf3530d21e84d246017 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97SO?= <142557582+Linxiushen@users.noreply.github.com> Date: Wed, 5 Aug 2026 01:35:09 +0800 Subject: [PATCH] fix(server): handle POST body disconnects gracefully --- src/mcp/server/streamable_http.py | 8 +++- tests/server/test_streamable_http_router.py | 41 +++++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/mcp/server/streamable_http.py b/src/mcp/server/streamable_http.py index 1a4e9939a4..ce8d63a840 100644 --- a/src/mcp/server/streamable_http.py +++ b/src/mcp/server/streamable_http.py @@ -36,7 +36,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 @@ -534,7 +534,11 @@ async def _handle_post_request(self, scope: Scope, request: Request, receive: Re return # Parse the body - only read it once - body = await request.body() + try: + body = await request.body() + except ClientDisconnect: + logger.debug("Client disconnected while sending POST request body") + return try: raw_message = pydantic_core.from_json(body) diff --git a/tests/server/test_streamable_http_router.py b/tests/server/test_streamable_http_router.py index 07aa063499..3dbe61130a 100644 --- a/tests/server/test_streamable_http_router.py +++ b/tests/server/test_streamable_http_router.py @@ -1,5 +1,7 @@ """Regression coverage for the StreamableHTTP per-session response router.""" +import logging + import anyio import pytest from mcp_types import JSONRPCMessage, JSONRPCResponse @@ -14,6 +16,7 @@ StreamableHTTPServerTransport, StreamId, ) +from mcp.shared._context_streams import create_context_streams from mcp.shared.message import SessionMessage @@ -44,6 +47,44 @@ async def send(self, message: Message) -> None: self.sent.append(message) +class _AsgiDisconnect(_AsgiPost): + """A POST whose body stream disconnects before the declared body is complete.""" + + async def receive(self) -> Message: + if not self._body_sent: + self._body_sent = True + return {"type": "http.request", "body": self._body, "more_body": True} + return {"type": "http.disconnect"} + + +@pytest.mark.anyio +async def test_post_client_disconnect_is_not_reported_as_server_error(caplog: pytest.LogCaptureFixture) -> None: + transport = StreamableHTTPServerTransport(mcp_session_id=None) + post = _AsgiDisconnect( + b'{"jsonrpc":"2.0",', + [ + (b"accept", b"application/json, text/event-stream"), + (b"content-type", b"application/json"), + ], + ) + read_stream_writer, read_stream = create_context_streams[SessionMessage | Exception](1) + transport._read_stream_writer = read_stream_writer + + try: + with caplog.at_level(logging.ERROR, logger="mcp.server.streamable_http"): + await transport.handle_request(post.scope, post.receive, post.send) + + await read_stream_writer.aclose() + with pytest.raises(anyio.EndOfStream): + await read_stream.receive() + finally: + await read_stream_writer.aclose() + await read_stream.aclose() + + assert post.sent == [] + assert not caplog.records + + @pytest.mark.anyio async def test_router_unconsumed_request_stream_does_not_block_siblings() -> None: """A response whose `sse_writer` is not yet receiving must not park the router (#1764).