diff --git a/src/mcp/server/stdio.py b/src/mcp/server/stdio.py index e526bab56..eaecb7dfb 100644 --- a/src/mcp/server/stdio.py +++ b/src/mcp/server/stdio.py @@ -34,14 +34,15 @@ 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 `fd` with `closefd=False` to force UTF-8 (Windows encoding is + # platform-dependent) without taking ownership of process stdio. + stdin_opened = stdout_opened = False if not stdin: - stdin = anyio.wrap_file(TextIOWrapper(sys.stdin.buffer, encoding="utf-8")) + stdin = anyio.wrap_file(TextIOWrapper(open(sys.stdin.fileno(), "rb", closefd=False), encoding="utf-8")) + stdin_opened = True if not stdout: - stdout = anyio.wrap_file(TextIOWrapper(sys.stdout.buffer, encoding="utf-8")) + stdout = anyio.wrap_file(TextIOWrapper(open(sys.stdout.fileno(), "wb", closefd=False), encoding="utf-8")) + stdout_opened = True read_stream: MemoryObjectReceiveStream[SessionMessage | Exception] read_stream_writer: MemoryObjectSendStream[SessionMessage | Exception] @@ -77,7 +78,13 @@ 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: + if stdin_opened: + await stdin.aclose() + if stdout_opened: + await stdout.aclose() diff --git a/tests/issues/test_1933_stdio_close.py b/tests/issues/test_1933_stdio_close.py new file mode 100644 index 000000000..da7d8c4d0 --- /dev/null +++ b/tests/issues/test_1933_stdio_close.py @@ -0,0 +1,49 @@ +"""Test for issue #1933: stdio_server closes real process stdio handles.""" + +import io +import os +import sys + +import pytest + +from mcp.server.stdio import stdio_server + + +@pytest.mark.anyio +async def test_stdio_server_preserves_process_handles(): + """After stdio_server() exits, the underlying stdin/stdout fds should still be open.""" + # 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() + + # 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 + fake_stdin.close() + fake_stdout.close() + for fd in [stdin_r_fd, stdout_r_fd, stdout_w_fd]: + try: + os.close(fd) + except OSError: + pass