Skip to content

Commit 48debb0

Browse files
author
Jianke LIN
committed
fix(stdio): drain responses after stdin EOF
1 parent 220d362 commit 48debb0

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
@@ -703,6 +703,7 @@ async def run(
703703
lifespan_state=lifespan_context,
704704
init_options=initialization_options,
705705
raise_exceptions=raise_exceptions,
706+
close_write_stream_on_read_close=False,
706707
)
707708

708709
def streamable_http_app(

src/mcp/server/runner.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,7 @@ async def serve_loop(
414414
session_id: str | None = None,
415415
init_options: InitializationOptions | None = None,
416416
raise_exceptions: bool = False,
417+
close_write_stream_on_read_close: bool = True,
417418
) -> None:
418419
"""Drive ``server`` in handshake-only loop mode over a stream pair until the channel closes.
419420
@@ -432,6 +433,7 @@ async def serve_loop(
432433
# next request (spec: SHOULD NOT, not MUST NOT) sees the initialized
433434
# state instead of failing the init-gate.
434435
inline_methods=frozenset({"initialize"}),
436+
close_write_stream_on_read_close=close_write_stream_on_read_close,
435437
)
436438
connection = Connection.for_loop(dispatcher, session_id=session_id)
437439
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: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,27 @@
1010
from mcp_types import (
1111
CLIENT_CAPABILITIES_META_KEY,
1212
CLIENT_INFO_META_KEY,
13+
LATEST_PROTOCOL_VERSION,
1314
PROTOCOL_VERSION_META_KEY,
15+
CallToolRequestParams,
16+
CallToolResult,
17+
ClientCapabilities,
18+
Implementation,
19+
InitializeRequestParams,
20+
JSONRPCError,
1421
JSONRPCMessage,
22+
JSONRPCNotification,
1523
JSONRPCRequest,
1624
JSONRPCResponse,
25+
ListToolsResult,
26+
PaginatedRequestParams,
27+
TextContent,
28+
Tool,
1729
jsonrpc_message_adapter,
1830
)
1931
from typing_extensions import Buffer
2032

33+
from mcp.server import Server, ServerRequestContext
2134
from mcp.server.mcpserver import MCPServer
2235
from mcp.server.stdio import stdio_server
2336
from mcp.shared.message import SessionMessage
@@ -274,3 +287,79 @@ def test_mcpserver_run_stdio_serves_a_modern_connection(monkeypatch: pytest.Monk
274287
# request was served at the discovered version, not the handshake era.
275288
assert responses[1].result["tools"] == []
276289
assert responses[1].result["resultType"] == "complete"
290+
291+
292+
@pytest.mark.anyio
293+
async def test_stdio_server_drains_in_flight_responses_on_stdin_eof():
294+
"""When stdin reaches EOF (e.g., bash-redirected input), already-received
295+
requests must still be able to emit their responses on stdout."""
296+
stdin = io.StringIO()
297+
stdout = io.StringIO()
298+
299+
tool_started_count = 0
300+
both_tools_started = anyio.Event()
301+
allow_tools_to_finish = anyio.Event()
302+
303+
async def handle_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
304+
return ListToolsResult(tools=[Tool(name="slow", description="test", input_schema={})])
305+
306+
async def handle_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
307+
nonlocal tool_started_count
308+
tool_started_count += 1
309+
if tool_started_count == 2:
310+
both_tools_started.set()
311+
await allow_tools_to_finish.wait()
312+
return CallToolResult(content=[TextContent(type="text", text="ok")])
313+
314+
server = Server("test", on_list_tools=handle_list_tools, on_call_tool=handle_call_tool)
315+
316+
init_req = JSONRPCRequest(
317+
jsonrpc="2.0",
318+
id=0,
319+
method="initialize",
320+
params=InitializeRequestParams(
321+
protocol_version=LATEST_PROTOCOL_VERSION,
322+
capabilities=ClientCapabilities(),
323+
client_info=Implementation(name="test", version="1.0"),
324+
).model_dump(by_alias=True, mode="json", exclude_none=True),
325+
)
326+
initialized = JSONRPCNotification(jsonrpc="2.0", method="notifications/initialized")
327+
call_1 = JSONRPCRequest(
328+
jsonrpc="2.0",
329+
id=1,
330+
method="tools/call",
331+
params=CallToolRequestParams(name="slow", arguments={}).model_dump(by_alias=True, mode="json"),
332+
)
333+
call_2 = JSONRPCRequest(
334+
jsonrpc="2.0",
335+
id=2,
336+
method="tools/call",
337+
params=CallToolRequestParams(name="slow", arguments={}).model_dump(by_alias=True, mode="json"),
338+
)
339+
340+
for message in (init_req, initialized, call_1, call_2):
341+
stdin.write(message.model_dump_json(by_alias=True, exclude_none=True) + "\n")
342+
stdin.seek(0)
343+
344+
async with stdio_server(stdin=anyio.AsyncFile(stdin), stdout=anyio.AsyncFile(stdout)) as (
345+
read_stream,
346+
write_stream,
347+
):
348+
with anyio.fail_after(5):
349+
async with anyio.create_task_group() as tg:
350+
tg.start_soon(server.run, read_stream, write_stream, server.create_initialization_options())
351+
await both_tools_started.wait()
352+
allow_tools_to_finish.set()
353+
354+
stdout.seek(0)
355+
ids: set[int | str] = set()
356+
for line in stdout.readlines():
357+
line = line.strip()
358+
if not line:
359+
continue
360+
message = jsonrpc_message_adapter.validate_json(line)
361+
if isinstance(message, JSONRPCResponse | JSONRPCError):
362+
assert message.id is not None
363+
ids.add(message.id)
364+
assert 1 in ids
365+
assert 2 in ids

0 commit comments

Comments
 (0)