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

128 lines
4.1 KiB
Python

from __future__ import annotations
from typing import Any
import pytest
import pytest_asyncio
from opensquilla.mcp.client import MCPClient
from opensquilla.mcp.types import MCPServerConfig, MCPToolDef, MCPToolResult
from opensquilla.tools.registry import ToolRegistry
class FakeMCPClient(MCPClient):
def __init__(
self,
config: MCPServerConfig,
tools: list[MCPToolDef] | None = None,
*,
fail_list: bool = False,
call_result: MCPToolResult | None = None,
) -> None:
super().__init__(config)
self.tools = tools or []
self.fail_list = fail_list
self.call_result = call_result
self.connected = False
self.closed = False
async def connect(self) -> None:
self.connected = True
async def close(self) -> None:
self.closed = True
async def list_tools(self) -> list[MCPToolDef]:
if self.fail_list:
raise RuntimeError("list failed")
return self.tools
async def call_tool(self, name: str, arguments: dict[str, Any]) -> MCPToolResult:
if self.call_result is not None:
return self.call_result
return MCPToolResult(content=f"{name}:{arguments}")
@pytest_asyncio.fixture(autouse=True)
async def _close_mcp_clients():
from opensquilla.mcp.discovery import close_active_clients
await close_active_clients()
yield
await close_active_clients()
@pytest.mark.asyncio
async def test_discovered_mcp_clients_have_owner_and_close_lifecycle(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from opensquilla.mcp import discovery
config = MCPServerConfig(name="docs", transport="stdio", command="mock-mcp")
client = FakeMCPClient(
config,
tools=[
MCPToolDef(
name="lookup",
description="Lookup docs",
input_schema={"properties": {"q": {"type": "string"}}, "required": ["q"]},
)
],
)
monkeypatch.setattr(discovery, "create_client", lambda _config: client)
names = await discovery.discover_and_register(config, ToolRegistry(), owner="gateway")
snapshot = discovery.active_clients_snapshot()
assert names == ["mcp_lookup"]
assert len(snapshot) == 1
assert snapshot[0].owner == "gateway"
assert snapshot[0].server_name == "docs"
assert snapshot[0].transport == "stdio"
assert snapshot[0].client is client
assert await discovery.close_active_clients(owner="docs") == 1
assert client.closed is True
assert discovery.active_clients_snapshot() == ()
@pytest.mark.asyncio
async def test_failed_mcp_discovery_closes_client_without_leaking(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from opensquilla.mcp import discovery
config = MCPServerConfig(name="broken", transport="stdio", command="mock-mcp")
client = FakeMCPClient(config, fail_list=True)
monkeypatch.setattr(discovery, "create_client", lambda _config: client)
with pytest.raises(RuntimeError, match="list failed"):
await discovery.discover_and_register(config, ToolRegistry())
assert client.closed is True
assert discovery.active_clients_snapshot() == ()
@pytest.mark.asyncio
async def test_registered_handler_surfaces_client_error_flag(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from opensquilla.mcp import discovery
from opensquilla.tool_boundary import ToolCall
from opensquilla.tools.dispatch import build_tool_handler
config = MCPServerConfig(name="docs", transport="stdio", command="mock-mcp")
client = FakeMCPClient(
config,
tools=[MCPToolDef(name="lookup", description="Lookup docs", input_schema={})],
call_result=MCPToolResult(content="invalid params", is_error=True),
)
monkeypatch.setattr(discovery, "create_client", lambda _config: client)
registry = ToolRegistry()
await discovery.discover_and_register(config, registry)
handler = build_tool_handler(registry)
result = await handler(ToolCall(tool_use_id="tu1", tool_name="mcp_lookup", arguments={}))
assert result.is_error is True
assert "invalid params" in result.content