From 88f9276dedfc274e4c22688c4f273099ac0270eb Mon Sep 17 00:00:00 2001 From: Nova Date: Mon, 8 Jun 2026 16:53:30 +0000 Subject: [PATCH 1/2] fix(stdio): drain pending responses before closing read stream on EOF Closes #2678. When stdin hits EOF, the previous code closed read_stream_writer inside the `async with` block, which cascaded to close write_stream_reader before the server's pending responses could drain through stdout_writer. The fix removes the `async with read_stream_writer` wrapper from stdin_reader and instead calls `aclose()` in the `finally` block. This ensures: 1. All stdin lines are read and forwarded to the server 2. The read stream is closed promptly on EOF (signaling the server) 3. Buffered responses in write_stream_reader drain through stdout_writer before the task group exits All existing tests pass. Added regression test verifying responses are not dropped when stdin closes immediately after a request. --- src/mcp/server/stdio.py | 22 +++++++----- tests/server/test_stdio_2678.py | 59 +++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 9 deletions(-) create mode 100644 tests/server/test_stdio_2678.py diff --git a/src/mcp/server/stdio.py b/src/mcp/server/stdio.py index 5c1459dff6..f192726972 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 0000000000..202c2412f3 --- /dev/null +++ b/tests/server/test_stdio_2678.py @@ -0,0 +1,59 @@ +"""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 +import time +from io import TextIOWrapper + +import anyio +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={}) From 7ef1e95df0f6e3cf1b5f47b695d2c32ae84ee346 Mon Sep 17 00:00:00 2001 From: Nova Date: Mon, 8 Jun 2026 23:41:56 +0000 Subject: [PATCH 2/2] fix: format test file and remove unused imports - Collapse multi-line io.BytesIO() to single line (ruff format) - Add blank line after module docstring (ruff format) - Remove unused imports: time, anyio (ruff check --fix) --- tests/server/test_stdio_2678.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/tests/server/test_stdio_2678.py b/tests/server/test_stdio_2678.py index 202c2412f3..f50ecec595 100644 --- a/tests/server/test_stdio_2678.py +++ b/tests/server/test_stdio_2678.py @@ -5,13 +5,12 @@ 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 -import time from io import TextIOWrapper -import anyio import pytest from mcp.server.mcpserver import MCPServer @@ -46,9 +45,7 @@ def test_stdio_response_not_dropped_on_eof(monkeypatch: pytest.MonkeyPatch) -> N 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" - ) + 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"))