Skip to content
Open
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: 6 additions & 2 deletions src/mcp/server/streamable_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down
41 changes: 41 additions & 0 deletions tests/server/test_streamable_http_router.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -14,6 +16,7 @@
StreamableHTTPServerTransport,
StreamId,
)
from mcp.shared._context_streams import create_context_streams
from mcp.shared.message import SessionMessage


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