Skip to content

Commit eb5b967

Browse files
committed
fix(server): handle POST body disconnects gracefully
1 parent a4f4ccd commit eb5b967

2 files changed

Lines changed: 47 additions & 2 deletions

File tree

src/mcp/server/streamable_http.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
from mcp_types.version import is_version_at_least
3737
from pydantic import ValidationError
3838
from sse_starlette import EventSourceResponse
39-
from starlette.requests import Request
39+
from starlette.requests import ClientDisconnect, Request
4040
from starlette.responses import Response
4141
from starlette.types import Receive, Scope, Send
4242

@@ -534,7 +534,11 @@ async def _handle_post_request(self, scope: Scope, request: Request, receive: Re
534534
return
535535

536536
# Parse the body - only read it once
537-
body = await request.body()
537+
try:
538+
body = await request.body()
539+
except ClientDisconnect:
540+
logger.debug("Client disconnected while sending POST request body")
541+
return
538542

539543
try:
540544
raw_message = pydantic_core.from_json(body)

tests/server/test_streamable_http_router.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"""Regression coverage for the StreamableHTTP per-session response router."""
22

3+
import logging
4+
35
import anyio
46
import pytest
57
from mcp_types import JSONRPCMessage, JSONRPCResponse
@@ -14,6 +16,7 @@
1416
StreamableHTTPServerTransport,
1517
StreamId,
1618
)
19+
from mcp.shared._context_streams import create_context_streams
1720
from mcp.shared.message import SessionMessage
1821

1922

@@ -44,6 +47,44 @@ async def send(self, message: Message) -> None:
4447
self.sent.append(message)
4548

4649

50+
class _AsgiDisconnect(_AsgiPost):
51+
"""A POST whose body stream disconnects before the declared body is complete."""
52+
53+
async def receive(self) -> Message:
54+
if not self._body_sent:
55+
self._body_sent = True
56+
return {"type": "http.request", "body": self._body, "more_body": True}
57+
return {"type": "http.disconnect"}
58+
59+
60+
@pytest.mark.anyio
61+
async def test_post_client_disconnect_is_not_reported_as_server_error(caplog: pytest.LogCaptureFixture) -> None:
62+
transport = StreamableHTTPServerTransport(mcp_session_id=None)
63+
post = _AsgiDisconnect(
64+
b'{"jsonrpc":"2.0",',
65+
[
66+
(b"accept", b"application/json, text/event-stream"),
67+
(b"content-type", b"application/json"),
68+
],
69+
)
70+
read_stream_writer, read_stream = create_context_streams[SessionMessage | Exception](1)
71+
transport._read_stream_writer = read_stream_writer
72+
73+
try:
74+
with caplog.at_level(logging.ERROR, logger="mcp.server.streamable_http"):
75+
await transport.handle_request(post.scope, post.receive, post.send)
76+
77+
await read_stream_writer.aclose()
78+
with pytest.raises(anyio.EndOfStream):
79+
await read_stream.receive()
80+
finally:
81+
await read_stream_writer.aclose()
82+
await read_stream.aclose()
83+
84+
assert post.sent == []
85+
assert not caplog.records
86+
87+
4788
@pytest.mark.anyio
4889
async def test_router_unconsumed_request_stream_does_not_block_siblings() -> None:
4990
"""A response whose `sse_writer` is not yet receiving must not park the router (#1764).

0 commit comments

Comments
 (0)