diff --git a/src/mcp/server/stdio.py b/src/mcp/server/stdio.py index 5c1459dff..f19272697 100644 --- a/src/mcp/server/stdio.py +++ b/src/mcp/server/stdio.py @@ -48,18 +48,22 @@ async def stdio_server(stdin: anyio.AsyncFile[str] | None = None, stdout: anyio. async def stdin_reader(): try: - async with read_stream_writer: - async for line in stdin: - try: - message = types.jsonrpc_message_adapter.validate_json(line, by_name=False) - except Exception as exc: - await read_stream_writer.send(exc) - continue + async for line in stdin: + try: + message = types.jsonrpc_message_adapter.validate_json(line, by_name=False) + except Exception as exc: + await read_stream_writer.send(exc) + continue - session_message = SessionMessage(message) - await read_stream_writer.send(session_message) + session_message = SessionMessage(message) + await read_stream_writer.send(session_message) except anyio.ClosedResourceError: # pragma: no cover await anyio.lowlevel.checkpoint() + finally: + # Close the read stream to signal EOF to the server. Any pending + # server responses are already buffered in write_stream_reader and + # will drain through stdout_writer before the task group exits. + await read_stream_writer.aclose() async def stdout_writer(): try: diff --git a/tests/server/test_stdio_2678.py b/tests/server/test_stdio_2678.py new file mode 100644 index 000000000..f50ecec59 --- /dev/null +++ b/tests/server/test_stdio_2678.py @@ -0,0 +1,56 @@ +"""Regression test for #2678: in-flight responses should not be dropped on stdin EOF. + +When a server receives a request and stdin hits EOF while the server is still +processing, the response must still be written to stdout. The fix closes +read_stream_writer in stdin_reader's finally block so the server sees EOF and +can flush pending writes before the task group exits. +""" + +import io +import sys +import threading +from io import TextIOWrapper + +import pytest + +from mcp.server.mcpserver import MCPServer +from mcp.types import ( + JSONRPCRequest, + JSONRPCResponse, + jsonrpc_message_adapter, +) + + +class _KeepOpenBytesIO(io.BytesIO): + """A BytesIO that survives its TextIOWrapper being closed.""" + + def close(self) -> None: + pass + + +def _run_stdio_bounded(server: MCPServer, timeout: float = 5) -> None: + def target() -> None: + server.run("stdio") + + thread = threading.Thread(target=target, daemon=True) + thread.start() + thread.join(timeout) + assert not thread.is_alive(), "run('stdio') did not return after stdin EOF" + + +def test_stdio_response_not_dropped_on_eof(monkeypatch: pytest.MonkeyPatch) -> None: + """Server response is written to stdout even when stdin closes right after the request. + + Regression test for #2678: stdin EOF used to close read_stream_writer before + the server could flush its response through stdout_writer. + """ + ping = JSONRPCRequest(jsonrpc="2.0", id=1, method="ping") + stdin_bytes = io.BytesIO(ping.model_dump_json(by_alias=True, exclude_none=True).encode() + b"\n") + captured = _KeepOpenBytesIO() + monkeypatch.setattr(sys, "stdin", TextIOWrapper(stdin_bytes, encoding="utf-8")) + monkeypatch.setattr(sys, "stdout", TextIOWrapper(captured, encoding="utf-8")) + + _run_stdio_bounded(MCPServer(name="TestEOF")) + + response = jsonrpc_message_adapter.validate_json(captured.getvalue().decode().strip()) + assert response == JSONRPCResponse(jsonrpc="2.0", id=1, result={})