Skip to content

Commit 6e1d1b0

Browse files
fix(stdio): drain accepted responses before EOF shutdown
Signed-off-by: ace-trump-tech <ace-trump-tech@users.noreply.github.com>
1 parent d2290ca commit 6e1d1b0

5 files changed

Lines changed: 90 additions & 5 deletions

File tree

src/mcp/server/lowlevel/server.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -699,6 +699,7 @@ async def run(
699699
# but also make tracing exceptions much easier during testing and when using
700700
# in-process servers.
701701
raise_exceptions: bool = False,
702+
graceful_shutdown_timeout: float = 0,
702703
) -> None:
703704
"""Serve a single connection over the given streams until the read side closes.
704705
@@ -716,6 +717,7 @@ async def run(
716717
lifespan_state=lifespan_context,
717718
init_options=initialization_options,
718719
raise_exceptions=raise_exceptions,
720+
graceful_shutdown_timeout=graceful_shutdown_timeout,
719721
)
720722

721723
def streamable_http_app(

src/mcp/server/mcpserver/server.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1069,6 +1069,10 @@ async def run_stdio_async(self) -> None:
10691069
read_stream,
10701070
write_stream,
10711071
self._lowlevel_server.create_initialization_options(),
1072+
# File-redirected stdin can reach EOF while accepted tool
1073+
# handlers are still producing responses. Give those writes
1074+
# a bounded drain window before cancelling the connection.
1075+
graceful_shutdown_timeout=0.5,
10721076
)
10731077

10741078
async def run_sse_async( # pragma: no cover

src/mcp/server/runner.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -608,6 +608,7 @@ async def serve_dual_era_loop(
608608
session_id: str | None = None,
609609
init_options: InitializationOptions | None = None,
610610
raise_exceptions: bool = False,
611+
graceful_shutdown_timeout: float = 0,
611612
) -> None:
612613
"""Drive `server` over a duplex stream pair, in the era the client opens with.
613614
@@ -630,7 +631,8 @@ async def serve_dual_era_loop(
630631
)
631632
if opens_modern:
632633
await _serve_modern_stream(
633-
server, replayed, write_stream, lifespan_state=lifespan_state, raise_exceptions=raise_exceptions
634+
server, replayed, write_stream, lifespan_state=lifespan_state,
635+
raise_exceptions=raise_exceptions, graceful_shutdown_timeout=graceful_shutdown_timeout
634636
)
635637
else:
636638
await _serve_legacy_stream(
@@ -641,6 +643,7 @@ async def serve_dual_era_loop(
641643
session_id=session_id,
642644
init_options=init_options,
643645
raise_exceptions=raise_exceptions,
646+
graceful_shutdown_timeout=graceful_shutdown_timeout,
644647
)
645648
finally:
646649
await write_stream.aclose()
@@ -723,6 +726,7 @@ async def _serve_legacy_stream(
723726
session_id: str | None,
724727
init_options: InitializationOptions | None,
725728
raise_exceptions: bool,
729+
graceful_shutdown_timeout: float = 0,
726730
) -> None:
727731
"""Serve a 2025 handshake connection; enveloped requests are refused."""
728732
dispatcher: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(
@@ -747,7 +751,11 @@ async def on_request(
747751
return await runner.on_request(dctx, method, params)
748752

749753
try:
750-
await dispatcher.run(on_request, runner.on_notify)
754+
await dispatcher.run(
755+
on_request,
756+
runner.on_notify,
757+
graceful_shutdown_timeout=graceful_shutdown_timeout,
758+
)
751759
finally:
752760
await aclose_shielded(connection)
753761

@@ -759,6 +767,7 @@ async def _serve_modern_stream(
759767
*,
760768
lifespan_state: LifespanT,
761769
raise_exceptions: bool,
770+
graceful_shutdown_timeout: float = 0,
762771
) -> None:
763772
"""Serve a 2026-07-28 connection: every request carries its own envelope."""
764773
dispatcher: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(
@@ -809,7 +818,11 @@ async def on_notify(dctx: DispatchContext[TransportContext], method: str, params
809818
finally:
810819
await aclose_shielded(connection)
811820

812-
await dispatcher.run(on_request, on_notify)
821+
await dispatcher.run(
822+
on_request,
823+
on_notify,
824+
graceful_shutdown_timeout=graceful_shutdown_timeout,
825+
)
813826

814827

815828
async def serve_one(

src/mcp/shared/jsonrpc_dispatcher.py

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,7 @@ def __init__(
307307
self._next_id = 0
308308
self._pending: dict[RequestId, _Pending] = {}
309309
self._in_flight: dict[RequestId, _InFlight[TransportT]] = {}
310+
self._active_requests: set[anyio.Event] = set()
310311
self._on_notify_intercept: OnNotifyIntercept | None = None
311312
self._tg: anyio.abc.TaskGroup | None = None
312313
self._running = False
@@ -480,12 +481,15 @@ async def run(
480481
on_notify_intercept: OnNotifyIntercept | None = None,
481482
*,
482483
task_status: anyio.abc.TaskStatus[None] = anyio.TASK_STATUS_IGNORED,
484+
graceful_shutdown_timeout: float = 0,
483485
) -> None:
484486
"""Drive the receive loop until the read stream closes.
485487
486488
`task_status.started()` fires once `send_raw_request` is usable.
487489
Single-shot: once the loop ends the dispatcher stays closed and cannot be restarted.
488490
"""
491+
if graceful_shutdown_timeout < 0:
492+
raise ValueError("graceful_shutdown_timeout must be non-negative")
489493
self._on_notify_intercept = on_notify_intercept
490494
try:
491495
# LIFO exits: the write stream closes only after the task-group join, so teardown writes still land.
@@ -511,6 +515,9 @@ async def run(
511515
self._running = False
512516
self._closed = True
513517
self._fan_out_closed()
518+
if graceful_shutdown_timeout and self._active_requests:
519+
with anyio.move_on_after(graceful_shutdown_timeout):
520+
await self._wait_for_active_requests()
514521
finally:
515522
# Cancel in-flight handlers; otherwise the task-group join
516523
# waits on handlers whose callers are already gone.
@@ -523,6 +530,15 @@ async def run(
523530
self._fan_out_closed()
524531
await resync_tracer()
525532

533+
async def _wait_for_active_requests(self) -> None:
534+
"""Wait for requests already accepted from a transport before cancellation."""
535+
events = tuple(self._active_requests)
536+
if not events:
537+
return
538+
async with anyio.create_task_group() as tg:
539+
for event in events:
540+
tg.start_soon(event.wait)
541+
526542
async def _dispatch(
527543
self,
528544
item: SessionMessage | Exception,
@@ -585,6 +601,8 @@ async def _dispatch_request(
585601
_progress_token=progress_token,
586602
)
587603
scope = anyio.CancelScope()
604+
completion = anyio.Event()
605+
self._active_requests.add(completion)
588606
# TODO(maxisbey): duplicate ids blind-overwrite (v1/TS parity); revisit
589607
# rejecting with INVALID_REQUEST. Key coerced so a stringified
590608
# `notifications/cancelled` id still correlates.
@@ -596,14 +614,28 @@ async def _dispatch_request(
596614

597615
async def _run_inline() -> None:
598616
try:
599-
await self._handle_request(req, dctx, scope, on_request)
617+
await self._run_request(req, dctx, scope, on_request, completion)
600618
finally:
601619
done.set()
602620

603621
self._spawn(_run_inline, sender_ctx=sender_ctx)
604622
await done.wait()
605623
else:
606-
self._spawn(self._handle_request, req, dctx, scope, on_request, sender_ctx=sender_ctx)
624+
self._spawn(self._run_request, req, dctx, scope, on_request, completion, sender_ctx=sender_ctx)
625+
626+
async def _run_request(
627+
self,
628+
req: JSONRPCRequest,
629+
dctx: _JSONRPCDispatchContext[TransportT],
630+
scope: anyio.CancelScope,
631+
on_request: OnRequest,
632+
completion: anyio.Event,
633+
) -> None:
634+
try:
635+
await self._handle_request(req, dctx, scope, on_request)
636+
finally:
637+
self._active_requests.discard(completion)
638+
completion.set()
607639

608640
def _dispatch_notification(
609641
self,

tests/shared/test_jsonrpc_dispatcher.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,40 @@ async def drive() -> None:
369369
s2c_recv.close()
370370

371371

372+
@pytest.mark.anyio
373+
async def test_run_can_drain_in_flight_handlers_before_eof_shutdown():
374+
"""A stdio-style EOF may follow a piped request; accepted responses get a bounded drain window."""
375+
c2s_send, c2s_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32)
376+
s2c_send, s2c_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32)
377+
server: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(c2s_recv, s2c_send)
378+
handler_started = anyio.Event()
379+
380+
async def slow(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]:
381+
handler_started.set()
382+
await anyio.sleep(0.01)
383+
return {"ok": True}
384+
385+
async def on_notify(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> None:
386+
raise NotImplementedError
387+
388+
async def drive() -> None:
389+
await server.run(slow, on_notify, graceful_shutdown_timeout=0.5)
390+
391+
async with anyio.create_task_group() as tg:
392+
tg.start_soon(drive)
393+
await c2s_send.send(SessionMessage(message=JSONRPCRequest(jsonrpc="2.0", id=1, method="slow")))
394+
await handler_started.wait()
395+
c2s_send.close()
396+
with anyio.fail_after(5):
397+
response = await s2c_recv.receive()
398+
399+
assert isinstance(response, SessionMessage)
400+
assert isinstance(response.message, JSONRPCResponse)
401+
assert response.message.id == 1
402+
assert response.message.result == {"ok": True}
403+
s2c_recv.close()
404+
405+
372406
@pytest.mark.anyio
373407
async def test_run_closes_write_stream_on_exit():
374408
"""run() owns both streams; the write end is released once the EOF teardown completes."""

0 commit comments

Comments
 (0)