Skip to content

Commit c516350

Browse files
committed
Drop request-scoped messages a JSON response cannot carry at the router
In JSON-response mode a message tied to an in-flight request has no wire form and no POST could replay it, so message_router now drops it before storing or queueing rather than leaving it for a consumer to discard. The per-request queue then carries exactly the one response, and the JSON POST handler collapses from a discard loop to a single receive with a real arm for a session torn down mid-request. This removes the four coverage pragmas the discard loop needed.
1 parent 9cbbd73 commit c516350

3 files changed

Lines changed: 96 additions & 32 deletions

File tree

src/mcp/server/streamable_http.py

Lines changed: 25 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -584,43 +584,24 @@ async def _handle_post_request(self, scope: Scope, request: Request, receive: Re
584584
session_message = SessionMessage(message, metadata=metadata)
585585
await writer.send(session_message)
586586
try:
587-
# Process messages from the request-specific stream
588-
# We need to collect all messages until we get a response
589-
response_message = None
590-
591-
# Use similar approach to SSE writer for consistency
592-
async for event_message in request_stream_reader: # pragma: no branch
593-
# If it's a response, this is what we're waiting for
594-
if isinstance(event_message.message, JSONRPCResponse | JSONRPCError):
595-
response_message = event_message.message
596-
break
597-
# For notifications and requests, keep waiting
598-
else: # pragma: no cover
599-
logger.debug(f"received: {event_message.message.method}")
600-
601-
# At this point we should have a response
602-
if response_message:
603-
# Create JSON response
604-
response = self._create_json_response(response_message)
605-
await response(scope, receive, send)
606-
else: # pragma: no cover
607-
# This shouldn't happen in normal operation
608-
logger.error("No response message received before stream closed")
609-
response = self._create_error_response(
610-
"Error processing request: No response received",
611-
HTTPStatus.INTERNAL_SERVER_ERROR,
612-
)
613-
await response(scope, receive, send)
614-
except Exception: # pragma: no cover
615-
logger.exception("Error processing JSON response")
587+
# `message_router` deposits only this request's own response
588+
# here: anything else scoped to the request has no wire in
589+
# JSON-response mode.
590+
event_message = await request_stream_reader.receive()
591+
except (anyio.EndOfStream, anyio.ClosedResourceError):
592+
# The stream closed with no response: the session was
593+
# terminated while this request was in flight.
594+
logger.debug(f"Session terminated with request {request_id} in flight; no response to send")
616595
response = self._create_error_response(
617-
"Error processing request",
596+
"Session terminated before the request completed",
618597
HTTPStatus.INTERNAL_SERVER_ERROR,
619598
INTERNAL_ERROR,
620599
)
621-
await response(scope, receive, send)
600+
else:
601+
response = self._create_json_response(event_message.message)
622602
finally:
623603
await self._clean_up_memory_streams(request_id)
604+
await response(scope, receive, send)
624605
else:
625606
# Mint the priming event before any per-request state exists:
626607
# `EventStore.store_event` is user code and may raise, in which
@@ -1021,7 +1002,19 @@ async def message_router():
10211002
)
10221003
and session_message.metadata.related_request_id is not None
10231004
):
1024-
target_request_id = str(session_message.metadata.related_request_id)
1005+
related_request_id = session_message.metadata.related_request_id
1006+
if self.is_json_response_enabled:
1007+
# A JSON body carries exactly the one response: a
1008+
# message that only rides this request's stream
1009+
# has no wire form (and no POST could replay it),
1010+
# so drop it before storing or queueing - never
1011+
# park it in a queue nothing drains (#1764).
1012+
logger.debug(
1013+
f"Dropped message related to request {related_request_id}: "
1014+
"a JSON response carries only its own response"
1015+
)
1016+
continue
1017+
target_request_id = str(related_request_id)
10251018

10261019
request_stream_id = target_request_id if target_request_id is not None else GET_STREAM_KEY
10271020

