Skip to content

Commit 52681d7

Browse files
fix: support fileno-less stdio streams
1 parent 52ed34a commit 52681d7

2 files changed

Lines changed: 51 additions & 9 deletions

File tree

src/mcp/server/stdio.py

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ async def run_server():
2020
import os
2121
import sys
2222
from contextlib import asynccontextmanager
23-
from io import TextIOWrapper
23+
from io import TextIOWrapper, UnsupportedOperation
2424

2525
import anyio
2626
import anyio.lowlevel
@@ -30,6 +30,33 @@ async def run_server():
3030
from mcp.shared.message import SessionMessage
3131

3232

33+
def _wrap_stdin() -> tuple[anyio.AsyncFile[str], bool]:
34+
"""Wrap stdin as UTF-8 text without closing process stdio on exit."""
35+
try:
36+
stdin_fd = os.dup(sys.stdin.fileno())
37+
except (AttributeError, OSError, UnsupportedOperation):
38+
# Some tests and embedders replace sys.stdin with fileno-less in-memory
39+
# streams. Keep supporting that shape by falling back to the existing
40+
# buffer-wrapping behavior.
41+
return anyio.wrap_file(TextIOWrapper(sys.stdin.buffer, encoding="utf-8", errors="replace")), False
42+
43+
stdin_buffer = os.fdopen(stdin_fd, "rb", closefd=True)
44+
return anyio.wrap_file(TextIOWrapper(stdin_buffer, encoding="utf-8", errors="replace")), True
45+
46+
47+
def _wrap_stdout() -> tuple[anyio.AsyncFile[str], bool]:
48+
"""Wrap stdout as UTF-8 text without closing process stdio on exit."""
49+
try:
50+
stdout_fd = os.dup(sys.stdout.fileno())
51+
except (AttributeError, OSError, UnsupportedOperation):
52+
# Match the fileno-less stdin fallback for in-memory test streams and
53+
# embedders that provide file-like stdout objects.
54+
return anyio.wrap_file(TextIOWrapper(sys.stdout.buffer, encoding="utf-8")), False
55+
56+
stdout_buffer = os.fdopen(stdout_fd, "wb", closefd=True)
57+
return anyio.wrap_file(TextIOWrapper(stdout_buffer, encoding="utf-8")), True
58+
59+
3360
@asynccontextmanager
3461
async def stdio_server(stdin: anyio.AsyncFile[str] | None = None, stdout: anyio.AsyncFile[str] | None = None):
3562
"""Server transport for stdio: this communicates with an MCP client by reading
@@ -42,15 +69,9 @@ async def stdio_server(stdin: anyio.AsyncFile[str] | None = None, stdout: anyio.
4269
close_stdin = False
4370
close_stdout = False
4471
if not stdin:
45-
stdin_fd = os.dup(sys.stdin.fileno())
46-
stdin_buffer = os.fdopen(stdin_fd, "rb", closefd=True)
47-
stdin = anyio.wrap_file(TextIOWrapper(stdin_buffer, encoding="utf-8", errors="replace"))
48-
close_stdin = True
72+
stdin, close_stdin = _wrap_stdin()
4973
if not stdout:
50-
stdout_fd = os.dup(sys.stdout.fileno())
51-
stdout_buffer = os.fdopen(stdout_fd, "wb", closefd=True)
52-
stdout = anyio.wrap_file(TextIOWrapper(stdout_buffer, encoding="utf-8"))
53-
close_stdout = True
74+
stdout, close_stdout = _wrap_stdout()
5475

5576
read_stream_writer, read_stream = create_context_streams[SessionMessage | Exception](0)
5677
write_stream, write_stream_reader = create_context_streams[SessionMessage](0)

tests/server/test_stdio.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,27 @@ async def test_stdio_server():
6464
assert received_responses[1] == JSONRPCResponse(jsonrpc="2.0", id=4, result={})
6565

6666

67+
@pytest.mark.anyio
68+
async def test_stdio_server_supports_fileno_less_standard_streams(monkeypatch: pytest.MonkeyPatch):
69+
"""The default path supports in-memory stdio replacements without fileno()."""
70+
request = JSONRPCRequest(jsonrpc="2.0", id=1, method="ping")
71+
raw_stdin = io.BytesIO(request.model_dump_json(by_alias=True, exclude_none=True).encode() + b"\n")
72+
raw_stdout = io.BytesIO()
73+
74+
test_stdin = TextIOWrapper(raw_stdin, encoding="utf-8")
75+
test_stdout = TextIOWrapper(raw_stdout, encoding="utf-8")
76+
monkeypatch.setattr(sys, "stdin", test_stdin)
77+
monkeypatch.setattr(sys, "stdout", test_stdout)
78+
79+
with anyio.fail_after(5):
80+
async with stdio_server() as (read_stream, write_stream):
81+
await write_stream.aclose()
82+
async with read_stream: # pragma: no branch
83+
message = await read_stream.receive()
84+
assert isinstance(message, SessionMessage)
85+
assert message.message == request
86+
87+
6788
@pytest.mark.anyio
6889
async def test_stdio_server_invalid_utf8():
6990
"""Non-UTF-8 bytes on stdin must not crash the server.

0 commit comments

Comments
 (0)