Files
syndarix/backend/tests/services/mcp/test_config.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

320 lines
11 KiB
Python

"""
Tests for MCP Configuration System
"""
import os
import tempfile
from pathlib import Path
import pytest
import yaml
from app.services.mcp.config import (
MCPConfig,
MCPServerConfig,
TransportType,
create_default_config,
load_mcp_config,
)
class TestTransportType:
"""Tests for TransportType enum."""
def test_transport_types(self):
"""Test that all transport types are defined."""
assert TransportType.HTTP == "http"
assert TransportType.STDIO == "stdio"
assert TransportType.SSE == "sse"
def test_transport_type_from_string(self):
"""Test creating transport type from string."""
assert TransportType("http") == TransportType.HTTP
assert TransportType("stdio") == TransportType.STDIO
assert TransportType("sse") == TransportType.SSE
class TestMCPServerConfig:
"""Tests for MCPServerConfig model."""
def test_minimal_config(self):
"""Test creating config with only required fields."""
config = MCPServerConfig(url="http://localhost:8000")
assert config.url == "http://localhost:8000"
assert config.transport == TransportType.HTTP
assert config.timeout == 30
assert config.retry_attempts == 3
assert config.enabled is True
def test_full_config(self):
"""Test creating config with all fields."""
config = MCPServerConfig(
url="http://localhost:8000",
transport=TransportType.SSE,
timeout=60,
retry_attempts=5,
retry_delay=2.0,
retry_max_delay=60.0,
circuit_breaker_threshold=10,
circuit_breaker_timeout=60.0,
enabled=False,
description="Test server",
)
assert config.timeout == 60
assert config.transport == TransportType.SSE
assert config.retry_attempts == 5
assert config.retry_delay == 2.0
assert config.retry_max_delay == 60.0
assert config.circuit_breaker_threshold == 10
assert config.circuit_breaker_timeout == 60.0
assert config.enabled is False
assert config.description == "Test server"
def test_env_var_expansion_simple(self):
"""Test simple environment variable expansion."""
os.environ["TEST_SERVER_URL"] = "http://test-server:9000"
try:
config = MCPServerConfig(url="${TEST_SERVER_URL}")
assert config.url == "http://test-server:9000"
finally:
del os.environ["TEST_SERVER_URL"]
def test_env_var_expansion_with_default(self):
"""Test environment variable expansion with default."""
# Ensure env var is not set
os.environ.pop("NONEXISTENT_URL", None)
config = MCPServerConfig(url="${NONEXISTENT_URL:-http://default:8000}")
assert config.url == "http://default:8000"
def test_env_var_expansion_override_default(self):
"""Test environment variable override of default."""
os.environ["TEST_OVERRIDE_URL"] = "http://override:9000"
try:
config = MCPServerConfig(url="${TEST_OVERRIDE_URL:-http://default:8000}")
assert config.url == "http://override:9000"
finally:
del os.environ["TEST_OVERRIDE_URL"]
def test_timeout_validation(self):
"""Test timeout validation bounds."""
# Valid bounds
config = MCPServerConfig(url="http://localhost", timeout=1)
assert config.timeout == 1
config = MCPServerConfig(url="http://localhost", timeout=600)
assert config.timeout == 600
# Invalid bounds
with pytest.raises(ValueError):
MCPServerConfig(url="http://localhost", timeout=0)
with pytest.raises(ValueError):
MCPServerConfig(url="http://localhost", timeout=601)
def test_retry_attempts_validation(self):
"""Test retry attempts validation bounds."""
config = MCPServerConfig(url="http://localhost", retry_attempts=0)
assert config.retry_attempts == 0
config = MCPServerConfig(url="http://localhost", retry_attempts=10)
assert config.retry_attempts == 10
with pytest.raises(ValueError):
MCPServerConfig(url="http://localhost", retry_attempts=-1)
with pytest.raises(ValueError):
MCPServerConfig(url="http://localhost", retry_attempts=11)
class TestMCPConfig:
"""Tests for MCPConfig model."""
def test_empty_config(self):
"""Test creating empty config."""
config = MCPConfig()
assert config.mcp_servers == {}
assert config.default_timeout == 30
assert config.default_retry_attempts == 3
assert config.connection_pool_size == 10
assert config.health_check_interval == 30
def test_config_with_servers(self):
"""Test creating config with servers."""
config = MCPConfig(
mcp_servers={
"server-1": MCPServerConfig(url="http://server1:8000"),
"server-2": MCPServerConfig(url="http://server2:8000"),
}
)
assert len(config.mcp_servers) == 2
assert "server-1" in config.mcp_servers
assert "server-2" in config.mcp_servers
def test_get_server(self):
"""Test getting server by name."""
config = MCPConfig(
mcp_servers={
"server-1": MCPServerConfig(url="http://server1:8000"),
}
)
server = config.get_server("server-1")
assert server is not None
assert server.url == "http://server1:8000"
missing = config.get_server("nonexistent")
assert missing is None
def test_get_enabled_servers(self):
"""Test getting only enabled servers."""
config = MCPConfig(
mcp_servers={
"enabled-1": MCPServerConfig(url="http://e1:8000", enabled=True),
"disabled-1": MCPServerConfig(url="http://d1:8000", enabled=False),
"enabled-2": MCPServerConfig(url="http://e2:8000", enabled=True),
}
)
enabled = config.get_enabled_servers()
assert len(enabled) == 2
assert "enabled-1" in enabled
assert "enabled-2" in enabled
assert "disabled-1" not in enabled
def test_list_server_names(self):
"""Test listing server names."""
config = MCPConfig(
mcp_servers={
"server-a": MCPServerConfig(url="http://a:8000"),
"server-b": MCPServerConfig(url="http://b:8000"),
}
)
names = config.list_server_names()
assert sorted(names) == ["server-a", "server-b"]
def test_from_dict(self):
"""Test creating config from dictionary."""
data = {
"mcp_servers": {
"test-server": {
"url": "http://test:8000",
"timeout": 45,
}
},
"default_timeout": 60,
}
config = MCPConfig.from_dict(data)
assert config.default_timeout == 60
assert config.mcp_servers["test-server"].timeout == 45
def test_from_yaml(self):
"""Test loading config from YAML file."""
yaml_content = """
mcp_servers:
test-server:
url: http://test:8000
timeout: 45
transport: http
enabled: true
default_timeout: 60
connection_pool_size: 20
"""
with tempfile.NamedTemporaryFile(
mode="w", suffix=".yaml", delete=False
) as f:
f.write(yaml_content)
f.flush()
try:
config = MCPConfig.from_yaml(f.name)
assert config.default_timeout == 60
assert config.connection_pool_size == 20
assert "test-server" in config.mcp_servers
assert config.mcp_servers["test-server"].timeout == 45
finally:
os.unlink(f.name)
def test_from_yaml_file_not_found(self):
"""Test error when YAML file not found."""
with pytest.raises(FileNotFoundError):
MCPConfig.from_yaml("/nonexistent/path/config.yaml")
class TestLoadMCPConfig:
"""Tests for load_mcp_config function."""
def test_load_with_explicit_path(self):
"""Test loading config with explicit path."""
yaml_content = """
mcp_servers:
explicit-server:
url: http://explicit:8000
"""
with tempfile.NamedTemporaryFile(
mode="w", suffix=".yaml", delete=False
) as f:
f.write(yaml_content)
f.flush()
try:
config = load_mcp_config(f.name)
assert "explicit-server" in config.mcp_servers
finally:
os.unlink(f.name)
def test_load_with_env_var(self):
"""Test loading config from environment variable path."""
yaml_content = """
mcp_servers:
env-server:
url: http://env:8000
"""
with tempfile.NamedTemporaryFile(
mode="w", suffix=".yaml", delete=False
) as f:
f.write(yaml_content)
f.flush()
os.environ["MCP_CONFIG_PATH"] = f.name
try:
config = load_mcp_config()
assert "env-server" in config.mcp_servers
finally:
del os.environ["MCP_CONFIG_PATH"]
os.unlink(f.name)
def test_load_returns_empty_config_if_missing(self):
"""Test that missing file returns empty config."""
os.environ.pop("MCP_CONFIG_PATH", None)
config = load_mcp_config("/nonexistent/path/config.yaml")
assert config.mcp_servers == {}
class TestCreateDefaultConfig:
"""Tests for create_default_config function."""
def test_creates_standard_servers(self):
"""Test that default config has standard servers."""
config = create_default_config()
assert "llm-gateway" in config.mcp_servers
assert "knowledge-base" in config.mcp_servers
assert "git-ops" in config.mcp_servers
assert "issues" in config.mcp_servers
def test_servers_have_correct_defaults(self):
"""Test that servers have correct default values."""
config = create_default_config()
llm = config.mcp_servers["llm-gateway"]
assert llm.timeout == 60 # LLM has longer timeout
assert llm.transport == TransportType.HTTP
git = config.mcp_servers["git-ops"]
assert git.timeout == 120 # Git ops has longest timeout
def test_servers_are_enabled(self):
"""Test that all default servers are enabled."""
config = create_default_config()
for name, server in config.mcp_servers.items():
assert server.enabled is True, f"Server {name} should be enabled"