tests/interaction/transports/test_streamable_http.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,35 @@ async def test_json_response_streamable_http_rejects_request_scoped_server_reque
119119
assert exc_info.value.error.code == INVALID_REQUEST
120120

121121

122+
@requirement("transport:streamable-http:json-response-restrictions")
123+
@requirement("transport:streamable-http:unrelated-messages")
124+
@requirement("hosting:http:standalone-sse")
125+
async def test_json_response_streamable_http_delivers_only_unrelated_notifications() -> None:
126+
"""In JSON-response mode the call's own log notification has no stream to ride and never
127+
reaches the client, while the tool result comes back as the JSON body and the unrelated
128+
resource-updated notification arrives on the standalone stream. The handler writes both
129+
notifications before returning, so once the result and the unrelated message are in, no
130+
request-scoped message can still be in flight."""
131+
received: list[IncomingMessage] = []
132+
server_message_seen = anyio.Event()
133+
134+
async def collect(message: IncomingMessage) -> None:
135+
received.append(message)
136+
server_message_seen.set()
137+
138+
async with connect_over_streamable_http(_smoke_server(), json_response=True, message_handler=collect) as client:
139+
with anyio.fail_after(5):
140+
result = await client.call_tool("announce", {})
141+
await server_message_seen.wait()
142+
143+
assert result == snapshot(
144+
CallToolResult(content=[TextContent(text="announced")], structured_content={"result": "announced"})
145+
)
146+
assert received == snapshot(
147+
[ResourceUpdatedNotification(params=ResourceUpdatedNotificationParams(uri="file:///watched.txt"))]
148+
)
149+
150+
122151
@requirement("transport:streamable-http:notifications")
123152
@requirement("transport:streamable-http:unrelated-messages")
124153
@requirement("hosting:http:standalone-sse")

tests/server/test_streamable_http_router.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,48 @@ async def asgi_send(message: Message) -> None:
118118
assert b"backend unavailable" not in body
119119

120120

121+
@pytest.mark.anyio
122+
async def test_json_post_answers_500_when_session_terminates_mid_request() -> None:
123+
"""A JSON-mode POST whose session is torn down before the handler answers gets a 500, not a stall."""
124+
transport = StreamableHTTPServerTransport(mcp_session_id="sid", is_json_response_enabled=True)
125+
body = b'{"jsonrpc":"2.0","id":"req-1","method":"tools/list","params":{}}'
126+
scope: Scope = {
127+
"type": "http",
128+
"method": "POST",
129+
"path": "/",
130+
"query_string": b"",
131+
"headers": [
132+
(b"accept", b"application/json"),
133+
(b"content-type", b"application/json"),
134+
(b"mcp-session-id", b"sid"),
135+
(b"mcp-protocol-version", b"2025-11-25"),
136+
],
137+
}
138+
body_sent = False
139+
140+
async def receive() -> Message:
141+
nonlocal body_sent
142+
if not body_sent:
143+
body_sent = True
144+
return {"type": "http.request", "body": body, "more_body": False}
145+
raise NotImplementedError
146+
147+
sent: list[Message] = []
148+
149+
async def asgi_send(message: Message) -> None:
150+
sent.append(message)
151+
152+
async with transport.connect() as (read_stream, _write_stream):
153+
async with anyio.create_task_group() as tg:
154+
tg.start_soon(transport.handle_request, scope, receive, asgi_send)
155+
with anyio.fail_after(5):
156+
await read_stream.receive() # the request reached the session; the POST is parked
157+
await transport.terminate()
158+
159+
assert sent[0]["type"] == "http.response.start"
160+
assert sent[0]["status"] == 500
161+
162+
121163
def test_transport_context_reports_response_mode_and_request_headers() -> None:
122164
"""The transport's own verdict: no request-scoped requests in JSON mode, headers off the carried Request."""
123165
json_transport = StreamableHTTPServerTransport(mcp_session_id="sid", is_json_response_enabled=True)

0 commit comments

Comments
 (0)