Skip to content

Commit cec0a3b

Browse files
author
Jianke LIN
committed
fix(stdio): drain responses after stdin EOF
1 parent e942d00 commit cec0a3b

5 files changed

Lines changed: 149 additions & 48 deletions

File tree

src/mcp/server/lowlevel/server.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -656,6 +656,7 @@ async def run(
656656
lifespan_state=lifespan_context,
657657
init_options=initialization_options,
658658
raise_exceptions=raise_exceptions,
659+
close_write_stream_on_read_close=False,
659660
)
660661

661662
def streamable_http_app(

src/mcp/server/runner.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,7 @@ async def serve_loop(
392392
session_id: str | None = None,
393393
init_options: InitializationOptions | None = None,
394394
raise_exceptions: bool = False,
395+
close_write_stream_on_read_close: bool = True,
395396
) -> None:
396397
"""Drive ``server`` in loop mode over a stream pair until the channel closes.
397398
@@ -409,6 +410,7 @@ async def serve_loop(
409410
# next request (spec: SHOULD NOT, not MUST NOT) sees the initialized
410411
# state instead of failing the init-gate.
411412
inline_methods=frozenset({"initialize"}),
413+
close_write_stream_on_read_close=close_write_stream_on_read_close,
412414
)
413415
connection = Connection.for_loop(dispatcher, session_id=session_id)
414416
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
@@ -250,6 +251,7 @@ def __init__(
250251
peer_cancel_mode: PeerCancelMode = "interrupt",
251252
raise_handler_exceptions: bool = False,
252253
inline_methods: frozenset[str] = frozenset(),
254+
close_write_stream_on_read_close: bool = True,
253255
on_stream_exception: Callable[[Exception], Awaitable[None]] | None = None,
254256
) -> None:
255257
"""Wire a dispatcher over a transport's `SessionMessage` stream pair.
@@ -262,6 +264,10 @@ def __init__(
262264
inline_methods: Methods awaited in the read loop before the next
263265
message is dequeued (e.g. `initialize`); an inline handler
264266
that awaits the peer deadlocks the parked loop.
267+
close_write_stream_on_read_close: Close the write stream when the
268+
read stream closes. Full-duplex transports may set this to
269+
false so in-flight handlers can finish writing responses after
270+
input EOF.
265271
on_stream_exception: Observer for `Exception` items on the read
266272
stream; without it they are debug-logged and dropped. Awaited
267273
inline in the read loop, so a slow observer stalls dispatch.
@@ -276,6 +282,7 @@ def __init__(
276282
)
277283
self._peer_cancel_mode: PeerCancelMode = peer_cancel_mode
278284
self._raise_handler_exceptions = raise_handler_exceptions
285+
self._close_write_stream_on_read_close = close_write_stream_on_read_close
279286
self._inline_methods = inline_methods
280287
self.on_stream_exception = on_stream_exception
281288
"""Observer for ``Exception`` items on the read stream. Mutable so a session can
@@ -447,33 +454,36 @@ async def run(
447454
`task_status.started()` fires once `send_raw_request` is usable.
448455
Single-shot: once the loop ends the dispatcher stays closed and cannot be restarted.
449456
"""
457+
normal_eof = False
450458
try:
451-
# LIFO exits: the write stream closes only after the task-group join, so teardown writes still land.
452-
async with self._write_stream:
453-
async with anyio.create_task_group() as tg:
454-
self._tg = tg
455-
self._running = True
456-
task_status.started()
457-
try:
458-
async with self._read_stream:
459-
try:
460-
async for item in self._read_stream:
461-
# Duck-typed: only `ContextReceiveStream` carries the
462-
# sender's per-message contextvars snapshot.
463-
sender_ctx: contextvars.Context | None = getattr(
464-
self._read_stream, "last_context", None
465-
)
466-
await self._dispatch(item, on_request, on_notify, sender_ctx)
467-
except anyio.ClosedResourceError:
468-
# Receive end closed under us (stateless SHTTP teardown); same as EOF.
469-
logger.debug("read stream closed by transport; treating as EOF")
470-
# EOF: wake blocked `send_raw_request` waiters with CONNECTION_CLOSED.
471-
self._running = False
472-
self._closed = True
473-
self._fan_out_closed()
474-
finally:
475-
# Cancel in-flight handlers; otherwise the task-group join
476-
# waits on handlers whose callers are already gone.
459+
async with anyio.create_task_group() as tg:
460+
self._tg = tg
461+
self._running = True
462+
task_status.started()
463+
try:
464+
async with AsyncExitStack() as stack:
465+
await stack.enter_async_context(self._read_stream)
466+
if self._close_write_stream_on_read_close:
467+
await stack.enter_async_context(self._write_stream)
468+
try:
469+
async for item in self._read_stream:
470+
# Duck-typed: only `ContextReceiveStream` carries the
471+
# sender's per-message contextvars snapshot.
472+
sender_ctx: contextvars.Context | None = getattr(
473+
self._read_stream, "last_context", None
474+
)
475+
await self._dispatch(item, on_request, on_notify, sender_ctx)
476+
except anyio.ClosedResourceError:
477+
# Receive end closed under us (stateless SHTTP teardown); same as EOF.
478+
logger.debug("read stream closed by transport; treating as EOF")
479+
# EOF: wake blocked `send_raw_request` waiters with CONNECTION_CLOSED.
480+
self._running = False
481+
self._fan_out_closed()
482+
normal_eof = True
483+
finally:
484+
if not normal_eof:
485+
# Cancel on crash/cancel paths. On normal EOF, let
486+
# already received handlers drain their responses.
477487
tg.cancel_scope.cancel()
478488
finally:
479489
# 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,
@@ -100,29 +101,18 @@ async def first_request():
100101

101102

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

118112
async def handle_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
119113
handler_started.set()
120-
try:
121-
await anyio.sleep_forever()
122-
finally:
123-
handler_cancelled.set()
124-
# unreachable: sleep_forever only exits via cancellation
125-
raise AssertionError # pragma: no cover
114+
await handler_allowed_to_finish.wait()
115+
return CallToolResult(content=[TextContent(type="text", text="ok")])
126116

127117
server = Server("test", on_call_tool=handle_call_tool)
128118

@@ -167,9 +157,13 @@ async def run_server():
167157
# handler gets CancelledError, server.run() returns.
168158
await to_server.aclose()
169159

170-
await server_run_returned.wait()
160+
handler_allowed_to_finish.set()
161+
162+
response = await from_server.receive()
163+
assert isinstance(response.message, JSONRPCResponse)
164+
assert response.message.id == 2
171165

172-
assert handler_cancelled.is_set()
166+
await server_run_returned.wait()
173167

174168

175169
@pytest.mark.anyio

tests/server/test_stdio.py

Lines changed: 96 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,26 @@
77

88
import anyio
99
import pytest
10-
from mcp_types import JSONRPCMessage, JSONRPCRequest, JSONRPCResponse, jsonrpc_message_adapter
11-
10+
from mcp_types import (
11+
LATEST_PROTOCOL_VERSION,
12+
CallToolRequestParams,
13+
CallToolResult,
14+
ClientCapabilities,
15+
Implementation,
16+
InitializeRequestParams,
17+
JSONRPCError,
18+
JSONRPCMessage,
19+
JSONRPCNotification,
20+
JSONRPCRequest,
21+
JSONRPCResponse,
22+
ListToolsResult,
23+
PaginatedRequestParams,
24+
TextContent,
25+
Tool,
26+
jsonrpc_message_adapter,
27+
)
28+
29+
from mcp.server import Server, ServerRequestContext
1230
from mcp.server.mcpserver import MCPServer
1331
from mcp.server.stdio import stdio_server
1432
from mcp.shared.message import SessionMessage
@@ -169,3 +187,79 @@ async def lifespan(server: MCPServer) -> AsyncIterator[None]:
169187
assert events == ["setup", "cleanup"]
170188
response = jsonrpc_message_adapter.validate_json(captured.getvalue().decode().strip())
171189
assert response == JSONRPCResponse(jsonrpc="2.0", id=1, result={})
190+
191+
192+
@pytest.mark.anyio
193+
async def test_stdio_server_drains_in_flight_responses_on_stdin_eof():
194+
"""When stdin reaches EOF (e.g., bash-redirected input), already-received
195+
requests must still be able to emit their responses on stdout."""
196+
stdin = io.StringIO()
197+
stdout = io.StringIO()
198+
199+
tool_started_count = 0
200+
both_tools_started = anyio.Event()
201+
allow_tools_to_finish = anyio.Event()
202+
203+
async def handle_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
204+
return ListToolsResult(tools=[Tool(name="slow", description="test", input_schema={})])
205+
206+
async def handle_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
207+
nonlocal tool_started_count
208+
tool_started_count += 1
209+
if tool_started_count == 2:
210+
both_tools_started.set()
211+
await allow_tools_to_finish.wait()
212+
return CallToolResult(content=[TextContent(type="text", text="ok")])
213+
214+
server = Server("test", on_list_tools=handle_list_tools, on_call_tool=handle_call_tool)
215+
216+
init_req = JSONRPCRequest(
217+
jsonrpc="2.0",
218+
id=0,
219+
method="initialize",
220+
params=InitializeRequestParams(
221+
protocol_version=LATEST_PROTOCOL_VERSION,
222+
capabilities=ClientCapabilities(),
223+
client_info=Implementation(name="test", version="1.0"),
224+
).model_dump(by_alias=True, mode="json", exclude_none=True),
225+
)
226+
initialized = JSONRPCNotification(jsonrpc="2.0", method="notifications/initialized")
227+
call_1 = JSONRPCRequest(
228+
jsonrpc="2.0",
229+
id=1,
230+
method="tools/call",
231+
params=CallToolRequestParams(name="slow", arguments={}).model_dump(by_alias=True, mode="json"),
232+
)
233+
call_2 = JSONRPCRequest(
234+
jsonrpc="2.0",
235+
id=2,
236+
method="tools/call",
237+
params=CallToolRequestParams(name="slow", arguments={}).model_dump(by_alias=True, mode="json"),
238+
)
239+
240+
for message in (init_req, initialized, call_1, call_2):
241+
stdin.write(message.model_dump_json(by_alias=True, exclude_none=True) + "\n")
242+
stdin.seek(0)
243+
244+
async with stdio_server(stdin=anyio.AsyncFile(stdin), stdout=anyio.AsyncFile(stdout)) as (
245+
read_stream,
246+
write_stream,
247+
):
248+
with anyio.fail_after(5):
249+
async with anyio.create_task_group() as tg:
250+
tg.start_soon(server.run, read_stream, write_stream, server.create_initialization_options())
251+
await both_tools_started.wait()
252+
allow_tools_to_finish.set()
253+
254+
stdout.seek(0)
255+
ids: set[int | str] = set()
256+
for line in stdout.readlines():
257+
line = line.strip()
258+
if not line:
259+
continue
260+
message = jsonrpc_message_adapter.validate_json(line)
261+
if isinstance(message, JSONRPCResponse | JSONRPCError):
262+
assert message.id is not None
263+
ids.add(message.id)
264+
assert 1 in ids
265+
assert 2 in ids

0 commit comments

Comments
 (0)