diff --git a/src/mcp/server/stdio.py b/src/mcp/server/stdio.py index 5c1459dff..b2e55af49 100644 --- a/src/mcp/server/stdio.py +++ b/src/mcp/server/stdio.py @@ -17,6 +17,8 @@ async def run_server(): ``` """ +import io +import os import sys from contextlib import asynccontextmanager from io import TextIOWrapper @@ -34,14 +36,31 @@ async def stdio_server(stdin: anyio.AsyncFile[str] | None = None, stdout: anyio. """Server transport for stdio: this communicates with an MCP client by reading from the current process' stdin and writing to stdout. """ - # Purposely not using context managers for these, as we don't want to close - # standard process handles. Encoding of stdin/stdout as text streams on - # python is platform-dependent (Windows is particularly problematic), so we - # re-wrap the underlying binary stream to ensure UTF-8. + # Re-wrap the underlying binary stream to ensure UTF-8 (encoding is + # platform-dependent on Windows). Use os.dup() to duplicate the file + # descriptor so that closing our wrapper does not close the real process + # stdin/stdout (issue #1933). Falls back to sharing the original buffer + # when the stream is not backed by a real file descriptor (e.g. BytesIO + # in tests); in that case we must not close the wrapper on exit. + stdin_created = False + stdout_created = False + if not stdin: - stdin = anyio.wrap_file(TextIOWrapper(sys.stdin.buffer, encoding="utf-8", errors="replace")) + stdin_buffer = sys.stdin.buffer + try: + stdin_buffer = os.fdopen(os.dup(stdin_buffer.fileno()), "rb") + stdin_created = True + except io.UnsupportedOperation: + pass + stdin = anyio.wrap_file(TextIOWrapper(stdin_buffer, encoding="utf-8", errors="replace")) if not stdout: - stdout = anyio.wrap_file(TextIOWrapper(sys.stdout.buffer, encoding="utf-8")) + stdout_buffer = sys.stdout.buffer + try: + stdout_buffer = os.fdopen(os.dup(stdout_buffer.fileno()), "wb") + stdout_created = True + except io.UnsupportedOperation: + pass + stdout = anyio.wrap_file(TextIOWrapper(stdout_buffer, encoding="utf-8")) read_stream_writer, read_stream = create_context_streams[SessionMessage | Exception](0) write_stream, write_stream_reader = create_context_streams[SessionMessage](0) @@ -71,7 +90,14 @@ async def stdout_writer(): except anyio.ClosedResourceError: # pragma: no cover await anyio.lowlevel.checkpoint() - async with anyio.create_task_group() as tg: - tg.start_soon(stdin_reader) - tg.start_soon(stdout_writer) - yield read_stream, write_stream + try: + async with anyio.create_task_group() as tg: + tg.start_soon(stdin_reader) + tg.start_soon(stdout_writer) + yield read_stream, write_stream + finally: + # Close the dup'd wrappers we own; do NOT close sys.stdin/sys.stdout. + if stdin_created: + await stdin.aclose() + if stdout_created: + await stdout.aclose() diff --git a/tests/server/test_stdio.py b/tests/server/test_stdio.py index 677a99356..412be2ad5 100644 --- a/tests/server/test_stdio.py +++ b/tests/server/test_stdio.py @@ -1,4 +1,5 @@ import io +import os import sys from io import TextIOWrapper @@ -92,3 +93,36 @@ async def test_stdio_server_invalid_utf8(monkeypatch: pytest.MonkeyPatch): second = await read_stream.receive() assert isinstance(second, SessionMessage) assert second.message == valid + + +@pytest.mark.anyio +async def test_stdio_server_does_not_close_real_stdin(monkeypatch: pytest.MonkeyPatch): + """Regression test for issue #1933: stdio_server() must not close + sys.stdin or sys.stdout after the context manager exits. + """ + # Use pipes so stdio_server() sees EOF immediately (no interactive stdin needed). + in_r, in_w = os.pipe() + out_r, out_w = os.pipe() + os.close(in_w) # make stdin appear at EOF so stdin_reader exits immediately + os.close(out_r) # we don't read server output in this test + + fake_stdin_buf = os.fdopen(in_r, "rb") + fake_stdout_buf = os.fdopen(out_w, "wb") + # Keep references so we can close them explicitly (prevents ResourceWarning + # when GC would otherwise finalize the file objects during teardown). + fake_stdin = TextIOWrapper(fake_stdin_buf, encoding="utf-8") + fake_stdout = TextIOWrapper(fake_stdout_buf, encoding="utf-8") + monkeypatch.setattr(sys, "stdin", fake_stdin) + monkeypatch.setattr(sys, "stdout", fake_stdout) + + with anyio.fail_after(5): + async with stdio_server() as (read_stream, write_stream): + await write_stream.aclose() + await read_stream.aclose() + + assert not fake_stdin_buf.closed, "stdio_server closed sys.stdin.buffer — regression from issue #1933" + assert not fake_stdout_buf.closed, "stdio_server closed sys.stdout.buffer" + + # Explicit close before teardown; GC-based close of real fds triggers ResourceWarning. + fake_stdin.close() + fake_stdout.close()