From c2be9d0f0bf5bbe8bcf64b792eafe4bcb20b358d Mon Sep 17 00:00:00 2001 From: adityuhkapoor Date: Wed, 11 Feb 2026 21:46:19 -0500 Subject: [PATCH 1/4] fix: prevent stdio_server from closing process stdio handles TextIOWrapper wraps sys.stdin.buffer/sys.stdout.buffer directly, so garbage-collecting the wrapper closes the real process stdio. Use os.dup() to give the wrapper its own file descriptor copy. Github-Issue:#1933 --- src/mcp/server/stdio.py | 15 +++++--- tests/issues/test_1933_stdio_close.py | 55 +++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 6 deletions(-) create mode 100644 tests/issues/test_1933_stdio_close.py 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 From 685115bbcb0dde71663dba099e0771c601ad208c Mon Sep 17 00:00:00 2001 From: Aditya K <41550011+adityuhkapoor@users.noreply.github.com> Date: Thu, 5 Mar 2026 12:01:42 -0500 Subject: [PATCH 2/4] use closefd=False per review feedback --- src/mcp/server/stdio.py | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/src/mcp/server/stdio.py b/src/mcp/server/stdio.py index 33c1249a9..5e83efc99 100644 --- a/src/mcp/server/stdio.py +++ b/src/mcp/server/stdio.py @@ -17,7 +17,6 @@ async def run_server(): ``` """ -import os import sys from contextlib import asynccontextmanager from io import TextIOWrapper @@ -35,17 +34,13 @@ 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. """ - # 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. + # Re-wrap the `fd` with `closefd=False` to force UTF-8 (Windows encoding is + # platform-dependant) without taking ownership of process stdio. if not stdin: - stdin_fd = os.dup(sys.stdin.buffer.fileno()) - stdin = anyio.wrap_file(TextIOWrapper(os.fdopen(stdin_fd, "rb"), encoding="utf-8")) + stdin = anyio.wrap_file(TextIOWrapper(open(sys.stdin.fileno(), "rb", closefd=False), encoding="utf-8")) if not stdout: - stdout_fd = os.dup(sys.stdout.buffer.fileno()) - stdout = anyio.wrap_file(TextIOWrapper(os.fdopen(stdout_fd, "wb"), encoding="utf-8")) - + stdout = anyio.wrap_file(TextIOWrapper(open(sys.stdout.fileno(), "wb", closefd=False), encoding="utf-8")) + read_stream: MemoryObjectReceiveStream[SessionMessage | Exception] read_stream_writer: MemoryObjectSendStream[SessionMessage | Exception] From ee156dd83453afa97e5c2df57c05f18a6fcba77c Mon Sep 17 00:00:00 2001 From: Aditya K <41550011+adityuhkapoor@users.noreply.github.com> Date: Thu, 5 Mar 2026 12:02:40 -0500 Subject: [PATCH 3/4] remove filterwarnings no longer needed --- tests/issues/test_1933_stdio_close.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/issues/test_1933_stdio_close.py b/tests/issues/test_1933_stdio_close.py index aa7873f37..28574e90c 100644 --- a/tests/issues/test_1933_stdio_close.py +++ b/tests/issues/test_1933_stdio_close.py @@ -11,7 +11,6 @@ @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. From 99239b63b1363b93d3956e480e39d3096a1bbdef Mon Sep 17 00:00:00 2001 From: adityuhkapoor <41550011+adityuhkapoor@users.noreply.github.com> Date: Thu, 5 Mar 2026 12:56:28 -0500 Subject: [PATCH 4/4] close wrappers on exit to fix ResourceWarning --- src/mcp/server/stdio.py | 21 +++++++++++++++------ tests/issues/test_1933_stdio_close.py | 13 ++++--------- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/src/mcp/server/stdio.py b/src/mcp/server/stdio.py index d1b748d34..eaecb7dfb 100644 --- a/src/mcp/server/stdio.py +++ b/src/mcp/server/stdio.py @@ -35,12 +35,15 @@ async def stdio_server(stdin: anyio.AsyncFile[str] | None = None, stdout: anyio. from the current process' stdin and writing to stdout. """ # Re-wrap the `fd` with `closefd=False` to force UTF-8 (Windows encoding is - # platform-dependant) without taking ownership of process stdio. + # platform-dependent) without taking ownership of process stdio. + stdin_opened = stdout_opened = False if not stdin: 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(open(sys.stdout.fileno(), "wb", closefd=False), encoding="utf-8")) - + stdout_opened = True + read_stream: MemoryObjectReceiveStream[SessionMessage | Exception] read_stream_writer: MemoryObjectSendStream[SessionMessage | Exception] @@ -75,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 index 28574e90c..da7d8c4d0 100644 --- a/tests/issues/test_1933_stdio_close.py +++ b/tests/issues/test_1933_stdio_close.py @@ -1,6 +1,5 @@ """Test for issue #1933: stdio_server closes real process stdio handles.""" -import gc import io import os import sys @@ -12,12 +11,7 @@ @pytest.mark.anyio 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. - """ + """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. @@ -39,7 +33,6 @@ async def test_stdio_server_preserves_process_handles(): await write_stream.aclose() await read_stream.aclose() - gc.collect() # os.fstat raises OSError if the fd was closed os.fstat(stdin_r_fd) @@ -47,8 +40,10 @@ async def test_stdio_server_preserves_process_handles(): 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: # pragma: no cover + except OSError: pass