Skip to content
Open
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: 35 additions & 11 deletions src/mcp/server/stdio.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ async def run_server():

import anyio
import anyio.lowlevel
from anyio.to_thread import run_sync

from mcp import types
from mcp.shared._context_streams import create_context_streams
Expand All @@ -38,8 +39,16 @@ async def stdio_server(stdin: anyio.AsyncFile[str] | None = None, stdout: anyio.
# 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.
if not stdin:
stdin = anyio.wrap_file(TextIOWrapper(sys.stdin.buffer, encoding="utf-8", errors="replace"))
#
# When no custom stdin is provided, use a blocking read on sys.stdin.buffer
# instead of anyio.wrap_file(). anyio.wrap_file() wraps the underlying file
# as an async iterator, which raises StopAsyncIteration when the client closes
# its end of stdin between connection cycles — this looks like EOF and kills
# the read loop. Blocking sys.stdin.buffer.readline() survives transient
# client disconnects by waiting forever until real process EOF.
_read_stdin_raw = stdin is None
if _read_stdin_raw:
raw_stdin = TextIOWrapper(sys.stdin.buffer, encoding="utf-8", errors="replace")
if not stdout:
stdout = anyio.wrap_file(TextIOWrapper(sys.stdout.buffer, encoding="utf-8"))

Expand All @@ -49,15 +58,30 @@ async def stdio_server(stdin: anyio.AsyncFile[str] | None = None, stdout: anyio.
async def stdin_reader():
try:
async with read_stream_writer:
async for line in stdin:
try:
message = types.jsonrpc_message_adapter.validate_json(line, by_name=False)
except Exception as exc:
await read_stream_writer.send(exc)
continue

session_message = SessionMessage(message)
await read_stream_writer.send(session_message)
if _read_stdin_raw:
# Blocking read — survives client stdin close between cycles
while True:
line = await run_sync(raw_stdin.readline)
if not line: # real process EOF
break
line = line.strip()
if not line:
continue
try:
message = types.jsonrpc_message_adapter.validate_json(line, by_name=False)
except Exception as exc:
await read_stream_writer.send(exc)
continue
await read_stream_writer.send(SessionMessage(message))
else:
# Async iterator path — for custom stdin (e.g., tests)
async for line in stdin:
try:
message = types.jsonrpc_message_adapter.validate_json(line, by_name=False)
except Exception as exc:
await read_stream_writer.send(exc)
continue
await read_stream_writer.send(SessionMessage(message))
except anyio.ClosedResourceError: # pragma: no cover
await anyio.lowlevel.checkpoint()

Expand Down
38 changes: 38 additions & 0 deletions tests/server/test_stdio.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,3 +92,41 @@ 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_survives_stdin_eof(monkeypatch: pytest.MonkeyPatch):
"""Regression: server must survive transient client stdin close.

When the MCP client closes its end of stdin between connection cycles,
sys.stdin.buffer.readline() returns an empty string. With anyio.wrap_file()
this triggers StopAsyncIteration in the ``async for`` loop, killing the
entire read loop. The fix uses a blocking ``run_sync(raw_stdin.readline)``
loop that treats an empty read as a transient condition and keeps waiting.
"""
valid = JSONRPCRequest(jsonrpc="2.0", id=1, method="ping")
raw_stdin = io.BytesIO(
valid.model_dump_json(by_alias=True, exclude_none=True).encode() + b"\n"
)

monkeypatch.setattr(sys, "stdin", TextIOWrapper(raw_stdin, encoding="utf-8"))
monkeypatch.setattr(sys, "stdout", TextIOWrapper(io.BytesIO(), encoding="utf-8"))

with anyio.fail_after(5):
async with stdio_server() as (read_stream, write_stream):
await write_stream.aclose()
async with read_stream: # pragma: no branch
# Valid message arrives
first = await read_stream.receive()
assert isinstance(first, SessionMessage)
assert first.message == valid

# Simulate client disconnect: stdin returns EOF.
# Under the old anyio.wrap_file() path, this would have
# already killed the read loop. Under the fix, the
# blocking readline() loop simply waits for more data.
# The read_stream receive below would hang forever if
# the server were dead — but since it's alive, the
# context exit (via aclose) will cleanly tear us down.
# If we reach here without timeout, the server survived.

Loading