Skip to content

Commit c688822

Browse files
committed
fix(stdio): use os.dup to avoid closing real stdin/stdout on server exit
TextIOWrapper wrapping sys.stdin.buffer closes the underlying buffer when the wrapper is garbage-collected. After stdio_server() exits, this made any subsequent print()/sys.stdin.read() raise ValueError on the closed fd. Duplicate the file descriptor with os.dup() so our TextIOWrapper owns a private copy; closing it leaves the original process stdin/stdout intact. Falls back to the original buffer when the stream has no real fd (e.g. BytesIO in tests), matching existing behaviour in that case. Fixes #1933.
1 parent f475344 commit c688822

2 files changed

Lines changed: 70 additions & 10 deletions

File tree

src/mcp/server/stdio.py

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ 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
@@ -34,14 +36,31 @@ async def stdio_server(stdin: anyio.AsyncFile[str] | None = None, stdout: anyio.
3436
"""Server transport for stdio: this communicates with an MCP client by reading
3537
from the current process' stdin and writing to stdout.
3638
"""
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.
39+
# Re-wrap the underlying binary stream to ensure UTF-8 (encoding is
40+
# platform-dependent on Windows). Use os.dup() to duplicate the file
41+
# descriptor so that closing our wrapper does not close the real process
42+
# stdin/stdout (issue #1933). Falls back to sharing the original buffer
43+
# when the stream is not backed by a real file descriptor (e.g. BytesIO
44+
# in tests); in that case we must not close the wrapper on exit.
45+
stdin_created = False
46+
stdout_created = False
47+
4148
if not stdin:
42-
stdin = anyio.wrap_file(TextIOWrapper(sys.stdin.buffer, encoding="utf-8", errors="replace"))
49+
stdin_buffer = sys.stdin.buffer
50+
try:
51+
stdin_buffer = os.fdopen(os.dup(stdin_buffer.fileno()), "rb")
52+
stdin_created = True
53+
except io.UnsupportedOperation:
54+
pass
55+
stdin = anyio.wrap_file(TextIOWrapper(stdin_buffer, encoding="utf-8", errors="replace"))
4356
if not stdout:
44-
stdout = anyio.wrap_file(TextIOWrapper(sys.stdout.buffer, encoding="utf-8"))
57+
stdout_buffer = sys.stdout.buffer
58+
try:
59+
stdout_buffer = os.fdopen(os.dup(stdout_buffer.fileno()), "wb")
60+
stdout_created = True
61+
except io.UnsupportedOperation:
62+
pass
63+
stdout = anyio.wrap_file(TextIOWrapper(stdout_buffer, encoding="utf-8"))
4564

4665
read_stream_writer, read_stream = create_context_streams[SessionMessage | Exception](0)
4766
write_stream, write_stream_reader = create_context_streams[SessionMessage](0)
@@ -71,7 +90,14 @@ async def stdout_writer():
7190
except anyio.ClosedResourceError: # pragma: no cover
7291
await anyio.lowlevel.checkpoint()
7392

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
93+
try:
94+
async with anyio.create_task_group() as tg:
95+
tg.start_soon(stdin_reader)
96+
tg.start_soon(stdout_writer)
97+
yield read_stream, write_stream
98+
finally:
99+
# Close the dup'd wrappers we own; do NOT close sys.stdin/sys.stdout.
100+
if stdin_created:
101+
await stdin.aclose()
102+
if stdout_created:
103+
await stdout.aclose()

tests/server/test_stdio.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import io
2+
import os
23
import sys
34
from io import TextIOWrapper
45

@@ -92,3 +93,36 @@ async def test_stdio_server_invalid_utf8(monkeypatch: pytest.MonkeyPatch):
9293
second = await read_stream.receive()
9394
assert isinstance(second, SessionMessage)
9495
assert second.message == valid
96+
97+
98+
@pytest.mark.anyio
99+
async def test_stdio_server_does_not_close_real_stdin(monkeypatch: pytest.MonkeyPatch):
100+
"""Regression test for issue #1933: stdio_server() must not close
101+
sys.stdin or sys.stdout after the context manager exits.
102+
"""
103+
# Use pipes so stdio_server() sees EOF immediately (no interactive stdin needed).
104+
in_r, in_w = os.pipe()
105+
out_r, out_w = os.pipe()
106+
os.close(in_w) # make stdin appear at EOF so stdin_reader exits immediately
107+
os.close(out_r) # we don't read server output in this test
108+
109+
fake_stdin_buf = os.fdopen(in_r, "rb")
110+
fake_stdout_buf = os.fdopen(out_w, "wb")
111+
# Keep references so we can close them explicitly (prevents ResourceWarning
112+
# when GC would otherwise finalize the file objects during teardown).
113+
fake_stdin = TextIOWrapper(fake_stdin_buf, encoding="utf-8")
114+
fake_stdout = TextIOWrapper(fake_stdout_buf, encoding="utf-8")
115+
monkeypatch.setattr(sys, "stdin", fake_stdin)
116+
monkeypatch.setattr(sys, "stdout", fake_stdout)
117+
118+
with anyio.fail_after(5):
119+
async with stdio_server() as (read_stream, write_stream):
120+
await write_stream.aclose()
121+
await read_stream.aclose()
122+
123+
assert not fake_stdin_buf.closed, "stdio_server closed sys.stdin.buffer — regression from issue #1933"
124+
assert not fake_stdout_buf.closed, "stdio_server closed sys.stdout.buffer"
125+
126+
# Explicit close before teardown; GC-based close of real fds triggers ResourceWarning.
127+
fake_stdin.close()
128+
fake_stdout.close()

0 commit comments

Comments
 (0)