Files
Max Isbey 1b74b06753 Tighten comments and docstrings repo-wide
Cut comment and docstring volume roughly in half across src, tests,
examples, and docs_src: removed comments that restate the adjacent code,
leftover development narration, section banners, and self-evident
Args/Returns blocks, and compressed the remaining docstrings to a
Google-style summary line plus only the detail that earns its place.

Kept (and tightened) the load-bearing content: Raises sections,
deprecation and version-availability notes, spec/RFC/issue references,
why-comments for non-obvious decisions, and all coverage pragmas. The
generated mcp_types.v* wire modules are untouched.
2026-06-29 15:10:27 +00:00

148 lines
6.0 KiB
Python

import io
import sys
import threading
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from io import TextIOWrapper
import anyio
import pytest
from mcp_types import JSONRPCMessage, JSONRPCRequest, JSONRPCResponse, jsonrpc_message_adapter
from mcp.server.mcpserver import MCPServer
from mcp.server.stdio import stdio_server
from mcp.shared.message import SessionMessage
@pytest.mark.anyio
async def test_stdio_server_round_trips_messages_over_injected_streams() -> None:
"""Each JSON-RPC message is framed as exactly one line, in both directions."""
stdin = io.StringIO()
stdout = io.StringIO()
messages = [
JSONRPCRequest(jsonrpc="2.0", id=1, method="ping"),
JSONRPCResponse(jsonrpc="2.0", id=2, result={}),
]
for message in messages:
stdin.write(message.model_dump_json(by_alias=True, exclude_none=True) + "\n")
stdin.seek(0)
with anyio.fail_after(5):
async with stdio_server(stdin=anyio.AsyncFile(stdin), stdout=anyio.AsyncFile(stdout)) as (
read_stream,
write_stream,
):
async with read_stream:
received_messages: list[JSONRPCMessage] = []
for _ in range(2):
received = await read_stream.receive()
assert not isinstance(received, Exception)
received_messages.append(received.message)
assert received_messages[0] == JSONRPCRequest(jsonrpc="2.0", id=1, method="ping")
assert received_messages[1] == JSONRPCResponse(jsonrpc="2.0", id=2, result={})
responses = [
JSONRPCRequest(jsonrpc="2.0", id=3, method="ping"),
JSONRPCResponse(jsonrpc="2.0", id=4, result={}),
]
for response in responses:
await write_stream.send(SessionMessage(response))
await write_stream.aclose()
stdout.seek(0)
output_lines = stdout.readlines()
assert len(output_lines) == 2
received_responses = [jsonrpc_message_adapter.validate_json(line.strip()) for line in output_lines]
assert received_responses[0] == JSONRPCRequest(jsonrpc="2.0", id=3, method="ping")
assert received_responses[1] == JSONRPCResponse(jsonrpc="2.0", id=4, result={})
@pytest.mark.anyio
async def test_stdio_server_invalid_utf8(monkeypatch: pytest.MonkeyPatch) -> None:
"""Invalid UTF-8 stdin lines surface as in-stream exceptions; later valid messages still arrive."""
valid = JSONRPCRequest(jsonrpc="2.0", id=1, method="ping")
raw_stdin = io.BytesIO(b"\xff\xfe\n" + valid.model_dump_json(by_alias=True, exclude_none=True).encode() + b"\n")
# Exercise stdio_server()'s default path, which re-wraps sys.stdin.buffer with errors='replace'.
monkeypatch.setattr(sys, "stdin", TextIOWrapper(raw_stdin, encoding="utf-8"))
monkeypatch.setattr(sys, "stdout", TextIOWrapper(io.BytesIO(), encoding="utf-8"))
with anyio.fail_after(5):
async with stdio_server() as (read_stream, write_stream):
await write_stream.aclose()
async with read_stream: # pragma: no branch
# \xff\xfe -> U+FFFD U+FFFD -> JSON parse fails -> exception in stream
first = await read_stream.receive()
assert isinstance(first, Exception)
second = await read_stream.receive()
assert isinstance(second, SessionMessage)
assert second.message == valid
class _KeepOpenBytesIO(io.BytesIO):
"""BytesIO that ignores close(), so output stays readable after `run()` tears down its TextIOWrapper."""
def close(self) -> None:
pass
def _run_stdio_bounded(server: MCPServer) -> None:
"""Run the blocking `server.run("stdio")` in a daemon thread joined with a 5s bound.
`run()` owns its event loop, so `anyio.fail_after` can't bound it; the join timeout turns a
hang into a red test. An exception escaping `run()` still fails: pytest's unhandled-thread
warning is escalated by `filterwarnings = ["error"]`.
"""
def target() -> None:
server.run("stdio")
thread = threading.Thread(target=target, daemon=True)
thread.start()
thread.join(5)
assert not thread.is_alive(), 'run("stdio") did not return after stdin EOF'
def test_mcpserver_run_stdio_serves_until_stdin_closes(monkeypatch: pytest.MonkeyPatch) -> None:
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="RunStdioServer"))
response = jsonrpc_message_adapter.validate_json(captured.getvalue().decode().strip())
assert response == JSONRPCResponse(jsonrpc="2.0", id=1, result={})
def test_mcpserver_run_stdio_runs_lifespan_cleanup_after_stdin_closes(monkeypatch: pytest.MonkeyPatch) -> None:
"""Regression for issue #1027: stdin EOF must end the run loop and unwind the lifespan, not kill it."""
events: list[str] = []
@asynccontextmanager
async def lifespan(server: MCPServer) -> AsyncIterator[None]:
events.append("setup")
try:
yield
finally:
events.append("cleanup")
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="LifespanStdioServer", lifespan=lifespan))
assert events == ["setup", "cleanup"]
response = jsonrpc_message_adapter.validate_json(captured.getvalue().decode().strip())
assert response == JSONRPCResponse(jsonrpc="2.0", id=1, result={})