Files
syndarix/backend/tests/services/mcp/test_client_manager.py
Felipe Cardoso e5975fa5d0 feat(backend): implement MCP client infrastructure (#55)
Core MCP client implementation with comprehensive tooling:

**Services:**
- MCPClientManager: Main facade for all MCP operations
- MCPServerRegistry: Thread-safe singleton for server configs
- ConnectionPool: Connection pooling with auto-reconnection
- ToolRouter: Automatic tool routing with circuit breaker
- AsyncCircuitBreaker: Custom async-compatible circuit breaker

**Configuration:**
- YAML-based config with Pydantic models
- Environment variable expansion support
- Transport types: HTTP, SSE, STDIO

**API Endpoints:**
- GET /mcp/servers - List all MCP servers
- GET /mcp/servers/{name}/tools - List server tools
- GET /mcp/tools - List all tools from all servers
- GET /mcp/health - Health check all servers
- POST /mcp/call - Execute tool (admin only)
- GET /mcp/circuit-breakers - Circuit breaker status
- POST /mcp/circuit-breakers/{name}/reset - Reset circuit breaker
- POST /mcp/servers/{name}/reconnect - Force reconnection

**Testing:**
- 156 unit tests with comprehensive coverage
- Tests for all services, routes, and error handling
- Proper mocking and async test support

**Documentation:**
- MCP_CLIENT.md with usage examples
- Phase 2+ workflow documentation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 11:12:41 +01:00

396 lines
13 KiB
Python

"""
Tests for MCP Client Manager
"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from app.services.mcp.client_manager import (
MCPClientManager,
ServerHealth,
get_mcp_client,
reset_mcp_client,
shutdown_mcp_client,
)
from app.services.mcp.config import MCPConfig, MCPServerConfig
from app.services.mcp.connection import ConnectionState
from app.services.mcp.exceptions import MCPServerNotFoundError
from app.services.mcp.registry import MCPServerRegistry
from app.services.mcp.routing import ToolInfo, ToolResult
@pytest.fixture
def reset_registry():
"""Reset the singleton registry before and after each test."""
MCPServerRegistry.reset_instance()
reset_mcp_client()
yield
MCPServerRegistry.reset_instance()
reset_mcp_client()
@pytest.fixture
def sample_config():
"""Create a sample MCP configuration."""
return MCPConfig(
mcp_servers={
"server-1": MCPServerConfig(
url="http://server1:8000",
timeout=30,
enabled=True,
),
"server-2": MCPServerConfig(
url="http://server2:8000",
timeout=60,
enabled=True,
),
}
)
class TestServerHealth:
"""Tests for ServerHealth dataclass."""
def test_healthy_server(self):
"""Test healthy server status."""
health = ServerHealth(
name="test-server",
healthy=True,
state="connected",
url="http://test:8000",
tools_count=5,
)
assert health.healthy is True
assert health.error is None
assert health.tools_count == 5
def test_unhealthy_server(self):
"""Test unhealthy server status."""
health = ServerHealth(
name="test-server",
healthy=False,
state="error",
url="http://test:8000",
error="Connection refused",
)
assert health.healthy is False
assert health.error == "Connection refused"
def test_to_dict(self):
"""Test converting to dictionary."""
health = ServerHealth(
name="test-server",
healthy=True,
state="connected",
url="http://test:8000",
tools_count=3,
)
d = health.to_dict()
assert d["name"] == "test-server"
assert d["healthy"] is True
assert d["state"] == "connected"
assert d["url"] == "http://test:8000"
assert d["tools_count"] == 3
class TestMCPClientManager:
"""Tests for MCPClientManager class."""
def test_initial_state(self, reset_registry):
"""Test initial manager state."""
manager = MCPClientManager()
assert manager.is_initialized is False
@pytest.mark.asyncio
async def test_initialize(self, reset_registry, sample_config):
"""Test manager initialization."""
manager = MCPClientManager(config=sample_config)
with patch.object(manager._pool, "get_connection") as mock_get_conn:
mock_conn = AsyncMock()
mock_conn.is_connected = True
mock_get_conn.return_value = mock_conn
with patch.object(manager, "_router") as mock_router:
mock_router.discover_tools = AsyncMock()
await manager.initialize()
assert manager.is_initialized is True
@pytest.mark.asyncio
async def test_shutdown(self, reset_registry, sample_config):
"""Test manager shutdown."""
manager = MCPClientManager(config=sample_config)
manager._initialized = True
with patch.object(manager._pool, "close_all") as mock_close:
mock_close.return_value = None
await manager.shutdown()
assert manager.is_initialized is False
mock_close.assert_called_once()
@pytest.mark.asyncio
async def test_connect(self, reset_registry, sample_config):
"""Test connecting to specific server."""
manager = MCPClientManager(config=sample_config)
with patch.object(manager._pool, "get_connection") as mock_get_conn:
mock_conn = AsyncMock()
mock_conn.is_connected = True
mock_get_conn.return_value = mock_conn
await manager.connect("server-1")
mock_get_conn.assert_called_once()
@pytest.mark.asyncio
async def test_connect_unknown_server(self, reset_registry, sample_config):
"""Test connecting to unknown server raises error."""
manager = MCPClientManager(config=sample_config)
with pytest.raises(MCPServerNotFoundError):
await manager.connect("unknown-server")
@pytest.mark.asyncio
async def test_disconnect(self, reset_registry, sample_config):
"""Test disconnecting from server."""
manager = MCPClientManager(config=sample_config)
with patch.object(manager._pool, "close_connection") as mock_close:
await manager.disconnect("server-1")
mock_close.assert_called_once_with("server-1")
@pytest.mark.asyncio
async def test_disconnect_all(self, reset_registry, sample_config):
"""Test disconnecting from all servers."""
manager = MCPClientManager(config=sample_config)
with patch.object(manager._pool, "close_all") as mock_close:
await manager.disconnect_all()
mock_close.assert_called_once()
@pytest.mark.asyncio
async def test_call_tool(self, reset_registry, sample_config):
"""Test calling a tool."""
manager = MCPClientManager(config=sample_config)
manager._initialized = True
expected_result = ToolResult(
success=True,
data={"id": "123"},
tool_name="create_issue",
server_name="server-1",
)
mock_router = MagicMock()
mock_router.call_tool = AsyncMock(return_value=expected_result)
manager._router = mock_router
result = await manager.call_tool(
server="server-1",
tool="create_issue",
args={"title": "Test"},
)
assert result.success is True
assert result.data == {"id": "123"}
mock_router.call_tool.assert_called_once()
@pytest.mark.asyncio
async def test_route_tool(self, reset_registry, sample_config):
"""Test routing a tool call."""
manager = MCPClientManager(config=sample_config)
manager._initialized = True
expected_result = ToolResult(
success=True,
data={"result": "ok"},
tool_name="auto_tool",
server_name="server-2",
)
mock_router = MagicMock()
mock_router.route_tool = AsyncMock(return_value=expected_result)
manager._router = mock_router
result = await manager.route_tool(
tool="auto_tool",
args={"key": "value"},
)
assert result.success is True
assert result.server_name == "server-2"
mock_router.route_tool.assert_called_once()
@pytest.mark.asyncio
async def test_list_tools(self, reset_registry, sample_config):
"""Test listing tools for a server."""
manager = MCPClientManager(config=sample_config)
# Set up capabilities in registry
manager._registry.set_capabilities(
"server-1",
tools=[
{"name": "tool1", "description": "Tool 1"},
{"name": "tool2", "description": "Tool 2"},
],
)
tools = await manager.list_tools("server-1")
assert len(tools) == 2
assert tools[0].name == "tool1"
assert tools[1].name == "tool2"
@pytest.mark.asyncio
async def test_list_all_tools(self, reset_registry, sample_config):
"""Test listing all tools from all servers."""
manager = MCPClientManager(config=sample_config)
manager._initialized = True
expected_tools = [
ToolInfo(name="tool1", server_name="server-1"),
ToolInfo(name="tool2", server_name="server-2"),
]
mock_router = MagicMock()
mock_router.list_all_tools = AsyncMock(return_value=expected_tools)
manager._router = mock_router
tools = await manager.list_all_tools()
assert len(tools) == 2
@pytest.mark.asyncio
async def test_health_check(self, reset_registry, sample_config):
"""Test health check on all servers."""
manager = MCPClientManager(config=sample_config)
with patch.object(manager._pool, "get_status") as mock_status:
mock_status.return_value = {
"server-1": {"state": "connected"},
"server-2": {"state": "disconnected"},
}
with patch.object(manager._pool, "health_check_all") as mock_health:
mock_health.return_value = {
"server-1": True,
"server-2": False,
}
health = await manager.health_check()
assert "server-1" in health
assert "server-2" in health
assert health["server-1"].healthy is True
assert health["server-2"].healthy is False
def test_list_servers(self, reset_registry, sample_config):
"""Test listing registered servers."""
manager = MCPClientManager(config=sample_config)
servers = manager.list_servers()
assert "server-1" in servers
assert "server-2" in servers
def test_list_enabled_servers(self, reset_registry, sample_config):
"""Test listing enabled servers."""
manager = MCPClientManager(config=sample_config)
servers = manager.list_enabled_servers()
assert "server-1" in servers
assert "server-2" in servers
def test_get_server_config(self, reset_registry, sample_config):
"""Test getting server configuration."""
manager = MCPClientManager(config=sample_config)
config = manager.get_server_config("server-1")
assert config.url == "http://server1:8000"
assert config.timeout == 30
def test_get_server_config_not_found(self, reset_registry, sample_config):
"""Test getting unknown server config raises error."""
manager = MCPClientManager(config=sample_config)
with pytest.raises(MCPServerNotFoundError):
manager.get_server_config("unknown")
def test_register_server(self, reset_registry, sample_config):
"""Test registering new server at runtime."""
manager = MCPClientManager(config=sample_config)
new_config = MCPServerConfig(url="http://new:8000")
manager.register_server("new-server", new_config)
assert "new-server" in manager.list_servers()
def test_unregister_server(self, reset_registry, sample_config):
"""Test unregistering a server."""
manager = MCPClientManager(config=sample_config)
result = manager.unregister_server("server-1")
assert result is True
assert "server-1" not in manager.list_servers()
# Unregistering non-existent returns False
result = manager.unregister_server("nonexistent")
assert result is False
def test_circuit_breaker_status(self, reset_registry, sample_config):
"""Test getting circuit breaker status."""
manager = MCPClientManager(config=sample_config)
# No router yet
status = manager.get_circuit_breaker_status()
assert status == {}
@pytest.mark.asyncio
async def test_reset_circuit_breaker(self, reset_registry, sample_config):
"""Test resetting circuit breaker."""
manager = MCPClientManager(config=sample_config)
# No router yet
result = await manager.reset_circuit_breaker("server-1")
assert result is False
class TestModuleLevelFunctions:
"""Tests for module-level convenience functions."""
@pytest.mark.asyncio
async def test_get_mcp_client_creates_singleton(self, reset_registry):
"""Test get_mcp_client creates and returns singleton."""
with patch(
"app.services.mcp.client_manager.MCPClientManager.initialize"
) as mock_init:
mock_init.return_value = None
client1 = await get_mcp_client()
client2 = await get_mcp_client()
assert client1 is client2
@pytest.mark.asyncio
async def test_shutdown_mcp_client(self, reset_registry):
"""Test shutting down the global client."""
with patch(
"app.services.mcp.client_manager.MCPClientManager.initialize"
) as mock_init:
mock_init.return_value = None
client = await get_mcp_client()
with patch.object(client, "shutdown") as mock_shutdown:
mock_shutdown.return_value = None
await shutdown_mcp_client()
def test_reset_mcp_client(self, reset_registry):
"""Test resetting the global client."""
reset_mcp_client()
# Should not raise