Skip to content

Commit 7f193d0

Browse files
committed
fix: preserve real stdin/stdout after stdio server exits
When running the MCP server with transport='stdio', closing the server would also close the process's real stdin/stdout handles. This caused ValueError when trying to use stdio after the server exits. The issue was that wrapping sys.stdin.buffer/sys.stdout.buffer with TextIOWrapper causes the underlying buffer to be closed when the wrapper is garbage collected, even if we don't use a context manager. Fix: Use os.dup() to duplicate the file descriptors before wrapping. When the duplicated descriptors are closed, the original stdin/stdout remain intact and usable. Fallback: For streams without a fileno() (e.g., BytesIO in tests), we fall back to wrapping them directly (previous behavior). Fixes #1933
1 parent 92c693b commit 7f193d0

2 files changed

Lines changed: 79 additions & 6 deletions

File tree

src/mcp/server/stdio.py

Lines changed: 21 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,27 @@ 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+
# We duplicate file descriptors when using the real stdin/stdout to avoid
40+
# closing the process's standard handles when the TextIOWrapper is closed.
41+
# This allows the process to continue using stdio normally after the server exits.
42+
# For streams without a fileno() (e.g., in-memory streams in tests), we fall back
43+
# to wrapping them directly.
4144
if not stdin:
42-
stdin = anyio.wrap_file(TextIOWrapper(sys.stdin.buffer, encoding="utf-8", errors="replace"))
45+
try:
46+
stdin_fd = os.dup(sys.stdin.fileno())
47+
stdin_bin = os.fdopen(stdin_fd, "rb", closefd=True)
48+
stdin = anyio.wrap_file(TextIOWrapper(stdin_bin, encoding="utf-8", errors="replace"))
49+
except (OSError, io.UnsupportedOperation):
50+
# Fallback for streams that don't support fileno() (e.g., BytesIO in tests)
51+
stdin = anyio.wrap_file(TextIOWrapper(sys.stdin.buffer, encoding="utf-8", errors="replace"))
4352
if not stdout:
44-
stdout = anyio.wrap_file(TextIOWrapper(sys.stdout.buffer, encoding="utf-8"))
53+
try:
54+
stdout_fd = os.dup(sys.stdout.fileno())
55+
stdout_bin = os.fdopen(stdout_fd, "wb", closefd=True)
56+
stdout = anyio.wrap_file(TextIOWrapper(stdout_bin, encoding="utf-8"))
57+
except (OSError, io.UnsupportedOperation):
58+
# Fallback for streams that don't support fileno() (e.g., BytesIO in tests)
59+
stdout = anyio.wrap_file(TextIOWrapper(sys.stdout.buffer, encoding="utf-8"))
4560

4661
read_stream: MemoryObjectReceiveStream[SessionMessage | Exception]
4762
read_stream_writer: MemoryObjectSendStream[SessionMessage | Exception]

tests/server/test_stdio.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import io
2+
import os
23
import sys
4+
import tempfile
5+
import warnings
36
from io import TextIOWrapper
47

58
import anyio
@@ -92,3 +95,58 @@ async def test_stdio_server_invalid_utf8(monkeypatch: pytest.MonkeyPatch):
9295
second = await read_stream.receive()
9396
assert isinstance(second, SessionMessage)
9497
assert second.message == valid
98+
99+
100+
@pytest.mark.anyio
101+
@pytest.mark.filterwarnings("ignore::pytest.PytestUnraisableExceptionWarning")
102+
async def test_stdio_server_does_not_close_real_stdio(monkeypatch: pytest.MonkeyPatch):
103+
"""Verify that stdio_server does not close the real stdin/stdout.
104+
105+
Regression test for https://github.com/modelcontextprotocol/python-sdk/issues/1933.
106+
When using the default stdin/stdout (i.e., not passing custom streams),
107+
the server should duplicate file descriptors so that closing the wrapper
108+
does not close sys.stdin/sys.stdout.
109+
"""
110+
# Create temp files to use as stdin/stdout (need real file descriptors)
111+
with tempfile.NamedTemporaryFile(delete=False) as tmp_stdin:
112+
tmp_stdin.write(b'{"jsonrpc":"2.0","id":1,"method":"ping"}\n')
113+
tmp_stdin_path = tmp_stdin.name
114+
115+
with tempfile.NamedTemporaryFile(delete=False) as tmp_stdout:
116+
tmp_stdout_path = tmp_stdout.name
117+
118+
stdin_wrapper = None
119+
stdout_wrapper = None
120+
121+
try:
122+
# Open the files and create wrappers that look like sys.stdin/stdout
123+
stdin_file = open(tmp_stdin_path, "rb")
124+
stdout_file = open(tmp_stdout_path, "wb")
125+
126+
stdin_wrapper = TextIOWrapper(stdin_file, encoding="utf-8")
127+
stdout_wrapper = TextIOWrapper(stdout_file, encoding="utf-8")
128+
129+
monkeypatch.setattr(sys, "stdin", stdin_wrapper)
130+
monkeypatch.setattr(sys, "stdout", stdout_wrapper)
131+
132+
# Run the server with default stdin/stdout
133+
with anyio.fail_after(5):
134+
async with stdio_server() as (read_stream, write_stream):
135+
await write_stream.aclose()
136+
async with read_stream:
137+
msg = await read_stream.receive()
138+
assert isinstance(msg, SessionMessage)
139+
140+
# After server exits, verify the original stdin/stdout are still usable
141+
# The monkeypatched sys.stdin/stdout should NOT be closed
142+
assert not stdin_wrapper.closed, "sys.stdin was closed by stdio_server"
143+
assert not stdout_wrapper.closed, "sys.stdout was closed by stdio_server"
144+
145+
finally:
146+
# Clean up
147+
if stdin_wrapper and not stdin_wrapper.closed:
148+
stdin_wrapper.close()
149+
if stdout_wrapper and not stdout_wrapper.closed:
150+
stdout_wrapper.close()
151+
os.unlink(tmp_stdin_path)
152+
os.unlink(tmp_stdout_path)

0 commit comments

Comments
 (0)