143 lines
6.6 KiB
Python
143 lines
6.6 KiB
Python
"""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 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.
|
|
"""
|
|
|
|
import io
|
|
import json
|
|
import os
|
|
import sys
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
import anyio
|
|
import pytest
|
|
from inline_snapshot import snapshot
|
|
|
|
from mcp.client.client import Client
|
|
from mcp.client.stdio import StdioServerParameters, stdio_client
|
|
from mcp.server.stdio import stdio_server
|
|
from mcp.shared.message import SessionMessage
|
|
from mcp.types import (
|
|
CallToolResult,
|
|
JSONRPCNotification,
|
|
JSONRPCRequest,
|
|
JSONRPCResponse,
|
|
LoggingMessageNotificationParams,
|
|
TextContent,
|
|
)
|
|
from mcp.types.jsonrpc import jsonrpc_message_adapter
|
|
from tests.interaction._connect import initialize_body
|
|
from tests.interaction._requirements import requirement
|
|
from tests.interaction.transports import _stdio_server
|
|
|
|
pytestmark = pytest.mark.anyio
|
|
|
|
_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."""
|
|
received: list[LoggingMessageNotificationParams] = []
|
|
|
|
async def collect(params: LoggingMessageNotificationParams) -> None:
|
|
received.append(params)
|
|
|
|
with tempfile.TemporaryFile(mode="w+") as errlog:
|
|
transport = stdio_client(
|
|
StdioServerParameters(
|
|
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_")},
|
|
),
|
|
errlog=errlog,
|
|
)
|
|
|
|
with anyio.fail_after(10):
|
|
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"})
|
|
|
|
errlog.seek(0)
|
|
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
|
|
# 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.
|
|
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.
|
|
|
|
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
|
|
divergence on `transport:stdio:stream-purity`).
|
|
"""
|
|
captured = io.StringIO()
|
|
sent_line = json.dumps(initialize_body(request_id=1)) + "\n"
|
|
|
|
with anyio.fail_after(5):
|
|
async with (
|
|
stdio_server(stdin=anyio.wrap_file(io.StringIO(sent_line)), stdout=anyio.wrap_file(captured)) as (
|
|
read_stream,
|
|
write_stream,
|
|
),
|
|
read_stream,
|
|
write_stream,
|
|
):
|
|
received = await read_stream.receive()
|
|
assert isinstance(received, SessionMessage)
|
|
assert isinstance(received.message, JSONRPCRequest)
|
|
assert received.message.method == "initialize"
|
|
|
|
response = JSONRPCResponse(jsonrpc="2.0", id=1, result={"text": "line\nbreak"})
|
|
notification = JSONRPCNotification(
|
|
jsonrpc="2.0", method="notifications/message", params={"level": "info", "data": "two\nlines"}
|
|
)
|
|
await write_stream.send(SessionMessage(response))
|
|
await write_stream.send(SessionMessage(notification))
|
|
|
|
output = captured.getvalue()
|
|
assert output.endswith("\n")
|
|
lines = output.removesuffix("\n").split("\n")
|
|
assert len(lines) == 2
|
|
messages = [jsonrpc_message_adapter.validate_json(line) for line in lines]
|
|
assert [type(message).__name__ for message in messages] == snapshot(["JSONRPCResponse", "JSONRPCNotification"])
|
|
# The newline inside the payload is JSON-escaped on the wire, not a literal newline that would
|
|
# break the one-message-per-line framing.
|
|
assert r"line\nbreak" in lines[0]
|
|
assert r"two\nlines" in lines[1]
|