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>
This commit is contained in:
2026-01-03 11:12:41 +01:00
parent 731a188a76
commit e5975fa5d0
22 changed files with 5763 additions and 0 deletions

View File

@@ -0,0 +1,345 @@
"""
Tests for MCP Tool Call Routing
"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from app.services.mcp.config import MCPConfig, MCPServerConfig
from app.services.mcp.connection import ConnectionPool
from app.services.mcp.exceptions import (
MCPCircuitOpenError,
MCPToolError,
MCPToolNotFoundError,
)
from app.services.mcp.registry import MCPServerRegistry
from app.services.mcp.routing import ToolInfo, ToolResult, ToolRouter
@pytest.fixture
def reset_registry():
"""Reset the singleton registry before and after each test."""
MCPServerRegistry.reset_instance()
yield
MCPServerRegistry.reset_instance()
@pytest.fixture
def registry(reset_registry):
"""Create a configured registry."""
reg = MCPServerRegistry()
reg.load_config(
MCPConfig(
mcp_servers={
"server-1": MCPServerConfig(
url="http://server1:8000",
retry_attempts=1,
retry_delay=0.1, # Minimum allowed value
circuit_breaker_threshold=3,
circuit_breaker_timeout=5.0,
),
"server-2": MCPServerConfig(
url="http://server2:8000",
retry_attempts=1,
retry_delay=0.1, # Minimum allowed value
),
}
)
)
return reg
@pytest.fixture
def pool():
"""Create a connection pool."""
return ConnectionPool()
@pytest.fixture
def router(registry, pool):
"""Create a tool router."""
return ToolRouter(registry, pool)
class TestToolInfo:
"""Tests for ToolInfo dataclass."""
def test_basic_tool_info(self):
"""Test creating basic tool info."""
info = ToolInfo(name="test-tool")
assert info.name == "test-tool"
assert info.description is None
assert info.server_name is None
assert info.input_schema is None
def test_full_tool_info(self):
"""Test creating full tool info."""
info = ToolInfo(
name="create_issue",
description="Create a new issue",
server_name="issues",
input_schema={"type": "object", "properties": {"title": {"type": "string"}}},
)
assert info.name == "create_issue"
assert info.description == "Create a new issue"
assert info.server_name == "issues"
assert "properties" in info.input_schema
def test_to_dict(self):
"""Test converting to dictionary."""
info = ToolInfo(
name="test-tool",
description="A test tool",
server_name="test-server",
)
result = info.to_dict()
assert result["name"] == "test-tool"
assert result["description"] == "A test tool"
assert result["server_name"] == "test-server"
class TestToolResult:
"""Tests for ToolResult dataclass."""
def test_success_result(self):
"""Test creating success result."""
result = ToolResult(
success=True,
data={"id": "123"},
tool_name="create_issue",
server_name="issues",
)
assert result.success is True
assert result.data == {"id": "123"}
assert result.error is None
def test_error_result(self):
"""Test creating error result."""
result = ToolResult(
success=False,
error="Tool execution failed",
error_code="INTERNAL_ERROR",
tool_name="create_issue",
server_name="issues",
)
assert result.success is False
assert result.error == "Tool execution failed"
assert result.error_code == "INTERNAL_ERROR"
def test_to_dict(self):
"""Test converting to dictionary."""
result = ToolResult(
success=True,
data={"result": "ok"},
tool_name="test",
execution_time_ms=123.45,
)
d = result.to_dict()
assert d["success"] is True
assert d["data"] == {"result": "ok"}
assert d["tool_name"] == "test"
assert d["execution_time_ms"] == 123.45
assert "request_id" in d # Auto-generated
class TestToolRouter:
"""Tests for ToolRouter class."""
@pytest.mark.asyncio
async def test_register_tool_mapping(self, router):
"""Test registering tool mappings."""
await router.register_tool_mapping("tool1", "server-1")
await router.register_tool_mapping("tool2", "server-2")
assert router.find_server_for_tool("tool1") == "server-1"
assert router.find_server_for_tool("tool2") == "server-2"
def test_find_server_for_unknown_tool(self, router):
"""Test finding server for unknown tool."""
result = router.find_server_for_tool("unknown-tool")
assert result is None
@pytest.mark.asyncio
async def test_call_tool_success(self, router, registry):
"""Test successful tool call."""
# Set up capabilities
registry.set_capabilities(
"server-1",
tools=[{"name": "test-tool"}],
)
await router.register_tool_mapping("test-tool", "server-1")
# Mock the pool connection and request
mock_conn = AsyncMock()
mock_conn.execute_request = AsyncMock(
return_value={"result": {"status": "ok"}}
)
mock_conn.is_connected = True
with patch.object(router._pool, "get_connection", return_value=mock_conn):
result = await router.call_tool(
server_name="server-1",
tool_name="test-tool",
arguments={"param": "value"},
)
assert result.success is True
assert result.data == {"status": "ok"}
assert result.tool_name == "test-tool"
assert result.server_name == "server-1"
assert result.execution_time_ms > 0
@pytest.mark.asyncio
async def test_call_tool_error_response(self, router, registry):
"""Test tool call with error response."""
registry.set_capabilities(
"server-1",
tools=[{"name": "test-tool"}],
)
await router.register_tool_mapping("test-tool", "server-1")
mock_conn = AsyncMock()
mock_conn.execute_request = AsyncMock(
return_value={
"error": {
"code": -32000,
"message": "Tool execution failed",
}
}
)
mock_conn.is_connected = True
with patch.object(router._pool, "get_connection", return_value=mock_conn):
result = await router.call_tool(
server_name="server-1",
tool_name="test-tool",
arguments={},
)
assert result.success is False
assert "Tool execution failed" in result.error
@pytest.mark.asyncio
async def test_route_tool(self, router, registry):
"""Test routing tool to correct server."""
registry.set_capabilities(
"server-1",
tools=[{"name": "tool-on-server-1"}],
)
await router.register_tool_mapping("tool-on-server-1", "server-1")
mock_conn = AsyncMock()
mock_conn.execute_request = AsyncMock(
return_value={"result": "routed"}
)
mock_conn.is_connected = True
with patch.object(router._pool, "get_connection", return_value=mock_conn):
result = await router.route_tool(
tool_name="tool-on-server-1",
arguments={"key": "value"},
)
assert result.success is True
assert result.server_name == "server-1"
@pytest.mark.asyncio
async def test_route_tool_not_found(self, router):
"""Test routing unknown tool raises error."""
with pytest.raises(MCPToolNotFoundError) as exc_info:
await router.route_tool(
tool_name="unknown-tool",
arguments={},
)
assert exc_info.value.tool_name == "unknown-tool"
@pytest.mark.asyncio
async def test_list_all_tools(self, router, registry):
"""Test listing all tools."""
registry.set_capabilities(
"server-1",
tools=[
{"name": "tool1", "description": "Tool 1"},
{"name": "tool2", "description": "Tool 2"},
],
)
registry.set_capabilities(
"server-2",
tools=[{"name": "tool3", "description": "Tool 3"}],
)
tools = await router.list_all_tools()
assert len(tools) == 3
tool_names = [t.name for t in tools]
assert "tool1" in tool_names
assert "tool2" in tool_names
assert "tool3" in tool_names
def test_circuit_breaker_status(self, router, registry):
"""Test getting circuit breaker status."""
# Initially no circuit breakers
status = router.get_circuit_breaker_status()
assert status == {}
@pytest.mark.asyncio
async def test_reset_circuit_breaker(self, router, registry):
"""Test resetting circuit breaker."""
# Reset non-existent returns False
result = await router.reset_circuit_breaker("server-1")
assert result is False
@pytest.mark.asyncio
async def test_discover_tools(self, router, registry):
"""Test tool discovery from servers."""
# Create mocks for different servers
mock_conn_1 = AsyncMock()
mock_conn_1.execute_request = AsyncMock(
return_value={
"tools": [
{"name": "discovered-tool", "description": "A discovered tool"},
]
}
)
mock_conn_1.server_name = "server-1"
mock_conn_1.is_connected = True
mock_conn_2 = AsyncMock()
mock_conn_2.execute_request = AsyncMock(
return_value={"tools": []} # Empty for server-2
)
mock_conn_2.server_name = "server-2"
mock_conn_2.is_connected = True
async def get_connection_side_effect(server_name, _config):
if server_name == "server-1":
return mock_conn_1
return mock_conn_2
with patch.object(
router._pool,
"get_connection",
side_effect=get_connection_side_effect,
):
await router.discover_tools()
# Check that tool mapping was registered
server = router.find_server_for_tool("discovered-tool")
assert server == "server-1"
def test_calculate_retry_delay(self, router, registry):
"""Test retry delay calculation."""
config = registry.get("server-1")
delay1 = router._calculate_retry_delay(1, config)
delay2 = router._calculate_retry_delay(2, config)
delay3 = router._calculate_retry_delay(3, config)
# Delays should increase with attempts
assert delay1 > 0
# Allow for jitter variation
assert delay1 <= config.retry_max_delay * 1.25