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
22 changes: 13 additions & 9 deletions src/mcp/server/stdio.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,18 +48,22 @@ 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
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)
session_message = SessionMessage(message)
await read_stream_writer.send(session_message)
except anyio.ClosedResourceError: # pragma: no cover
await anyio.lowlevel.checkpoint()
finally:
# Close the read stream to signal EOF to the server. Any pending
# server responses are already buffered in write_stream_reader and
# will drain through stdout_writer before the task group exits.
await read_stream_writer.aclose()

async def stdout_writer():
try:
Expand Down
56 changes: 56 additions & 0 deletions tests/server/test_stdio_2678.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"""Regression test for #2678: in-flight responses should not be dropped on stdin EOF.

When a server receives a request and stdin hits EOF while the server is still
processing, the response must still be written to stdout. The fix closes
read_stream_writer in stdin_reader's finally block so the server sees EOF and
can flush pending writes before the task group exits.
"""

import io
import sys
import threading
from io import TextIOWrapper

import pytest

from mcp.server.mcpserver import MCPServer
from mcp.types import (
JSONRPCRequest,
JSONRPCResponse,
jsonrpc_message_adapter,
)


class _KeepOpenBytesIO(io.BytesIO):
"""A BytesIO that survives its TextIOWrapper being closed."""

def close(self) -> None:
pass


def _run_stdio_bounded(server: MCPServer, timeout: float = 5) -> None:
def target() -> None:
server.run("stdio")

thread = threading.Thread(target=target, daemon=True)
thread.start()
thread.join(timeout)
assert not thread.is_alive(), "run('stdio') did not return after stdin EOF"


def test_stdio_response_not_dropped_on_eof(monkeypatch: pytest.MonkeyPatch) -> None:
"""Server response is written to stdout even when stdin closes right after the request.

Regression test for #2678: stdin EOF used to close read_stream_writer before
the server could flush its response through stdout_writer.
"""
ping = JSONRPCRequest(jsonrpc="2.0", id=1, method="ping")
stdin_bytes = io.BytesIO(ping.model_dump_json(by_alias=True, exclude_none=True).encode() + b"\n")
captured = _KeepOpenBytesIO()
monkeypatch.setattr(sys, "stdin", TextIOWrapper(stdin_bytes, encoding="utf-8"))
monkeypatch.setattr(sys, "stdout", TextIOWrapper(captured, encoding="utf-8"))

_run_stdio_bounded(MCPServer(name="TestEOF"))

response = jsonrpc_message_adapter.validate_json(captured.getvalue().decode().strip())
assert response == JSONRPCResponse(jsonrpc="2.0", id=1, result={})
Loading