diff --git a/src/mcp/server/stdio.py b/src/mcp/server/stdio.py index 7f3aa2ac2..9987747fe 100644 --- a/src/mcp/server/stdio.py +++ b/src/mcp/server/stdio.py @@ -17,6 +17,7 @@ async def run_server(): ``` """ +import os import sys from contextlib import asynccontextmanager from io import TextIOWrapper @@ -34,14 +35,16 @@ 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. + # 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. We duplicate the file descriptors first so that + # closing the wrapper doesn't close the real process stdio handles. if not stdin: - stdin = anyio.wrap_file(TextIOWrapper(sys.stdin.buffer, encoding="utf-8")) + stdin_fd = os.dup(sys.stdin.buffer.fileno()) + stdin = anyio.wrap_file(TextIOWrapper(os.fdopen(stdin_fd, "rb"), encoding="utf-8")) if not stdout: - stdout = anyio.wrap_file(TextIOWrapper(sys.stdout.buffer, encoding="utf-8")) + stdout_fd = os.dup(sys.stdout.buffer.fileno()) + stdout = anyio.wrap_file(TextIOWrapper(os.fdopen(stdout_fd, "wb"), encoding="utf-8")) read_stream: MemoryObjectReceiveStream[SessionMessage | Exception] read_stream_writer: MemoryObjectSendStream[SessionMessage | Exception] diff --git a/tests/issues/test_1933_stdio_close.py b/tests/issues/test_1933_stdio_close.py new file mode 100644 index 000000000..aa7873f37 --- /dev/null +++ b/tests/issues/test_1933_stdio_close.py @@ -0,0 +1,55 @@ +"""Test for issue #1933: stdio_server closes real process stdio handles.""" + +import gc +import io +import os +import sys + +import pytest + +from mcp.server.stdio import stdio_server + + +@pytest.mark.anyio +@pytest.mark.filterwarnings("ignore::pytest.PytestUnraisableExceptionWarning") +async def test_stdio_server_preserves_process_handles(): + """After stdio_server() exits, the underlying stdin/stdout fds should still be open. + + Before the fix, TextIOWrapper took ownership of sys.stdin.buffer and + sys.stdout.buffer. When the wrapper was garbage-collected, it closed the + underlying buffer, permanently killing process stdio. + """ + # Create real pipes to stand in for process stdin/stdout. + # Real fds are required because the bug involves TextIOWrapper closing + # the underlying fd — StringIO doesn't have file descriptors. + stdin_r_fd, stdin_w_fd = os.pipe() + stdout_r_fd, stdout_w_fd = os.pipe() + + fake_stdin = io.TextIOWrapper(io.BufferedReader(io.FileIO(stdin_r_fd, "rb"))) + fake_stdout = io.TextIOWrapper(io.BufferedWriter(io.FileIO(stdout_w_fd, "wb"))) + + saved_stdin, saved_stdout = sys.stdin, sys.stdout + sys.stdin = fake_stdin + sys.stdout = fake_stdout + + # Close write end so stdin_reader gets EOF immediately + os.close(stdin_w_fd) + + try: + async with stdio_server() as (read_stream, write_stream): + await write_stream.aclose() + + await read_stream.aclose() + gc.collect() + + # os.fstat raises OSError if the fd was closed + os.fstat(stdin_r_fd) + os.fstat(stdout_w_fd) + finally: + sys.stdin = saved_stdin + sys.stdout = saved_stdout + for fd in [stdin_r_fd, stdout_r_fd, stdout_w_fd]: + try: + os.close(fd) + except OSError: # pragma: no cover + pass