Skip to content

Commit e20be4d

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 ffcc057 commit e20be4d

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
@@ -612,43 +612,24 @@ async def _handle_post_request(self, scope: Scope, request: Request, receive: Re
612612
session_message = SessionMessage(message, metadata=metadata)
613613
await writer.send(session_message)
614614
try:
615-
# Process messages from the request-specific stream
616-
# We need to collect all messages until we get a response
617-
response_message = None
618-
619-
# Use similar approach to SSE writer for consistency
620-
async for event_message in request_stream_reader: # pragma: no branch
621-
# If it's a response, this is what we're waiting for
622-
if isinstance(event_message.message, JSONRPCResponse | JSONRPCError):
623-
response_message = event_message.message
624-
break
625-
# For notifications and requests, keep waiting
626-
else: # pragma: no cover
627-
logger.debug(f"received: {event_message.message.method}")
628-
629-
# At this point we should have a response
630-
if response_message:
631-
# Create JSON response
632-
response = self._create_json_response(response_message)
633-
await response(scope, receive, send)
634-
else: # pragma: no cover
635-
# This shouldn't happen in normal operation
636-
logger.error("No response message received before stream closed")
637-
response = self._create_error_response(
638-
"Error processing request: No response received",
639-
HTTPStatus.INTERNAL_SERVER_ERROR,
640-
)
641-
await response(scope, receive, send)
642-
except Exception: # pragma: no cover
643-
logger.exception("Error processing JSON response")
615+
# `message_router` deposits only this request's own response
616+
# here: anything else scoped to the request has no wire in
617+
# JSON-response mode.
618+
event_message = await request_stream_reader.receive()
619+
except (anyio.EndOfStream, anyio.ClosedResourceError):
620+
# The stream closed with no response: the session was
621+
# terminated while this request was in flight.
622+
logger.debug(f"Session terminated with request {request_id} in flight; no response to send")
644623
response = self._create_error_response(
645-
"Error processing request",
624+
"Session terminated before the request completed",
646625
HTTPStatus.INTERNAL_SERVER_ERROR,
647626
INTERNAL_ERROR,
648627
)
649-
await response(scope, receive, send)
628+
else:
629+
response = self._create_json_response(event_message.message)
650630
finally:
651631
await self._clean_up_memory_streams(request_id)
632+
await response(scope, receive, send)
652633
else:
653634
# Mint the priming event before any per-request state exists:
654635
# `EventStore.store_event` is user code and may raise, in which
@@ -1049,7 +1030,19 @@ async def message_router():
10491030
)
10501031
and session_message.metadata.related_request_id is not None
10511032
):
1052-
target_request_id = str(session_message.metadata.related_request_id)
1033+
related_request_id = session_message.metadata.related_request_id
1034+
if self.is_json_response_enabled:
1035+
# A JSON body carries exactly the one response: a
1036+
# message that only rides this request's stream
1037+
# has no wire form (and no POST could replay it),
1038+
# so drop it before storing or queueing - never
1039+
# park it in a queue nothing drains (#1764).
1040+
logger.debug(
1041+
f"Dropped message related to request {related_request_id}: "
1042+
"a JSON response carries only its own response"
1043+
)
1044+
continue
1045+
target_request_id = str(related_request_id)
10531046

10541047
request_stream_id = target_request_id if target_request_id is not None else GET_STREAM_KEY
10551048

tests/interaction/transports/test_streamable_http.py

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

136136

137+
@requirement("transport:streamable-http:json-response-restrictions")
138+
@requirement("transport:streamable-http:unrelated-messages")
139+
@requirement("hosting:http:standalone-sse")
140+
async def test_json_response_streamable_http_delivers_only_unrelated_notifications() -> None:
141+
"""In JSON-response mode the call's own log notification has no stream to ride and never
142+
reaches the client, while the tool result comes back as the JSON body and the unrelated
143+
resource-updated notification arrives on the standalone stream. The handler writes both
144+
notifications before returning, so once the result and the unrelated message are in, no
145+
request-scoped message can still be in flight."""
146+
received: list[IncomingMessage] = []
147+
server_message_seen = anyio.Event()
148+
149+
async def collect(message: IncomingMessage) -> None:
150+
received.append(message)
151+
server_message_seen.set()
152+
153+
async with connect_over_streamable_http(_smoke_server(), json_response=True, message_handler=collect) as client:
154+
with anyio.fail_after(5):
155+
result = await client.call_tool("announce", {})
156+
await server_message_seen.wait()
157+
158+
assert result == snapshot(
159+
CallToolResult(content=[TextContent(text="announced")], structured_content={"result": "announced"})
160+
)
161+
assert received == snapshot(
162+
[ResourceUpdatedNotification(params=ResourceUpdatedNotificationParams(uri="file:///watched.txt"))]
163+
)
164+
165+
137166
@requirement("transport:streamable-http:notifications")
138167
@requirement("transport:streamable-http:unrelated-messages")
139168
@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)