Skip to content

Commit 66568d3

Browse files
author
Jianke LIN
committed
fix(stdio): drain responses after stdin EOF
1 parent a4f4ccd commit 66568d3

5 files changed

Lines changed: 142 additions & 46 deletions

File tree

‎src/mcp/server/lowlevel/server.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -715,6 +715,7 @@ async def run(
715715
lifespan_state=lifespan_context,
716716
init_options=initialization_options,
717717
raise_exceptions=raise_exceptions,
718+
close_write_stream_on_read_close=False,
718719
)
719720

720721
def streamable_http_app(

‎src/mcp/server/runner.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -476,6 +476,7 @@ async def serve_loop(
476476
session_id: str | None = None,
477477
init_options: InitializationOptions | None = None,
478478
raise_exceptions: bool = False,
479+
close_write_stream_on_read_close: bool = True,
479480
) -> None:
480481
"""Drive ``server`` in handshake-only loop mode over a stream pair until the channel closes.
481482
@@ -494,6 +495,7 @@ async def serve_loop(
494495
# next request (spec: SHOULD NOT, not MUST NOT) sees the initialized
495496
# state instead of failing the init-gate.
496497
inline_methods=frozenset({"initialize"}),
498+
close_write_stream_on_read_close=close_write_stream_on_read_close,
497499
)
498500
connection = Connection.for_loop(dispatcher, session_id=session_id)
499501
await serve_connection(

‎src/mcp/shared/jsonrpc_dispatcher.py‎

Lines changed: 36 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import contextvars
1111
import logging
1212
from collections.abc import Awaitable, Callable, Mapping
13+
from contextlib import AsyncExitStack
1314
from dataclasses import dataclass, field
1415
from functools import partial
1516
from typing import Any, Generic, Literal, cast
@@ -272,6 +273,7 @@ def __init__(
272273
peer_cancel_mode: PeerCancelMode = "interrupt",
273274
raise_handler_exceptions: bool = False,
274275
inline_methods: frozenset[str] = frozenset(),
276+
close_write_stream_on_read_close: bool = True,
275277
on_stream_exception: Callable[[Exception], Awaitable[None]] | None = None,
276278
) -> None:
277279
"""Wire a dispatcher over a transport's `SessionMessage` stream pair.
@@ -284,6 +286,10 @@ def __init__(
284286
inline_methods: Methods awaited in the read loop before the next
285287
message is dequeued (e.g. `initialize`); an inline handler
286288
that awaits the peer deadlocks the parked loop.
289+
close_write_stream_on_read_close: Close the write stream when the
290+
read stream closes. Full-duplex transports may set this to
291+
false so in-flight handlers can finish writing responses after
292+
input EOF.
287293
on_stream_exception: Observer for `Exception` items on the read
288294
stream; without it they are debug-logged and dropped. Awaited
289295
inline in the read loop, so a slow observer stalls dispatch.
@@ -298,6 +304,7 @@ def __init__(
298304
)
299305
self._peer_cancel_mode: PeerCancelMode = peer_cancel_mode
300306
self._raise_handler_exceptions = raise_handler_exceptions
307+
self._close_write_stream_on_read_close = close_write_stream_on_read_close
301308
self._inline_methods = inline_methods
302309
self.on_stream_exception = on_stream_exception
303310
"""Observer for ``Exception`` items on the read stream. Mutable so a session can
@@ -487,33 +494,36 @@ async def run(
487494
Single-shot: once the loop ends the dispatcher stays closed and cannot be restarted.
488495
"""
489496
self._on_notify_intercept = on_notify_intercept
497+
normal_eof = False
490498
try:
491-
# LIFO exits: the write stream closes only after the task-group join, so teardown writes still land.
492-
async with self._write_stream:
493-
async with anyio.create_task_group() as tg:
494-
self._tg = tg
495-
self._running = True
496-
task_status.started()
497-
try:
498-
async with self._read_stream:
499-
try:
500-
async for item in self._read_stream:
501-
# Duck-typed: only `ContextReceiveStream` carries the
502-
# sender's per-message contextvars snapshot.
503-
sender_ctx: contextvars.Context | None = getattr(
504-
self._read_stream, "last_context", None
505-
)
506-
await self._dispatch(item, on_request, on_notify, sender_ctx)
507-
except anyio.ClosedResourceError:
508-
# Receive end closed under us (stateless SHTTP teardown); same as EOF.
509-
logger.debug("read stream closed by transport; treating as EOF")
510-
# EOF: wake blocked `send_raw_request` waiters with CONNECTION_CLOSED.
511-
self._running = False
512-
self._closed = True
513-
self._fan_out_closed()
514-
finally:
515-
# Cancel in-flight handlers; otherwise the task-group join
516-
# waits on handlers whose callers are already gone.
499+
async with anyio.create_task_group() as tg:
500+
self._tg = tg
501+
self._running = True
502+
task_status.started()
503+
try:
504+
async with AsyncExitStack() as stack:
505+
await stack.enter_async_context(self._read_stream)
506+
if self._close_write_stream_on_read_close:
507+
await stack.enter_async_context(self._write_stream)
508+
try:
509+
async for item in self._read_stream:
510+
# Duck-typed: only `ContextReceiveStream` carries the
511+
# sender's per-message contextvars snapshot.
512+
sender_ctx: contextvars.Context | None = getattr(
513+
self._read_stream, "last_context", None
514+
)
515+
await self._dispatch(item, on_request, on_notify, sender_ctx)
516+
except anyio.ClosedResourceError:
517+
# Receive end closed under us (stateless SHTTP teardown); same as EOF.
518+
logger.debug("read stream closed by transport; treating as EOF")
519+
# EOF: wake blocked `send_raw_request` waiters with CONNECTION_CLOSED.
520+
self._running = False
521+
self._fan_out_closed()
522+
normal_eof = True
523+
finally:
524+
if not normal_eof:
525+
# Cancel on crash/cancel paths. On normal EOF, let
526+
# already received handlers drain their responses.
517527
tg.cancel_scope.cancel()
518528
finally:
519529
# Covers cancel/crash paths that skip the inline fan-out; idempotent.

‎tests/server/test_cancel_handling.py‎

Lines changed: 14 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
InitializeRequestParams,
1414
JSONRPCNotification,
1515
JSONRPCRequest,
16+
JSONRPCResponse,
1617
ListToolsResult,
1718
PaginatedRequestParams,
1819
TextContent,
@@ -107,29 +108,18 @@ async def first_request():
107108

108109

109110
@pytest.mark.anyio
110-
async def test_server_cancels_in_flight_handlers_on_transport_close():
111-
"""When the transport closes mid-request, server.run() must cancel in-flight
112-
handlers rather than join on them.
113-
114-
Without the cancel, the task group waits for the handler, which then tries
115-
to respond through a write stream that _receive_loop already closed,
116-
raising ClosedResourceError and crashing server.run() with exit code 1.
117-
118-
This drives server.run() with raw memory streams because InMemoryTransport
119-
wraps it in its own finally-cancel (_memory.py) which masks the bug.
120-
"""
111+
async def test_server_drains_in_flight_handlers_on_transport_read_eof():
112+
"""When the transport's read side hits EOF (e.g., stdio stdin closes), the
113+
server must drain already-started handlers so their responses reach the
114+
peer via the still-open write side."""
121115
handler_started = anyio.Event()
122-
handler_cancelled = anyio.Event()
116+
handler_allowed_to_finish = anyio.Event()
123117
server_run_returned = anyio.Event()
124118

125119
async def handle_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
126120
handler_started.set()
127-
try:
128-
await anyio.sleep_forever()
129-
finally:
130-
handler_cancelled.set()
131-
# unreachable: sleep_forever only exits via cancellation
132-
raise AssertionError # pragma: no cover
121+
await handler_allowed_to_finish.wait()
122+
return CallToolResult(content=[TextContent(type="text", text="ok")])
133123

134124
server = Server("test", on_call_tool=handle_call_tool)
135125

@@ -174,9 +164,13 @@ async def run_server():
174164
# handler gets CancelledError, server.run() returns.
175165
await to_server.aclose()
176166

177-
await server_run_returned.wait()
167+
handler_allowed_to_finish.set()
168+
169+
response = await from_server.receive()
170+
assert isinstance(response.message, JSONRPCResponse)
171+
assert response.message.id == 2
178172

179-
assert handler_cancelled.is_set()
173+
await server_run_returned.wait()
180174

181175

182176
@pytest.mark.anyio

‎tests/server/test_stdio.py‎

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,28 @@
1313
from mcp_types import (
1414
CLIENT_CAPABILITIES_META_KEY,
1515
CLIENT_INFO_META_KEY,
16+
LATEST_PROTOCOL_VERSION,
1617
PROTOCOL_VERSION_META_KEY,
1718
SERVER_INFO_META_KEY,
19+
CallToolRequestParams,
20+
CallToolResult,
21+
ClientCapabilities,
22+
Implementation,
23+
InitializeRequestParams,
24+
JSONRPCError,
1825
JSONRPCMessage,
26+
JSONRPCNotification,
1927
JSONRPCRequest,
2028
JSONRPCResponse,
29+
ListToolsResult,
30+
PaginatedRequestParams,
31+
TextContent,
32+
Tool,
2133
jsonrpc_message_adapter,
2234
)
2335
from typing_extensions import Buffer
2436

37+
from mcp.server import Server, ServerRequestContext
2538
from mcp.server.mcpserver import MCPServer
2639
from mcp.server.stdio import stdio_server
2740
from mcp.shared.message import SessionMessage
@@ -692,3 +705,79 @@ def test_mcpserver_run_stdio_serves_a_modern_connection(monkeypatch: pytest.Monk
692705
# resultType is modern-only: proves the request was served at the discovered version.
693706
assert responses[1].result["tools"] == []
694707
assert responses[1].result["resultType"] == "complete"
708+
709+
710+
@pytest.mark.anyio
711+
async def test_stdio_server_drains_in_flight_responses_on_stdin_eof():
712+
"""When stdin reaches EOF (e.g., bash-redirected input), already-received
713+
requests must still be able to emit their responses on stdout."""
714+
stdin = io.StringIO()
715+
stdout = io.StringIO()
716+
717+
tool_started_count = 0
718+
both_tools_started = anyio.Event()
719+
allow_tools_to_finish = anyio.Event()
720+
721+
async def handle_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
722+
return ListToolsResult(tools=[Tool(name="slow", description="test", input_schema={})])
723+
724+
async def handle_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
725+
nonlocal tool_started_count
726+
tool_started_count += 1
727+
if tool_started_count == 2:
728+
both_tools_started.set()
729+
await allow_tools_to_finish.wait()
730+
return CallToolResult(content=[TextContent(type="text", text="ok")])
731+
732+
server = Server("test", on_list_tools=handle_list_tools, on_call_tool=handle_call_tool)
733+
734+
init_req = JSONRPCRequest(
735+
jsonrpc="2.0",
736+
id=0,
737+
method="initialize",
738+
params=InitializeRequestParams(
739+
protocol_version=LATEST_PROTOCOL_VERSION,
740+
capabilities=ClientCapabilities(),
741+
client_info=Implementation(name="test", version="1.0"),
742+
).model_dump(by_alias=True, mode="json", exclude_none=True),
743+
)
744+
initialized = JSONRPCNotification(jsonrpc="2.0", method="notifications/initialized")
745+
call_1 = JSONRPCRequest(
746+
jsonrpc="2.0",
747+
id=1,
748+
method="tools/call",
749+
params=CallToolRequestParams(name="slow", arguments={}).model_dump(by_alias=True, mode="json"),
750+
)
751+
call_2 = JSONRPCRequest(
752+
jsonrpc="2.0",
753+
id=2,
754+
method="tools/call",
755+
params=CallToolRequestParams(name="slow", arguments={}).model_dump(by_alias=True, mode="json"),
756+
)
757+
758+
for message in (init_req, initialized, call_1, call_2):
759+
stdin.write(message.model_dump_json(by_alias=True, exclude_none=True) + "\n")
760+
stdin.seek(0)
761+
762+
async with stdio_server(stdin=anyio.AsyncFile(stdin), stdout=anyio.AsyncFile(stdout)) as (
763+
read_stream,
764+
write_stream,
765+
):
766+
with anyio.fail_after(5):
767+
async with anyio.create_task_group() as tg:
768+
tg.start_soon(server.run, read_stream, write_stream, server.create_initialization_options())
769+
await both_tools_started.wait()
770+
allow_tools_to_finish.set()
771+
772+
stdout.seek(0)
773+
ids: set[int | str] = set()
774+
for line in stdout.readlines():
775+
line = line.strip()
776+
if not line:
777+
continue
778+
message = jsonrpc_message_adapter.validate_json(line)
779+
if isinstance(message, JSONRPCResponse | JSONRPCError):
780+
assert message.id is not None
781+
ids.add(message.id)
782+
assert 1 in ids
783+
assert 2 in ids

0 commit comments

Comments
 (0)