diff --git a/src/mcp/server/streamable_http.py b/src/mcp/server/streamable_http.py index 15328ccab..72f88af44 100644 --- a/src/mcp/server/streamable_http.py +++ b/src/mcp/server/streamable_http.py @@ -646,12 +646,18 @@ async def sse_writer(): except ClientDisconnect: # Client went away mid-request (network timeout, cancel, LB drop). Not a - # server error — log at WARNING and skip the response: the socket is gone. + # server error — log at WARNING and send a response so middleware chains + # (e.g. Starlette BaseHTTPMiddleware) don't raise "No response returned". + # The ASGI server will drop the response if the socket is already closed. # Notify the writer so the inner session task can unblock cleanly. logger.warning("Client disconnected during POST request") if writer is not None: with suppress(Exception): await writer.send(ClientDisconnect()) + # 499 = Client Closed Request (nginx convention, not in stdlib HTTPStatus) + response = self._create_json_response(None, 499) # type: ignore[arg-type] + with suppress(Exception): + await response(scope, receive, send) return except Exception as err: # pragma: no cover logger.exception("Error handling POST request") diff --git a/tests/server/streamable_http/test_client_disconnect_post.py b/tests/server/streamable_http/test_client_disconnect_post.py index 0e2b9f4de..cc5d5062c 100644 --- a/tests/server/streamable_http/test_client_disconnect_post.py +++ b/tests/server/streamable_http/test_client_disconnect_post.py @@ -86,8 +86,9 @@ async def dummy_send(message): ) @pytest.mark.anyio - async def test_client_disconnect_does_not_send_response(self): - """After ClientDisconnect, no response should be sent (socket is closed).""" + async def test_client_disconnect_sends_response(self): + """After ClientDisconnect, a 202 response is sent so middleware chains don't + raise 'No response returned' (ASGI server drops it if socket is closed).""" transport = StreamableHTTPServerTransport(mcp_session_id=None) scope = self._make_scope() @@ -116,10 +117,13 @@ async def dummy_send(message): scope, mock_request, dummy_receive, dummy_send ) - # No HTTP response should be sent to the closed socket - assert len(send_calls) == 0, ( - f"Expected no ASGI sends after ClientDisconnect, got {len(send_calls)}" + # A response IS sent (202 Accepted) so middleware chains don't blow up + assert len(send_calls) >= 1, ( + f"Expected at least 1 ASGI send (response), got {len(send_calls)}" ) + # First send should be http.response.start with 499 (Client Closed Request) + assert send_calls[0]["type"] == "http.response.start" + assert send_calls[0]["status"] == 499 @pytest.mark.anyio async def test_client_disconnect_notifies_writer(self): @@ -193,7 +197,9 @@ async def dummy_send(message): scope, mock_request, dummy_receive, dummy_send ) - # The broken writer.send was called once + # The broken writer.send was called once (suppressed) broken_send.assert_called_once() - # No response was sent (socket is closed) - assert len(send_calls) == 0 + # Response is still sent even though writer was broken + assert len(send_calls) >= 1 + assert send_calls[0]["type"] == "http.response.start" + assert send_calls[0]["status"] == 499