Files
2026-07-13 13:12:33 +08:00

231 lines
6.8 KiB
Python

from __future__ import annotations
import asyncio
import json
import sys
from pathlib import Path
import pytest
from opensquilla.mcp.stdio import MCPStdioClient
from opensquilla.mcp.types import MCPServerConfig
_SDK_SERVER_SCRIPT = str(Path(__file__).parent / "fixtures" / "fastmcp_server.py")
# A stdio MCP server (newline-delimited JSON-RPC) exposing one "search" tool
# whose call response carries the MCP result-level ``isError`` flag.
_ERROR_SERVER_SCRIPT = r"""
import json, sys
def send(payload):
sys.stdout.buffer.write((json.dumps(payload) + "\n").encode())
sys.stdout.buffer.flush()
while True:
line = sys.stdin.buffer.readline()
if not line:
break
msg = json.loads(line.decode())
msg_id = msg.get("id")
if msg_id is None:
continue # notification
method = msg.get("method")
if method == "initialize":
send({"jsonrpc": "2.0", "id": msg_id,
"result": {"protocolVersion": "2024-11-05", "capabilities": {},
"serverInfo": {"name": "fake", "version": "0.0.1"}}})
elif method == "tools/call":
send({"jsonrpc": "2.0", "id": msg_id,
"result": {"isError": True,
"content": [{"type": "text",
"text": "Error: upstream API rejected the query"}]}})
else:
send({"jsonrpc": "2.0", "id": msg_id, "result": {}})
"""
class _FakeProcess:
def __init__(self, *, exits_on_terminate: bool = True) -> None:
self.returncode: int | None = None
self.terminated = False
self.killed = False
self.wait_calls = 0
self.exits_on_terminate = exits_on_terminate
def terminate(self) -> None:
self.terminated = True
if self.exits_on_terminate:
self.returncode = 0
def kill(self) -> None:
self.killed = True
self.returncode = -9
async def wait(self) -> int:
self.wait_calls += 1
if self.returncode is None:
await asyncio.sleep(3600)
return self.returncode
def _client_with_process(process: _FakeProcess) -> MCPStdioClient:
client = MCPStdioClient(MCPServerConfig(name="demo", transport="stdio", command="demo"))
client._process = process # type: ignore[assignment]
return client
class _RecordingStdin:
def __init__(self) -> None:
self.writes: list[bytes] = []
self.changed = asyncio.Event()
def write(self, data: bytes) -> None:
self.writes.append(data)
self.changed.set()
async def drain(self) -> None:
return None
async def wait_for_count(self, count: int) -> None:
while len(self.writes) < count:
self.changed.clear()
await self.changed.wait()
class _QueuedStdout:
def __init__(self) -> None:
self.lines: asyncio.Queue[bytes] = asyncio.Queue()
async def readline(self) -> bytes:
return await self.lines.get()
def respond(self, request_id: int) -> None:
self.lines.put_nowait(
(json.dumps({"jsonrpc": "2.0", "id": request_id, "result": {}}) + "\n").encode()
)
class _ConcurrentProcess:
def __init__(self) -> None:
self.stdin = _RecordingStdin()
self.stdout = _QueuedStdout()
@pytest.mark.asyncio
async def test_send_request_before_connect_reports_connection_error() -> None:
client = MCPStdioClient(MCPServerConfig(name="demo", transport="stdio", command="demo"))
with pytest.raises(ConnectionError, match="not connected"):
await client._send_request("tools/list")
@pytest.mark.asyncio
async def test_read_response_skips_invalid_utf8_lines() -> None:
process = _ConcurrentProcess()
process.stdout.lines.put_nowait(b"\xff\n")
process.stdout.respond(1)
client = _client_with_process(process) # type: ignore[arg-type]
assert await client._read_response(1) == {"jsonrpc": "2.0", "id": 1, "result": {}}
@pytest.mark.asyncio
async def test_concurrent_requests_are_serialized_to_preserve_responses() -> None:
process = _ConcurrentProcess()
client = _client_with_process(process) # type: ignore[arg-type]
first = asyncio.create_task(client._send_request("first"))
second: asyncio.Task[dict] | None = None
try:
await process.stdin.wait_for_count(1)
second = asyncio.create_task(client._send_request("second"))
await asyncio.sleep(0)
assert len(process.stdin.writes) == 1
process.stdout.respond(1)
await first
await process.stdin.wait_for_count(2)
process.stdout.respond(2)
await second
finally:
first.cancel()
if second is not None:
second.cancel()
pending = [first] if second is None else [first, second]
await asyncio.gather(*pending, return_exceptions=True)
@pytest.mark.asyncio
async def test_close_waits_for_terminated_stdio_process() -> None:
process = _FakeProcess(exits_on_terminate=True)
await _client_with_process(process).close()
assert process.terminated is True
assert process.killed is False
assert process.wait_calls == 1
@pytest.mark.asyncio
async def test_close_kills_stdio_process_when_terminate_times_out(
monkeypatch: pytest.MonkeyPatch,
) -> None:
process = _FakeProcess(exits_on_terminate=False)
client = _client_with_process(process)
monkeypatch.setattr(client, "_CLOSE_TIMEOUT_SECONDS", 0.001)
await client.close()
assert process.terminated is True
assert process.killed is True
assert process.wait_calls == 2
def test_encode_request_is_newline_delimited_json() -> None:
encoded = MCPStdioClient._encode_request({"jsonrpc": "2.0", "id": 1, "method": "x"})
assert not encoded.startswith(b"Content-Length: ")
assert encoded.endswith(b"\n")
assert b"\n" not in encoded[:-1]
@pytest.mark.asyncio
async def test_connect_and_list_tools_against_sdk_stdio_server() -> None:
pytest.importorskip("mcp")
client = MCPStdioClient(
MCPServerConfig(
name="demo",
transport="stdio",
command=sys.executable,
args=[_SDK_SERVER_SCRIPT],
)
)
try:
await asyncio.wait_for(client.connect(), timeout=30.0)
tools = await asyncio.wait_for(client.list_tools(), timeout=30.0)
finally:
await client.close()
assert [t.name for t in tools] == ["ping"]
@pytest.mark.asyncio
async def test_call_tool_honors_result_level_is_error_flag() -> None:
client = MCPStdioClient(
MCPServerConfig(
name="demo",
transport="stdio",
command=sys.executable,
args=["-c", _ERROR_SERVER_SCRIPT],
)
)
try:
await client.connect()
result = await client.call_tool("search", {"q": "x"})
finally:
await client.close()
assert "upstream API rejected" in result.content
assert result.is_error is True