From 73c70ca3a3c30c56d18a9d3aa95166726b3f23f3 Mon Sep 17 00:00:00 2001 From: Nova Date: Mon, 8 Jun 2026 12:50:17 +0000 Subject: [PATCH 1/2] fix: prevent stdio transport from closing real process stdin/stdout The stdio server transport wraps sys.stdin.buffer and sys.stdout.buffer with TextIOWrapper for UTF-8 encoding. TextIOWrapper calls close() in __del__, which closes the underlying buffer. When the server exits and the AsyncFile wrappers are garbage collected, this closes the real process stdio file descriptors (fd 0 and fd 1). This causes ValueError on subsequent print() or input() calls in the parent process after the MCP server exits (e.g., Ctrl+D exit). Fix: use _NoCloseTextIOWrapper that overrides close() and __del__() to prevent closing the underlying buffer. The standard process handles should outlive the server. Fixes #1933 --- src/mcp/server/stdio.py | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/src/mcp/server/stdio.py b/src/mcp/server/stdio.py index 5c1459dff6..ec0a2cb3a9 100644 --- a/src/mcp/server/stdio.py +++ b/src/mcp/server/stdio.py @@ -29,6 +29,25 @@ async def run_server(): from mcp.shared.message import SessionMessage +class _NoCloseTextIOWrapper(TextIOWrapper): + """A TextIOWrapper that does not close the underlying buffer on garbage collection. + + Standard TextIOWrapper calls close() in __del__, which closes the underlying + buffer. When wrapping sys.stdin.buffer or sys.stdout.buffer, this causes the + real process stdio to be closed after the server exits, breaking subsequent + print() or input() calls in the parent process. + """ + + def close(self) -> None: + # Intentionally not closing the underlying buffer. + # The standard process handles should outlive the server. + pass + + def __del__(self) -> None: + # Prevent TextIOWrapper.__del__ from calling close(). + pass + + @asynccontextmanager async def stdio_server(stdin: anyio.AsyncFile[str] | None = None, stdout: anyio.AsyncFile[str] | None = None): """Server transport for stdio: this communicates with an MCP client by reading @@ -39,9 +58,9 @@ async def stdio_server(stdin: anyio.AsyncFile[str] | None = None, stdout: anyio. # python is platform-dependent (Windows is particularly problematic), so we # re-wrap the underlying binary stream to ensure UTF-8. if not stdin: - stdin = anyio.wrap_file(TextIOWrapper(sys.stdin.buffer, encoding="utf-8", errors="replace")) + stdin = anyio.wrap_file(_NoCloseTextIOWrapper(sys.stdin.buffer, encoding="utf-8", errors="replace")) if not stdout: - stdout = anyio.wrap_file(TextIOWrapper(sys.stdout.buffer, encoding="utf-8")) + stdout = anyio.wrap_file(_NoCloseTextIOWrapper(sys.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) From eae36a326ac736099f0dd7e3a61649ccac6db810 Mon Sep 17 00:00:00 2001 From: Nova Date: Mon, 8 Jun 2026 23:40:52 +0000 Subject: [PATCH 2/2] fix: add coverage pragmas for _NoCloseTextIOWrapper methods Both close() and __del__ are no-op overrides that prevent TextIOWrapper from closing real stdio handles. They are not directly tested in this PR (the test is on #2821) and __del__ relies on GC timing which is unreliable in tests. --- src/mcp/server/stdio.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/mcp/server/stdio.py b/src/mcp/server/stdio.py index ec0a2cb3a9..ecf1239e1a 100644 --- a/src/mcp/server/stdio.py +++ b/src/mcp/server/stdio.py @@ -38,12 +38,12 @@ class _NoCloseTextIOWrapper(TextIOWrapper): print() or input() calls in the parent process. """ - def close(self) -> None: + def close(self) -> None: # pragma: lax no cover # Intentionally not closing the underlying buffer. # The standard process handles should outlive the server. pass - def __del__(self) -> None: + def __del__(self) -> None: # pragma: lax no cover # Prevent TextIOWrapper.__del__ from calling close(). pass