Skip to content

Commit 5458c58

Browse files
committed
fix: drain accepted JSON-RPC requests after read EOF
1 parent 8d0f928 commit 5458c58

2 files changed

Lines changed: 69 additions & 0 deletions

File tree

src/mcp/shared/jsonrpc_dispatcher.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,12 @@
6060
_SHUTDOWN_WRITE_TIMEOUT: float = 1
6161
"""Tighter bound for the shutdown-arm error write so a wedged transport can't hold session close."""
6262

63+
_DRAIN_INBOUND_ON_EOF_TIMEOUT: float = 5
64+
"""Bound for letting already-accepted inbound requests write responses after read EOF."""
65+
66+
_DRAIN_INBOUND_ON_EOF_POLL_INTERVAL: float = 0.01
67+
"""Polling interval while waiting for accepted inbound requests to finish."""
68+
6369
TransportT = TypeVar("TransportT", bound=TransportContext, default=TransportContext)
6470

6571
PeerCancelMode = Literal["interrupt", "signal"]
@@ -285,6 +291,7 @@ def __init__(
285291
self._next_id = 0
286292
self._pending: dict[RequestId, _Pending] = {}
287293
self._in_flight: dict[RequestId, _InFlight[TransportT]] = {}
294+
self._active_inbound_requests = 0
288295
self._tg: anyio.abc.TaskGroup | None = None
289296
self._running = False
290297
self._closed = False
@@ -471,6 +478,7 @@ async def run(
471478
self._running = False
472479
self._closed = True
473480
self._fan_out_closed()
481+
await self._drain_active_inbound_requests()
474482
finally:
475483
# Cancel in-flight handlers; otherwise the task-group join
476484
# waits on handlers whose callers are already gone.
@@ -545,6 +553,7 @@ async def _dispatch_request(
545553
_progress_token=progress_token,
546554
)
547555
scope = anyio.CancelScope()
556+
self._active_inbound_requests += 1
548557
# TODO(maxisbey): duplicate ids blind-overwrite (v1/TS parity); revisit
549558
# rejecting with INVALID_REQUEST. Key coerced so a stringified
550559
# `notifications/cancelled` id still correlates.
@@ -659,6 +668,24 @@ def _fan_out_closed(self) -> None:
659668
pass
660669
self._pending.clear()
661670

671+
async def _drain_active_inbound_requests(self) -> None:
672+
"""Let accepted inbound requests finish response writes after read EOF.
673+
674+
A redirected-stdin stdio transport can reach EOF immediately after the last
675+
request is accepted. Treating EOF as immediate shutdown cancels handlers
676+
before their JSON-RPC responses reach stdout. Keep the write side open
677+
briefly so already-accepted requests can produce responses, then let the
678+
caller cancel any stragglers.
679+
"""
680+
with anyio.move_on_after(_DRAIN_INBOUND_ON_EOF_TIMEOUT) as scope:
681+
while self._active_inbound_requests:
682+
await anyio.sleep(_DRAIN_INBOUND_ON_EOF_POLL_INTERVAL)
683+
if scope.cancelled_caught:
684+
logger.warning(
685+
"timed out waiting for %d inbound request(s) to finish after read EOF",
686+
self._active_inbound_requests,
687+
)
688+
662689
async def _handle_request(
663690
self,
664691
req: JSONRPCRequest,
@@ -722,6 +749,8 @@ async def _handle_request(
722749
await self._write_error(req.id, ErrorData(code=0, message=str(e)))
723750
if self._raise_handler_exceptions:
724751
raise
752+
finally:
753+
self._active_inbound_requests = max(0, self._active_inbound_requests - 1)
725754
# No `_in_flight` pop here: the inner finally covers every path, and a late pop could evict a reused id.
726755

727756
def _allocate_id(self) -> int:

tests/shared/test_jsonrpc_dispatcher.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,46 @@ async def caller() -> None:
252252
s.close()
253253

254254

255+
@pytest.mark.anyio
256+
async def test_read_eof_drains_accepted_inbound_request_response():
257+
"""Read EOF must not cancel a request that was already accepted.
258+
259+
This covers redirected-stdin stdio servers: EOF can arrive immediately after
260+
the final JSON-RPC request is read, while the tool handler still has an
261+
await point before its response write.
262+
"""
263+
c2s_send, c2s_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32)
264+
s2c_send, s2c_recv = anyio.create_memory_object_stream[SessionMessage](32)
265+
server: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(c2s_recv, s2c_send)
266+
handler_started = anyio.Event()
267+
268+
async def on_request(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]:
269+
handler_started.set()
270+
await anyio.sleep(0.05)
271+
return {"ok": True}
272+
273+
async def on_notify(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> None:
274+
pass
275+
276+
try:
277+
async with anyio.create_task_group() as tg:
278+
await tg.start(server.run, on_request, on_notify)
279+
await c2s_send.send(SessionMessage(message=JSONRPCRequest(jsonrpc="2.0", id=1, method="slow")))
280+
await handler_started.wait()
281+
282+
# Simulate stdin EOF after the request has been accepted but before
283+
# the handler has finished and written its response.
284+
c2s_send.close()
285+
286+
with anyio.fail_after(5):
287+
response = await s2c_recv.receive()
288+
assert response.message == JSONRPCResponse(jsonrpc="2.0", id=1, result={"ok": True})
289+
tg.cancel_scope.cancel()
290+
finally:
291+
for s in (c2s_send, c2s_recv, s2c_send, s2c_recv):
292+
s.close()
293+
294+
255295
@pytest.mark.anyio
256296
async def test_run_returns_cleanly_when_read_stream_receive_end_is_closed():
257297
"""Iterating a closed receive end is EOF, not a crash (stateless SHTTP closes it during teardown)."""

0 commit comments

Comments
 (0)