|
| 1 | +"""The suite's one stdio end-to-end test: a real SDK Server in a subprocess, driven by Client. |
| 2 | +
|
| 3 | +Everything else in the suite runs in a single process; this test exists to prove the same |
| 4 | +client↔server round trip works over the stdio transport's real boundary (a child process whose |
| 5 | +stdin/stdout carry one newline-delimited JSON-RPC message per line). The server lives in |
| 6 | +`_stdio_server.py` and is launched via `python -m` so subprocess coverage measurement applies. |
| 7 | +
|
| 8 | +stdio is deliberately not a leg of the `connect`-fixture matrix: spawning a subprocess per test |
| 9 | +would be slow, and the matrix already proves transport-agnosticism over three in-process |
| 10 | +transports. Process-lifecycle edge cases (escalation to terminate/kill, stderr handling, parse |
| 11 | +errors) are covered by `tests/client/test_stdio.py` and stay deferred here. |
| 12 | +""" |
| 13 | + |
| 14 | +import os |
| 15 | +import sys |
| 16 | +import tempfile |
| 17 | +from pathlib import Path |
| 18 | + |
| 19 | +import anyio |
| 20 | +import pytest |
| 21 | +from inline_snapshot import snapshot |
| 22 | + |
| 23 | +from mcp.client.client import Client |
| 24 | +from mcp.client.stdio import StdioServerParameters, stdio_client |
| 25 | +from mcp.types import CallToolResult, LoggingMessageNotificationParams, TextContent |
| 26 | +from tests.interaction._requirements import requirement |
| 27 | +from tests.interaction.transports import _stdio_server |
| 28 | + |
| 29 | +pytestmark = pytest.mark.anyio |
| 30 | + |
| 31 | +_REPO_ROOT = Path(__file__).parents[3] |
| 32 | + |
| 33 | + |
| 34 | +@requirement("transport:stdio") |
| 35 | +@requirement("transport:stdio:clean-shutdown") |
| 36 | +async def test_tool_call_and_notification_round_trip_over_a_stdio_subprocess() -> None: |
| 37 | + """A Client connected over stdio initializes, calls a tool with arguments, receives the |
| 38 | + server's log notification before the call returns, and the server exits when the transport |
| 39 | + closes its stdin.""" |
| 40 | + received: list[LoggingMessageNotificationParams] = [] |
| 41 | + |
| 42 | + async def collect(params: LoggingMessageNotificationParams) -> None: |
| 43 | + received.append(params) |
| 44 | + |
| 45 | + with tempfile.TemporaryFile(mode="w+") as errlog: |
| 46 | + transport = stdio_client( |
| 47 | + StdioServerParameters( |
| 48 | + command=sys.executable, |
| 49 | + args=["-m", _stdio_server.__name__], |
| 50 | + cwd=str(_REPO_ROOT), |
| 51 | + # stdio_client deliberately filters the inherited environment to a safe minimum, |
| 52 | + # which drops the variables coverage.py's subprocess support uses; pass them through |
| 53 | + # so the server module is measured. Empty when not running under coverage. |
| 54 | + env={key: value for key, value in os.environ.items() if key.startswith("COVERAGE_")}, |
| 55 | + ), |
| 56 | + errlog=errlog, |
| 57 | + ) |
| 58 | + |
| 59 | + with anyio.fail_after(10): |
| 60 | + async with Client(transport, logging_callback=collect) as client: |
| 61 | + assert client.initialize_result.server_info.name == "stdio-echo" |
| 62 | + result = await client.call_tool("echo", {"text": "across\nprocesses"}) |
| 63 | + |
| 64 | + errlog.seek(0) |
| 65 | + captured_stderr = errlog.read() |
| 66 | + |
| 67 | + assert result == snapshot(CallToolResult(content=[TextContent(text="across\nprocesses")])) |
| 68 | + # stdio carries one ordered server→client stream, so the same notification-before-response |
| 69 | + # guarantee holds here as for the in-memory transport. |
| 70 | + assert received == snapshot( |
| 71 | + [LoggingMessageNotificationParams(level="info", logger="echo", data="echoing across\nprocesses")] |
| 72 | + ) |
| 73 | + # The server writes this line only after its run loop returns, which happens when stdin closes: |
| 74 | + # seeing it proves the process exited on its own rather than via the transport's terminate |
| 75 | + # escalation, without a timing-based assertion. |
| 76 | + assert captured_stderr == snapshot("stdio-echo: clean exit\n") |
0 commit comments