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>
260 lines
8.6 KiB
Python
260 lines
8.6 KiB
Python
"""
|
|
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()
|