Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 36 additions & 10 deletions src/mcp/server/stdio.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ async def run_server():
```
"""

import io
import os
import sys
from contextlib import asynccontextmanager
from io import TextIOWrapper
Expand All @@ -34,14 +36,31 @@ async def stdio_server(stdin: anyio.AsyncFile[str] | None = None, stdout: anyio.
"""Server transport for stdio: this communicates with an MCP client by reading
from the current process' stdin and writing to stdout.
"""
# Purposely not using context managers for these, as we don't want to close
# standard process handles. Encoding of stdin/stdout as text streams on
# python is platform-dependent (Windows is particularly problematic), so we
# re-wrap the underlying binary stream to ensure UTF-8.
# Re-wrap the underlying binary stream to ensure UTF-8 (encoding is
# platform-dependent on Windows). Use os.dup() to duplicate the file
# descriptor so that closing our wrapper does not close the real process
# stdin/stdout (issue #1933). Falls back to sharing the original buffer
# when the stream is not backed by a real file descriptor (e.g. BytesIO
# in tests); in that case we must not close the wrapper on exit.
stdin_created = False
stdout_created = False

if not stdin:
stdin = anyio.wrap_file(TextIOWrapper(sys.stdin.buffer, encoding="utf-8", errors="replace"))
stdin_buffer = sys.stdin.buffer
try:
stdin_buffer = os.fdopen(os.dup(stdin_buffer.fileno()), "rb")
stdin_created = True
except io.UnsupportedOperation:
pass
stdin = anyio.wrap_file(TextIOWrapper(stdin_buffer, encoding="utf-8", errors="replace"))
if not stdout:
stdout = anyio.wrap_file(TextIOWrapper(sys.stdout.buffer, encoding="utf-8"))
stdout_buffer = sys.stdout.buffer
try:
stdout_buffer = os.fdopen(os.dup(stdout_buffer.fileno()), "wb")
stdout_created = True
except io.UnsupportedOperation:
pass
stdout = anyio.wrap_file(TextIOWrapper(stdout_buffer, encoding="utf-8"))

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

async with anyio.create_task_group() as tg:
tg.start_soon(stdin_reader)
tg.start_soon(stdout_writer)
yield read_stream, write_stream
try:
async with anyio.create_task_group() as tg:
tg.start_soon(stdin_reader)
tg.start_soon(stdout_writer)
yield read_stream, write_stream
finally:
# Close the dup'd wrappers we own; do NOT close sys.stdin/sys.stdout.
if stdin_created:
await stdin.aclose()
if stdout_created:
await stdout.aclose()
34 changes: 34 additions & 0 deletions tests/server/test_stdio.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import io
import os
import sys
from io import TextIOWrapper

Expand Down Expand Up @@ -92,3 +93,36 @@ async def test_stdio_server_invalid_utf8(monkeypatch: pytest.MonkeyPatch):
second = await read_stream.receive()
assert isinstance(second, SessionMessage)
assert second.message == valid


@pytest.mark.anyio
async def test_stdio_server_does_not_close_real_stdin(monkeypatch: pytest.MonkeyPatch):
"""Regression test for issue #1933: stdio_server() must not close
sys.stdin or sys.stdout after the context manager exits.
"""
# Use pipes so stdio_server() sees EOF immediately (no interactive stdin needed).
in_r, in_w = os.pipe()
out_r, out_w = os.pipe()
os.close(in_w) # make stdin appear at EOF so stdin_reader exits immediately
os.close(out_r) # we don't read server output in this test

fake_stdin_buf = os.fdopen(in_r, "rb")
fake_stdout_buf = os.fdopen(out_w, "wb")
# Keep references so we can close them explicitly (prevents ResourceWarning
# when GC would otherwise finalize the file objects during teardown).
fake_stdin = TextIOWrapper(fake_stdin_buf, encoding="utf-8")
fake_stdout = TextIOWrapper(fake_stdout_buf, encoding="utf-8")
monkeypatch.setattr(sys, "stdin", fake_stdin)
monkeypatch.setattr(sys, "stdout", fake_stdout)

with anyio.fail_after(5):
async with stdio_server() as (read_stream, write_stream):
await write_stream.aclose()
await read_stream.aclose()

assert not fake_stdin_buf.closed, "stdio_server closed sys.stdin.buffer — regression from issue #1933"
assert not fake_stdout_buf.closed, "stdio_server closed sys.stdout.buffer"

# Explicit close before teardown; GC-based close of real fds triggers ResourceWarning.
fake_stdin.close()
fake_stdout.close()
Loading