Skip to content

Commit 0e48b3e

Browse files
steps-reclaude
andcommitted
fix(server): don't close real process stdio in stdio_server (#1933)
stdio_server re-wraps sys.stdin.buffer/sys.stdout.buffer in a TextIOWrapper to force UTF-8. The wrapper's __del__ finalizer closes the buffer it wraps, so once the wrapper is garbage-collected the real sys.stdin/sys.stdout is closed and any code that runs after the server exits raises "ValueError: I/O operation on closed file." on print() or a stdout write. Dup the underlying fd (os.dup, Windows-safe) and wrap the copy, so the wrapper only ever closes the duplicate; close those wrappers on exit to free the dup'd fds. When sys.std* has no real fd (pytest capture, embedded interpreters, injected in-memory streams) fall back to wrapping .buffer and detach() the wrapper on exit, which severs it from the buffer without closing it so the finalizer can no longer close the real handle either. Adds a regression test asserting the real stdin/stdout buffers survive after the transport exits. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 1216c53 commit 0e48b3e

2 files changed

Lines changed: 91 additions & 10 deletions

File tree

src/mcp/server/stdio.py

Lines changed: 48 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,12 @@ async def run_server():
1717
```
1818
"""
1919

20+
import io
21+
import os
2022
import sys
2123
from contextlib import asynccontextmanager
2224
from io import TextIOWrapper
25+
from typing import TextIO
2326

2427
import anyio
2528
import anyio.lowlevel
@@ -34,14 +37,39 @@ async def stdio_server(stdin: anyio.AsyncFile[str] | None = None, stdout: anyio.
3437
"""Server transport for stdio: this communicates with an MCP client by reading
3538
from the current process' stdin and writing to stdout.
3639
"""
37-
# Purposely not using context managers for these, as we don't want to close
38-
# standard process handles. Encoding of stdin/stdout as text streams on
39-
# python is platform-dependent (Windows is particularly problematic), so we
40-
# re-wrap the underlying binary stream to ensure UTF-8.
40+
# We don't want to close the process' standard handles. Wrapping
41+
# sys.std{in,out}.buffer in a TextIOWrapper is not enough on its own: the
42+
# wrapper's __del__ finalizer closes the buffer it wraps, so once the wrapper
43+
# is garbage-collected the real sys.std{in,out} is closed and any later
44+
# print()/read raises "ValueError: I/O operation on closed file." (#1933).
45+
# Encoding of stdin/stdout as text streams on python is platform-dependent
46+
# (Windows is particularly problematic), so we still re-wrap the underlying
47+
# binary stream to ensure UTF-8.
48+
#
49+
# Preferred path: dup the underlying fd (os.dup is Windows-safe) and wrap the
50+
# copy, so the wrapper only ever closes the duplicate; we close those wrappers
51+
# on exit to free the dup'd fds. When sys.std* has no real fd (pytest capture,
52+
# embedded interpreters, injected in-memory streams), we fall back to wrapping
53+
# .buffer directly and detach() the wrapper on exit, which severs it from the
54+
# buffer without closing it -- so the finalizer can no longer close the real
55+
# handle either.
56+
to_close: list[TextIOWrapper] = []
57+
to_detach: list[TextIOWrapper] = []
58+
59+
def wrap_std(std: TextIO, mode: str, errors: str | None) -> anyio.AsyncFile[str]:
60+
try:
61+
binary = open(os.dup(std.fileno()), mode + "b", closefd=True)
62+
wrapper = TextIOWrapper(binary, encoding="utf-8", errors=errors)
63+
to_close.append(wrapper)
64+
except (AttributeError, OSError, ValueError, io.UnsupportedOperation):
65+
wrapper = TextIOWrapper(std.buffer, encoding="utf-8", errors=errors)
66+
to_detach.append(wrapper)
67+
return anyio.wrap_file(wrapper)
68+
4169
if not stdin:
42-
stdin = anyio.wrap_file(TextIOWrapper(sys.stdin.buffer, encoding="utf-8", errors="replace"))
70+
stdin = wrap_std(sys.stdin, "r", errors="replace")
4371
if not stdout:
44-
stdout = anyio.wrap_file(TextIOWrapper(sys.stdout.buffer, encoding="utf-8"))
72+
stdout = wrap_std(sys.stdout, "w", errors=None)
4573

4674
read_stream_writer, read_stream = create_context_streams[SessionMessage | Exception](0)
4775
write_stream, write_stream_reader = create_context_streams[SessionMessage](0)
@@ -71,7 +99,17 @@ async def stdout_writer():
7199
except anyio.ClosedResourceError: # pragma: no cover
72100
await anyio.lowlevel.checkpoint()
73101

74-
async with anyio.create_task_group() as tg:
75-
tg.start_soon(stdin_reader)
76-
tg.start_soon(stdout_writer)
77-
yield read_stream, write_stream
102+
try:
103+
async with anyio.create_task_group() as tg:
104+
tg.start_soon(stdin_reader)
105+
tg.start_soon(stdout_writer)
106+
yield read_stream, write_stream
107+
finally:
108+
# Close the wrappers around dup'd fds (frees the duplicate) and detach the
109+
# wrappers around the real .buffer (severs them without closing the real
110+
# handle, so their finalizers can't close it either). Neither touches the
111+
# process' standard handles or a caller-injected stream.
112+
for wrapper in to_close:
113+
wrapper.close()
114+
for wrapper in to_detach:
115+
wrapper.detach()

tests/server/test_stdio.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import gc
12
import io
23
import sys
4+
import tempfile
35
import threading
46
from collections.abc import AsyncIterator
57
from contextlib import asynccontextmanager
@@ -75,6 +77,47 @@ async def test_stdio_server_round_trips_messages_over_injected_streams() -> None
7577
assert received_responses[1] == JSONRPCResponse(jsonrpc="2.0", id=4, result={})
7678

7779

80+
@pytest.mark.anyio
81+
async def test_stdio_server_does_not_close_real_std_handles(monkeypatch: pytest.MonkeyPatch) -> None:
82+
"""Regression test for issue #1933: the default path must not close process stdio.
83+
84+
`stdio_server()` re-wraps `sys.stdin.buffer`/`sys.stdout.buffer` in a `TextIOWrapper`
85+
to force UTF-8. The wrapper's `__del__` finalizer used to close the underlying buffer,
86+
so any code running after the server exits raised
87+
`ValueError: I/O operation on closed file.` on `print()` / stdout writes.
88+
89+
The default path dups the fds and wraps the copies, so tearing the wrappers down
90+
closes only the duplicates and leaves the real process handles usable. Real
91+
fd-backed temp files stand in for the process handles here so that the fix's
92+
`sys.std{in,out}.fileno()` path is exercised; the empty stdin file reaches EOF
93+
immediately so the reader worker unwinds without a client.
94+
"""
95+
with (
96+
tempfile.TemporaryFile(mode="rb", buffering=0) as stdin_raw,
97+
tempfile.TemporaryFile(mode="wb", buffering=0) as stdout_raw,
98+
):
99+
stdin_file = TextIOWrapper(stdin_raw, encoding="utf-8") # empty -> immediate EOF
100+
stdout_file = TextIOWrapper(stdout_raw, encoding="utf-8")
101+
monkeypatch.setattr(sys, "stdin", stdin_file)
102+
monkeypatch.setattr(sys, "stdout", stdout_file)
103+
104+
with anyio.fail_after(5):
105+
async with stdio_server() as (read_stream, write_stream):
106+
async with read_stream, write_stream:
107+
pass
108+
109+
# Force finalization of any TextIOWrapper the server created internally.
110+
gc.collect()
111+
112+
# On the buggy code these assertions fail: the internal wrapper's __del__
113+
# closed the buffer we handed in.
114+
assert not stdin_raw.closed, "stdio_server closed the real stdin buffer"
115+
assert not stdout_raw.closed, "stdio_server closed the real stdout buffer"
116+
# And the process handles stay usable after the server exits.
117+
stdout_file.write("after-server-exit\n")
118+
stdout_file.flush()
119+
120+
78121
@pytest.mark.anyio
79122
async def test_stdio_server_invalid_utf8(monkeypatch: pytest.MonkeyPatch) -> None:
80123
"""Non-UTF-8 stdin bytes surface as an in-stream exception without killing the stream.

0 commit comments

Comments
 (0)