Skip to content

Commit 193c4f5

Browse files
committed
fix: use os.dup to avoid closing real stdin/stdout in stdio_server
Fixes #1933 When stdio_server() wraps sys.stdin.buffer/sys.stdout.buffer directly, closing the context manager also closes the real file descriptors, causing ValueError on subsequent I/O operations. Fix: Use os.dup() to duplicate file descriptors before wrapping them. This ensures closing the wrappers doesn't affect the real stdin/stdout. Added fallback for environments where fileno() is not available (e.g., BytesIO-backed streams in tests).
1 parent 616476f commit 193c4f5

3 files changed

Lines changed: 1363 additions & 1331 deletions

File tree

src/mcp/server/stdio.py

Lines changed: 20 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,26 @@ 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+
# Use os.dup() to duplicate file descriptors so that closing the wrappers
40+
# doesn't close the real stdin/stdout. This allows the caller to continue
41+
# using stdin/stdout after the server exits.
4142
if not stdin:
42-
stdin = anyio.wrap_file(TextIOWrapper(sys.stdin.buffer, encoding="utf-8", errors="replace"))
43+
try:
44+
stdin_fd = os.dup(sys.stdin.fileno())
45+
stdin_bin = os.fdopen(stdin_fd, "rb", closefd=True)
46+
stdin = anyio.wrap_file(TextIOWrapper(stdin_bin, encoding="utf-8", errors="replace"))
47+
except (io.UnsupportedOperation, ValueError):
48+
# Fallback for environments where fileno() is not available
49+
# (e.g., BytesIO-backed streams in tests)
50+
stdin = anyio.wrap_file(TextIOWrapper(sys.stdin.buffer, encoding="utf-8", errors="replace"))
4351
if not stdout:
44-
stdout = anyio.wrap_file(TextIOWrapper(sys.stdout.buffer, encoding="utf-8"))
52+
try:
53+
stdout_fd = os.dup(sys.stdout.fileno())
54+
stdout_bin = os.fdopen(stdout_fd, "wb", closefd=True)
55+
stdout = anyio.wrap_file(TextIOWrapper(stdout_bin, encoding="utf-8"))
56+
except (io.UnsupportedOperation, ValueError):
57+
# Fallback for environments where fileno() is not available
58+
stdout = anyio.wrap_file(TextIOWrapper(sys.stdout.buffer, encoding="utf-8"))
4559

4660
read_stream_writer, read_stream = create_context_streams[SessionMessage | Exception](0)
4761
write_stream, write_stream_reader = create_context_streams[SessionMessage](0)

tests/server/test_stdio.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,3 +92,21 @@ async def test_stdio_server_invalid_utf8(monkeypatch: pytest.MonkeyPatch):
9292
second = await read_stream.receive()
9393
assert isinstance(second, SessionMessage)
9494
assert second.message == valid
95+
96+
97+
@pytest.mark.anyio
98+
async def test_stdio_server_uses_os_dup():
99+
"""Verify stdio_server uses os.dup to avoid closing real stdin/stdout.
100+
101+
This verifies the fix for https://github.com/modelcontextprotocol/python-sdk/issues/1933
102+
where wrapping sys.stdin.buffer/sys.stdout.buffer directly caused the real
103+
file descriptors to be closed when the context manager exited.
104+
"""
105+
import inspect
106+
107+
from mcp.server import stdio as stdio_module
108+
109+
source = inspect.getsource(stdio_module.stdio_server)
110+
# The fix uses os.dup to duplicate file descriptors
111+
assert "os.dup" in source
112+
assert "os.fdopen" in source

0 commit comments

Comments
 (0)