Skip to content

Commit 4e71f5d

Browse files
committed
fix: prevent stdio_server from closing real stdin/stdout
Duplicate stdin/stdout file descriptors with os.dup() before wrapping in TextIOWrapper. Without this, closing the wrapper also closes sys.stdin.buffer / sys.stdout.buffer, breaking any subsequent stdio operations in the caller process. Fixes #1933
1 parent 161834d commit 4e71f5d

2 files changed

Lines changed: 72 additions & 6 deletions

File tree

src/mcp/server/stdio.py

Lines changed: 22 additions & 6 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,28 @@ 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+
# Duplicate stdin/stdout file descriptors so that closing the wrappers does
40+
# not close the underlying process handles. Without dup(), TextIOWrapper
41+
# takes ownership of sys.stdin.buffer / sys.stdout.buffer and closes them on
42+
# __exit__, breaking any subsequent stdio operations in the caller (see #1933).
43+
#
44+
# When stdin/stdout lack real file descriptors (e.g. io.BytesIO in tests),
45+
# fall back to wrapping the buffer directly — there are no process handles
46+
# to protect in that case.
4147
if not stdin:
42-
stdin = anyio.wrap_file(TextIOWrapper(sys.stdin.buffer, encoding="utf-8", errors="replace"))
48+
try:
49+
stdin_fd = os.dup(sys.stdin.fileno())
50+
stdin_bin = os.fdopen(stdin_fd, "rb", closefd=True)
51+
except io.UnsupportedOperation:
52+
stdin_bin = sys.stdin.buffer
53+
stdin = anyio.wrap_file(TextIOWrapper(stdin_bin, encoding="utf-8", errors="replace"))
4354
if not stdout:
44-
stdout = anyio.wrap_file(TextIOWrapper(sys.stdout.buffer, encoding="utf-8"))
55+
try:
56+
stdout_fd = os.dup(sys.stdout.fileno())
57+
stdout_bin = os.fdopen(stdout_fd, "wb", closefd=True)
58+
except io.UnsupportedOperation:
59+
stdout_bin = sys.stdout.buffer
60+
stdout = anyio.wrap_file(TextIOWrapper(stdout_bin, encoding="utf-8"))
4561

4662
read_stream_writer, read_stream = create_context_streams[SessionMessage | Exception](0)
4763
write_stream, write_stream_reader = create_context_streams[SessionMessage](0)

tests/server/test_stdio.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
import sys
33
from io import TextIOWrapper
44

5+
from pathlib import Path
6+
57
import anyio
68
import pytest
79

@@ -63,6 +65,54 @@ async def test_stdio_server():
6365
assert received_responses[1] == JSONRPCResponse(jsonrpc="2.0", id=4, result={})
6466

6567

68+
@pytest.mark.anyio
69+
@pytest.mark.filterwarnings("default:unclosed file:ResourceWarning")
70+
async def test_stdio_server_does_not_close_sys_stdin_stdout(tmp_path: Path):
71+
"""Exiting stdio_server must not close the real sys.stdin / sys.stdout.
72+
73+
Regression test for https://github.com/modelcontextprotocol/python-sdk/issues/1933.
74+
Uses real file descriptors via os.pipe() to exercise the os.dup() path.
75+
"""
76+
import os
77+
78+
valid = JSONRPCRequest(jsonrpc="2.0", id=1, method="ping")
79+
payload = valid.model_dump_json(by_alias=True, exclude_none=True).encode() + b"\n"
80+
81+
# Create a pipe with the MCP message, and a temp file for stdout.
82+
read_fd, write_fd = os.pipe()
83+
os.write(write_fd, payload)
84+
os.close(write_fd)
85+
86+
stdout_path = tmp_path / "stdout.bin"
87+
stdout_fd = os.open(str(stdout_path), os.O_CREAT | os.O_WRONLY | os.O_TRUNC)
88+
89+
# Save originals and replace with our pipe/file.
90+
orig_stdin = sys.stdin
91+
orig_stdout = sys.stdout
92+
test_stdin = os.fdopen(read_fd, "rb")
93+
test_stdout = os.fdopen(stdout_fd, "wb")
94+
sys.stdin = test_stdin
95+
sys.stdout = test_stdout
96+
97+
try:
98+
with anyio.fail_after(5):
99+
async with stdio_server() as (read_stream, write_stream):
100+
await write_stream.aclose()
101+
async with read_stream:
102+
msg = await read_stream.receive()
103+
assert isinstance(msg, SessionMessage)
104+
105+
# After exiting the server, the original sys.stdin / sys.stdout must
106+
# still be usable — the wrappers must NOT have closed them.
107+
assert not sys.stdin.closed, "sys.stdin was closed by stdio_server"
108+
assert not sys.stdout.closed, "sys.stdout was closed by stdio_server"
109+
finally:
110+
sys.stdin = orig_stdin
111+
sys.stdout = orig_stdout
112+
test_stdin.close()
113+
test_stdout.close()
114+
115+
66116
@pytest.mark.anyio
67117
async def test_stdio_server_invalid_utf8(monkeypatch: pytest.MonkeyPatch):
68118
"""Non-UTF-8 bytes on stdin must not crash the server.

0 commit comments

Comments
 (0)