Skip to content

Commit a0d6233

Browse files
committed
Address review round three: EOF tolerance, docstring, refusal wording
- Treat a read stream whose receive end the transport closed as end-of-input in the opening peek and the relay, mirroring the tolerance the dispatcher's read loop already had; the read loop moved here, so its EOF handling moves with it. Two tests close the stream before and after the first request and expect a clean return. - ListenHandler's docstring no longer claims it needs SSE mode. - The legacy refusal states the era rule instead of narrating what the first request was, so it holds for every opener.
1 parent 46c04ba commit a0d6233

3 files changed

Lines changed: 55 additions & 16 deletions

File tree

src/mcp/server/runner.py

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -677,22 +677,30 @@ async def replay_then_relay() -> None:
677677
async with replay_send:
678678
for envelope in lead:
679679
await replay_send.send(envelope)
680-
async for item in read_stream:
681-
await replay_send.send((_sender_context(read_stream), item))
680+
try:
681+
async for item in read_stream:
682+
await replay_send.send((_sender_context(read_stream), item))
683+
except anyio.ClosedResourceError:
684+
# Receive end closed under us (stateless SHTTP teardown); same as EOF.
685+
logger.debug("read stream closed by transport; treating as EOF")
682686

683687
# This helper takes ownership of `read_stream` from the serving loop, so
684688
# every exit - including cancellation while awaiting the first request -
685689
# closes it and the replay channel.
686690
try:
687-
async for item in read_stream:
688-
if isinstance(item, SessionMessage) and isinstance(item.message, JSONRPCRequest):
689-
opening_request = item.message
690-
elif len(lead) >= _PRE_REQUEST_REPLAY_LIMIT:
691-
logger.debug("dropped a frame received before the first request: %r", item)
692-
continue
693-
lead.append((_sender_context(read_stream), item))
694-
if opening_request is not None:
695-
break
691+
try:
692+
async for item in read_stream:
693+
if isinstance(item, SessionMessage) and isinstance(item.message, JSONRPCRequest):
694+
opening_request = item.message
695+
elif len(lead) >= _PRE_REQUEST_REPLAY_LIMIT:
696+
logger.debug("dropped a frame received before the first request: %r", item)
697+
continue
698+
lead.append((_sender_context(read_stream), item))
699+
if opening_request is not None:
700+
break
701+
except anyio.ClosedResourceError:
702+
# Receive end closed under us (stateless SHTTP teardown); same as EOF.
703+
logger.debug("read stream closed by transport; treating as EOF")
696704
async with anyio.create_task_group() as tg:
697705
tg.start_soon(replay_then_relay)
698706
yield opening_request, replayed
@@ -730,8 +738,8 @@ async def on_request(
730738
if method != "initialize" and _has_modern_envelope(params):
731739
raise MCPError(
732740
code=INVALID_REQUEST,
733-
message="this connection's first request carried no 2026-07-28 envelope, so it "
734-
"serves the handshake era; enveloped requests are not accepted on it",
741+
message="this connection serves the handshake protocol era; "
742+
"requests carrying the 2026-07-28 envelope are not accepted on it",
735743
)
736744
return await runner.on_request(dctx, method, params)
737745

src/mcp/server/subscriptions.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -164,8 +164,8 @@ class ListenHandler:
164164
cancels the handler; the stream just ends, per the spec's abrupt-close
165165
contract) or `close` ends all streams gracefully.
166166
167-
Requires a transport that can stream a request's response (streamable
168-
HTTP's SSE mode).
167+
Served on any transport that can carry the request's response stream:
168+
streamable HTTP's SSE mode, or a duplex stream pair such as stdio.
169169
170170
`max_subscriptions` bounds concurrent streams (further listen requests are
171171
rejected with `INTERNAL_ERROR`, before the ack). `max_buffered_events`

tests/server/test_runner.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
import anyio
1818
import anyio.abc
19+
import anyio.lowlevel
1920
import pytest
2021
from mcp_types import (
2122
CLIENT_CAPABILITIES_META_KEY,
@@ -1544,7 +1545,7 @@ async def test_dual_era_loop_initialize_locks_legacy_and_rejects_modern_traffic(
15441545
assert result["tools"][0]["name"] == "t"
15451546
assert discover_exc.value.error.code == INVALID_REQUEST
15461547
assert envelope_exc.value.error.code == INVALID_REQUEST
1547-
assert "carried no 2026-07-28 envelope" in envelope_exc.value.error.message
1548+
assert "serves the handshake protocol era" in envelope_exc.value.error.message
15481549

15491550

15501551
@pytest.mark.anyio
@@ -2019,6 +2020,36 @@ async def test_dual_era_loop_leading_notifications_never_decide_the_era(server:
20192020
assert result["tools"][0]["name"] == "t"
20202021

20212022

2023+
@pytest.mark.anyio
2024+
async def test_dual_era_loop_treats_a_read_stream_closed_before_the_first_request_as_end_of_input(server: SrvT):
2025+
"""A transport closing the read stream's receive end (the stateless teardown
2026+
pattern) ends the loop like end-of-input rather than surfacing a
2027+
closed-resource error - here before the client has sent anything."""
2028+
c2s_send, c2s_recv = anyio.create_memory_object_stream[SessionMessage | Exception](8)
2029+
s2c_send, s2c_recv = anyio.create_memory_object_stream[SessionMessage | Exception](8)
2030+
with anyio.fail_after(5):
2031+
async with anyio.create_task_group() as tg, c2s_send, s2c_recv:
2032+
tg.start_soon(partial(serve_dual_era_loop, server, c2s_recv, s2c_send, lifespan_state=_LIFESPAN))
2033+
await anyio.lowlevel.checkpoint()
2034+
c2s_recv.close() # the transport tears down under the waiting loop
2035+
2036+
2037+
@pytest.mark.anyio
2038+
async def test_dual_era_loop_treats_a_read_stream_closed_mid_connection_as_end_of_input(server: SrvT):
2039+
"""The same teardown after the connection is open ends the era loop
2040+
cleanly: the relay behind the opening request treats the closed receive
2041+
end as end-of-input."""
2042+
c2s_send, c2s_recv = anyio.create_memory_object_stream[SessionMessage | Exception](8)
2043+
s2c_send, s2c_recv = anyio.create_memory_object_stream[SessionMessage | Exception](8)
2044+
ping = JSONRPCRequest(jsonrpc="2.0", id=1, method="ping", params=None)
2045+
with anyio.fail_after(5):
2046+
async with anyio.create_task_group() as tg, c2s_send, s2c_recv:
2047+
tg.start_soon(partial(serve_dual_era_loop, server, c2s_recv, s2c_send, lifespan_state=_LIFESPAN))
2048+
await c2s_send.send(SessionMessage(message=ping))
2049+
await s2c_recv.receive() # the connection is open and answered
2050+
c2s_recv.close()
2051+
2052+
20222053
@pytest.mark.anyio
20232054
async def test_dual_era_client_propagates_body_exception_unwrapped(server: SrvT):
20242055
"""The harness re-raises body exceptions as-is, not as `ExceptionGroup`."""

0 commit comments

Comments
 (0)