Fix stdio client shutdown bugs and rebuild the stdio test suite (#2773)

This commit is contained in:
Max 2026-06-05 16:15:43 +01:00 • committed by GitHub
commit bdc48e98b1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -1,18 +1,13 @@
"""The stdio transport: one subprocess end-to-end test and one in-process framing test.
Everything else in the suite runs in a single process; the subprocess test exists to prove the same
client↔server round trip works over the stdio transport's real boundary (a child process whose
stdin/stdout carry one newline-delimited JSON-RPC message per line). The server lives in
`_stdio_server.py` and is launched via `python -m` so subprocess coverage measurement applies.
The subprocess test proves the client-server round trip over the transport's real process
boundary; its server lives in `_stdio_server.py` and is launched via `python -m` so subprocess
coverage measurement applies. The framing test drives `stdio_server` over injected in-process
streams instead.
The framing test drives `stdio_server` in-process by passing it injected text streams instead of the
real stdin/stdout, so the raw lines the transport writes can be asserted directly without a process
boundary.
stdio is deliberately not a leg of the `connect`-fixture matrix: spawning a subprocess per test
would be slow, and the matrix already proves transport-agnosticism over three in-process
transports. Process-lifecycle edge cases (escalation to terminate/kill, parse errors) are covered by
`tests/client/test_stdio.py` and stay deferred here.
stdio is deliberately not a leg of the `connect`-fixture matrix: a subprocess per test would be
slow, and the matrix already proves transport-agnosticism in-process. Process-lifecycle edge
cases (terminate/kill escalation, parse errors) stay in `tests/client/test_stdio.py`.
"""
import io
@ -26,6 +21,7 @@ import anyio
import pytest
from inline_snapshot import snapshot
from mcp.client import stdio
from mcp.client.client import Client
from mcp.client.stdio import StdioServerParameters, stdio_client
from mcp.server.stdio import stdio_server
@ -51,10 +47,21 @@ _REPO_ROOT = Path(__file__).parents[3]
@requirement("transport:stdio")
@requirement("transport:stdio:clean-shutdown")
@requirement("transport:stdio:stderr-passthrough")
async def test_tool_call_and_notification_round_trip_over_a_stdio_subprocess() -> None:
"""A Client connected over stdio initializes, calls a tool with arguments, receives the
server's log notification before the call returns, and the server exits when the transport
closes its stdin."""
async def test_tool_call_and_notification_round_trip_over_a_stdio_subprocess(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A stdio-subprocess Client round-trips a tool call, a notification, and a clean exit.
The Client initializes, calls a tool with arguments, and receives the server's log
notification before the call returns; the server exits when the transport closes its
stdin.
"""
# After stdin closes, the child must unwind, write the clean-exit line, and let coverage's
# atexit hook persist its subprocess data file before escalation. The production 2s default
# was too tight on slow Windows runners: the child was killed mid-atexit (test stayed green)
# and the silently missing data file tripped the 100% coverage gate. Not under test.
monkeypatch.setattr(stdio, "PROCESS_TERMINATION_TIMEOUT", 10.0)
received: list[LoggingMessageNotificationParams] = []
async def collect(params: LoggingMessageNotificationParams) -> None:
@ -66,15 +73,18 @@ async def test_tool_call_and_notification_round_trip_over_a_stdio_subprocess() -
command=sys.executable,
args=["-m", _stdio_server.__name__],
cwd=str(_REPO_ROOT),
# stdio_client deliberately filters the inherited environment to a safe minimum,
# which drops the variables coverage.py's subprocess support uses; pass them through
# so the server module is measured. Empty when not running under coverage.
env={key: value for key, value in os.environ.items() if key.startswith("COVERAGE_")},
# stdio_client filters the inherited environment, dropping the variables
# coverage.py's subprocess support uses; pass them through so the server module is
# measured. PYTHONWARNINGS: the child recompiles anyio (pytest's pyc tag differs),
# and on 3.14 anyio's return-in-finally SyntaxWarning would land on the snapshot stderr.
env={key: value for key, value in os.environ.items() if key.startswith("COVERAGE_")}
| {"PYTHONWARNINGS": "ignore::SyntaxWarning"},
),
errlog=errlog,
)
with anyio.fail_after(10):
# Must exceed session time plus the patched PROCESS_TERMINATION_TIMEOUT (10s).
with anyio.fail_after(20):
async with Client(transport, logging_callback=collect) as client:
assert client.initialize_result.server_info.name == "stdio-echo"
result = await client.call_tool("echo", {"text": "across\nprocesses"})
@ -83,28 +93,23 @@ async def test_tool_call_and_notification_round_trip_over_a_stdio_subprocess() -
captured_stderr = errlog.read()
assert result == snapshot(CallToolResult(content=[TextContent(text="across\nprocesses")]))
# stdio carries one ordered server→client stream, so the same notification-before-response
# stdio carries one ordered server-to-client stream, so the same notification-before-response
# guarantee holds here as for the in-memory transport.
assert received == snapshot(
[LoggingMessageNotificationParams(level="info", logger="echo", data="echoing across\nprocesses")]
)
# The server writes this line only after its run loop returns, which happens when stdin closes:
# seeing it proves the process exited on its own rather than via the transport's terminate
# escalation, without a timing-based assertion. The capture itself proves stderr passthrough:
# the transport routes the child's stderr to the caller's `errlog` without consuming it.
# The server writes this line only after its run loop returns on stdin close: seeing it proves
# a self-exit, not the terminate escalation. The capture itself proves stderr passthrough.
assert captured_stderr == snapshot("stdio-echo: clean exit\n")
@requirement("transport:stdio:stream-purity")
@requirement("transport:stdio:no-embedded-newlines")
async def test_stdio_server_writes_one_jsonrpc_message_per_line() -> None:
"""Everything `stdio_server` writes is a valid JSON-RPC message on its own line, and nothing else.
"""Every `stdio_server` write is one valid JSON-RPC message on its own line.
The transport's stdin/stdout parameters are public, so the test injects in-process text streams
instead of the real process handles and drives the read/write streams directly: a JSON-RPC line on
stdin is parsed and delivered, and every message sent on the write stream appears as exactly one
newline-terminated line whose payload newlines are JSON-escaped. This proves the transport's own
framing; it does not guard `sys.stdout` against handler code that prints to it directly (see the
Each line is newline-terminated with payload newlines JSON-escaped. This proves the
transport's own framing; it does not guard `sys.stdout` against handler code (see the
divergence on `transport:stdio:stream-purity`).
"""
captured = io.StringIO()