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,259 @@
"""
Tests for MCP Exception Classes
"""
import pytest
from app.services.mcp.exceptions import (
MCPCircuitOpenError,
MCPConnectionError,
MCPError,
MCPServerNotFoundError,
MCPTimeoutError,
MCPToolError,
MCPToolNotFoundError,
MCPValidationError,
)
class TestMCPError:
"""Tests for base MCPError class."""
def test_basic_error(self):
"""Test basic error creation."""
error = MCPError("Test error")
assert error.message == "Test error"
assert error.server_name is None
assert error.details == {}
assert str(error) == "Test error"
def test_error_with_server_name(self):
"""Test error with server name."""
error = MCPError("Test error", server_name="test-server")
assert error.server_name == "test-server"
assert "server=test-server" in str(error)
def test_error_with_details(self):
"""Test error with additional details."""
error = MCPError(
"Test error",
server_name="test-server",
details={"key": "value"},
)
assert error.details == {"key": "value"}
assert "details={'key': 'value'}" in str(error)
class TestMCPConnectionError:
"""Tests for MCPConnectionError class."""
def test_basic_connection_error(self):
"""Test basic connection error."""
error = MCPConnectionError("Connection failed")
assert error.message == "Connection failed"
assert error.url is None
assert error.cause is None
def test_connection_error_with_url(self):
"""Test connection error with URL."""
error = MCPConnectionError(
"Connection failed",
server_name="test-server",
url="http://localhost:8000",
)
assert error.url == "http://localhost:8000"
assert "url=http://localhost:8000" in str(error)
def test_connection_error_with_cause(self):
"""Test connection error with cause."""
cause = ConnectionError("Network error")
error = MCPConnectionError(
"Connection failed",
cause=cause,
)
assert error.cause is cause
assert "ConnectionError" in str(error)
class TestMCPTimeoutError:
"""Tests for MCPTimeoutError class."""
def test_basic_timeout_error(self):
"""Test basic timeout error."""
error = MCPTimeoutError("Request timed out")
assert error.message == "Request timed out"
assert error.timeout_seconds is None
assert error.operation is None
def test_timeout_error_with_details(self):
"""Test timeout error with details."""
error = MCPTimeoutError(
"Request timed out",
server_name="test-server",
timeout_seconds=30.0,
operation="POST /mcp",
)
assert error.timeout_seconds == 30.0
assert error.operation == "POST /mcp"
assert "timeout=30.0s" in str(error)
assert "operation=POST /mcp" in str(error)
class TestMCPToolError:
"""Tests for MCPToolError class."""
def test_basic_tool_error(self):
"""Test basic tool error."""
error = MCPToolError("Tool execution failed")
assert error.message == "Tool execution failed"
assert error.tool_name is None
assert error.tool_args is None
assert error.error_code is None
def test_tool_error_with_details(self):
"""Test tool error with all details."""
error = MCPToolError(
"Tool execution failed",
server_name="llm-gateway",
tool_name="chat",
tool_args={"prompt": "Hello"},
error_code="INVALID_ARGS",
)
assert error.tool_name == "chat"
assert error.tool_args == {"prompt": "Hello"}
assert error.error_code == "INVALID_ARGS"
assert "tool=chat" in str(error)
assert "error_code=INVALID_ARGS" in str(error)
class TestMCPServerNotFoundError:
"""Tests for MCPServerNotFoundError class."""
def test_server_not_found(self):
"""Test server not found error."""
error = MCPServerNotFoundError("unknown-server")
assert error.server_name == "unknown-server"
assert "MCP server not found: unknown-server" in error.message
assert error.available_servers == []
def test_server_not_found_with_available(self):
"""Test server not found with available servers listed."""
error = MCPServerNotFoundError(
"unknown-server",
available_servers=["server-1", "server-2"],
)
assert error.available_servers == ["server-1", "server-2"]
assert "available=['server-1', 'server-2']" in str(error)
class TestMCPToolNotFoundError:
"""Tests for MCPToolNotFoundError class."""
def test_tool_not_found(self):
"""Test tool not found error."""
error = MCPToolNotFoundError("unknown-tool")
assert error.tool_name == "unknown-tool"
assert "Tool not found: unknown-tool" in error.message
assert error.available_tools == []
def test_tool_not_found_with_available(self):
"""Test tool not found with available tools listed."""
error = MCPToolNotFoundError(
"unknown-tool",
available_tools=["tool-1", "tool-2", "tool-3", "tool-4", "tool-5", "tool-6"],
)
assert len(error.available_tools) == 6
# Should show first 5 tools with ellipsis
assert "available_tools=['tool-1', 'tool-2', 'tool-3', 'tool-4', 'tool-5']..." in str(error)
class TestMCPCircuitOpenError:
"""Tests for MCPCircuitOpenError class."""
def test_circuit_open_error(self):
"""Test circuit open error."""
error = MCPCircuitOpenError("test-server")
assert error.server_name == "test-server"
assert "Circuit breaker open for server: test-server" in error.message
assert error.failure_count is None
assert error.reset_timeout is None
def test_circuit_open_error_with_details(self):
"""Test circuit open error with details."""
error = MCPCircuitOpenError(
"test-server",
failure_count=5,
reset_timeout=30.0,
)
assert error.failure_count == 5
assert error.reset_timeout == 30.0
assert "failures=5" in str(error)
assert "reset_in=30.0s" in str(error)
class TestMCPValidationError:
"""Tests for MCPValidationError class."""
def test_validation_error(self):
"""Test validation error."""
error = MCPValidationError("Validation failed")
assert error.message == "Validation failed"
assert error.tool_name is None
assert error.field_errors == {}
def test_validation_error_with_details(self):
"""Test validation error with field errors."""
error = MCPValidationError(
"Validation failed",
tool_name="create_issue",
field_errors={
"title": "Title is required",
"priority": "Invalid priority value",
},
)
assert error.tool_name == "create_issue"
assert error.field_errors == {
"title": "Title is required",
"priority": "Invalid priority value",
}
assert "tool=create_issue" in str(error)
assert "fields=['title', 'priority']" in str(error)
class TestExceptionInheritance:
"""Tests for exception inheritance chain."""
def test_all_errors_inherit_from_mcp_error(self):
"""Test that all custom exceptions inherit from MCPError."""
assert issubclass(MCPConnectionError, MCPError)
assert issubclass(MCPTimeoutError, MCPError)
assert issubclass(MCPToolError, MCPError)
assert issubclass(MCPServerNotFoundError, MCPError)
assert issubclass(MCPToolNotFoundError, MCPError)
assert issubclass(MCPCircuitOpenError, MCPError)
assert issubclass(MCPValidationError, MCPError)
def test_all_errors_inherit_from_exception(self):
"""Test that base MCPError inherits from Exception."""
assert issubclass(MCPError, Exception)
def test_catch_all_with_mcp_error(self):
"""Test that all errors can be caught with MCPError."""
def raise_connection_error():
raise MCPConnectionError("Connection failed")
def raise_timeout_error():
raise MCPTimeoutError("Timeout")
def raise_tool_error():
raise MCPToolError("Tool failed")
with pytest.raises(MCPError):
raise_connection_error()
with pytest.raises(MCPError):
raise_timeout_error()
with pytest.raises(MCPError):
raise_tool_error()