Skip to content

Commit 584e098

Browse files
committed
test: add an SDK-client to SDK-server stdio end-to-end interaction test
1 parent 8353a9b commit 584e098

3 files changed

Lines changed: 136 additions & 6 deletions

File tree

tests/interaction/_requirements.py

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1779,12 +1779,11 @@ def __post_init__(self) -> None:
17791779
),
17801780
"transport:stdio": Requirement(
17811781
source=f"{SPEC_BASE_URL}/basic/transports#stdio",
1782-
behavior="The interaction round trip works over a stdio subprocess.",
1783-
transports=("stdio",),
1784-
deferred=(
1785-
"Not yet covered here: a single composed end-to-end stdio test is planned; process lifecycle "
1786-
"details are covered by tests/client/test_stdio.py."
1782+
behavior=(
1783+
"A Client connected to a real SDK Server over stdio initializes, calls a tool with arguments, "
1784+
"and receives notifications and results over the child process's stdin/stdout."
17871785
),
1786+
transports=("stdio",),
17881787
),
17891788
# ═══════════════════════════════════════════════════════════════════════════
17901789
# Hosting: session lifecycle
@@ -2494,7 +2493,6 @@ def __post_init__(self) -> None:
24942493
source=f"{SPEC_BASE_URL}/basic/lifecycle#shutdown",
24952494
behavior="Closing the client transport closes the child process's stdin and the server exits cleanly.",
24962495
transports=("stdio",),
2497-
deferred="Not yet covered here; existing coverage in tests/client/test_stdio.py.",
24982496
),
24992497
"transport:stdio:stream-purity": Requirement(
25002498
source=f"{SPEC_BASE_URL}/basic/transports#stdio",
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
"""A real low-level Server over the stdio transport, for the suite's one subprocess test.
2+
3+
Runnable as `python -m tests.interaction.transports._stdio_server` from the repo root; the test
4+
launches it that way via `stdio_client`. Kept separate from the test module so the server lives in
5+
its own importable file (subprocess coverage applies) while the test file follows the suite's
6+
test-only-functions convention.
7+
"""
8+
9+
import sys
10+
11+
import anyio
12+
13+
from mcp.server import Server, ServerRequestContext
14+
from mcp.server.stdio import stdio_server
15+
from mcp.types import (
16+
CallToolRequestParams,
17+
CallToolResult,
18+
ListToolsResult,
19+
PaginatedRequestParams,
20+
TextContent,
21+
Tool,
22+
)
23+
24+
25+
async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
26+
return ListToolsResult(
27+
tools=[
28+
Tool(
29+
name="echo",
30+
input_schema={"type": "object", "properties": {"text": {"type": "string"}}, "required": ["text"]},
31+
)
32+
]
33+
)
34+
35+
36+
async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
37+
assert params.name == "echo"
38+
assert params.arguments is not None
39+
text = params.arguments["text"]
40+
await ctx.session.send_log_message(level="info", data=f"echoing {text}", logger="echo")
41+
return CallToolResult(content=[TextContent(text=text)])
42+
43+
44+
server = Server("stdio-echo", on_list_tools=list_tools, on_call_tool=call_tool)
45+
46+
47+
async def main() -> None:
48+
async with stdio_server() as (read_stream, write_stream):
49+
await server.run(read_stream, write_stream, server.create_initialization_options())
50+
# Reached only when the run loop exits because stdin closed; if the process were terminated
51+
# the test's stderr capture would not see this line.
52+
print("stdio-echo: clean exit", file=sys.stderr, flush=True)
53+
54+
55+
if __name__ == "__main__":
56+
anyio.run(main)
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
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

Comments
 (0)