From ab6589cf2f4bcd71a89b1c81588d2854fbd72f63 Mon Sep 17 00:00:00 2001 From: Will James Date: Tue, 5 May 2026 13:38:44 -0500 Subject: [PATCH 1/2] fix(streamable_http): send response on ClientDisconnect to satisfy middleware chains When ClientDisconnect is caught in _handle_post_request, send a 202 Accepted response so that Starlette BaseHTTPMiddleware wrappers don't raise RuntimeError('No response returned'). The ASGI server will drop the response if the socket is already closed. Fixes ai-codegen-api 'No response returned' error when client disconnects mid-POST. The existing ClientDisconnectHandlerMiddleware can no longer intercept the early return since BaseHTTPMiddleware wraps above it. --- src/mcp/server/streamable_http.py | 7 +++++- .../test_client_disconnect_post.py | 22 ++++++++++++------- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/src/mcp/server/streamable_http.py b/src/mcp/server/streamable_http.py index 15328ccab..a8f4bc58f 100644 --- a/src/mcp/server/streamable_http.py +++ b/src/mcp/server/streamable_http.py @@ -646,12 +646,17 @@ 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()) + response = self._create_json_response(None, HTTPStatus.ACCEPTED) + 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..af73b7ffa 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 202 + assert send_calls[0]["type"] == "http.response.start" + assert send_calls[0]["status"] == 202 @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"] == 202 From 8284879f645b06847a898773a0cfb0699eb645c5 Mon Sep 17 00:00:00 2001 From: Will James Date: Tue, 5 May 2026 14:09:32 -0500 Subject: [PATCH 2/2] fix(streamable_http): use 499 (Client Closed Request) instead of 202 for ClientDisconnect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 499 is the nginx convention for 'Client Closed Request' — more semantically correct than 202 Accepted for a cancelled request. --- src/mcp/server/streamable_http.py | 3 ++- tests/server/streamable_http/test_client_disconnect_post.py | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/mcp/server/streamable_http.py b/src/mcp/server/streamable_http.py index a8f4bc58f..72f88af44 100644 --- a/src/mcp/server/streamable_http.py +++ b/src/mcp/server/streamable_http.py @@ -654,7 +654,8 @@ async def sse_writer(): if writer is not None: with suppress(Exception): await writer.send(ClientDisconnect()) - response = self._create_json_response(None, HTTPStatus.ACCEPTED) + # 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 diff --git a/tests/server/streamable_http/test_client_disconnect_post.py b/tests/server/streamable_http/test_client_disconnect_post.py index af73b7ffa..cc5d5062c 100644 --- a/tests/server/streamable_http/test_client_disconnect_post.py +++ b/tests/server/streamable_http/test_client_disconnect_post.py @@ -121,9 +121,9 @@ async def dummy_send(message): 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 202 + # 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"] == 202 + assert send_calls[0]["status"] == 499 @pytest.mark.anyio async def test_client_disconnect_notifies_writer(self): @@ -202,4 +202,4 @@ async def dummy_send(message): # 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"] == 202 + assert send_calls[0]["status"] == 499