Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion src/mcp/server/streamable_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
22 changes: 14 additions & 8 deletions tests/server/streamable_http/test_client_disconnect_post.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
Loading