forked from cardosofelipe/fast-next-template
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:
234
backend/app/services/mcp/config.py
Normal file
234
backend/app/services/mcp/config.py
Normal file
@@ -0,0 +1,234 @@
|
||||
"""
|
||||
MCP Configuration System
|
||||
|
||||
Pydantic models for MCP server configuration with YAML file loading
|
||||
and environment variable overrides.
|
||||
"""
|
||||
|
||||
import os
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
class TransportType(str, Enum):
|
||||
"""Supported MCP transport types."""
|
||||
|
||||
HTTP = "http"
|
||||
STDIO = "stdio"
|
||||
SSE = "sse"
|
||||
|
||||
|
||||
class MCPServerConfig(BaseModel):
|
||||
"""Configuration for a single MCP server."""
|
||||
|
||||
url: str = Field(..., description="Server URL (supports ${ENV_VAR} syntax)")
|
||||
transport: TransportType = Field(
|
||||
default=TransportType.HTTP,
|
||||
description="Transport protocol to use",
|
||||
)
|
||||
timeout: int = Field(
|
||||
default=30,
|
||||
ge=1,
|
||||
le=600,
|
||||
description="Request timeout in seconds",
|
||||
)
|
||||
retry_attempts: int = Field(
|
||||
default=3,
|
||||
ge=0,
|
||||
le=10,
|
||||
description="Number of retry attempts on failure",
|
||||
)
|
||||
retry_delay: float = Field(
|
||||
default=1.0,
|
||||
ge=0.1,
|
||||
le=60.0,
|
||||
description="Initial delay between retries in seconds",
|
||||
)
|
||||
retry_max_delay: float = Field(
|
||||
default=30.0,
|
||||
ge=1.0,
|
||||
le=300.0,
|
||||
description="Maximum delay between retries in seconds",
|
||||
)
|
||||
circuit_breaker_threshold: int = Field(
|
||||
default=5,
|
||||
ge=1,
|
||||
le=50,
|
||||
description="Number of failures before opening circuit",
|
||||
)
|
||||
circuit_breaker_timeout: float = Field(
|
||||
default=30.0,
|
||||
ge=5.0,
|
||||
le=300.0,
|
||||
description="Seconds to wait before attempting to close circuit",
|
||||
)
|
||||
enabled: bool = Field(
|
||||
default=True,
|
||||
description="Whether this server is enabled",
|
||||
)
|
||||
description: str | None = Field(
|
||||
default=None,
|
||||
description="Human-readable description of the server",
|
||||
)
|
||||
|
||||
@field_validator("url", mode="before")
|
||||
@classmethod
|
||||
def expand_env_vars(cls, v: str) -> str:
|
||||
"""Expand environment variables in URL using ${VAR:-default} syntax."""
|
||||
if not isinstance(v, str):
|
||||
return v
|
||||
|
||||
result = v
|
||||
# Find all ${VAR} or ${VAR:-default} patterns
|
||||
import re
|
||||
|
||||
pattern = r"\$\{([^}]+)\}"
|
||||
matches = re.findall(pattern, v)
|
||||
|
||||
for match in matches:
|
||||
if ":-" in match:
|
||||
var_name, default = match.split(":-", 1)
|
||||
else:
|
||||
var_name, default = match, ""
|
||||
|
||||
env_value = os.environ.get(var_name.strip(), default)
|
||||
result = result.replace(f"${{{match}}}", env_value)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class MCPConfig(BaseModel):
|
||||
"""Root configuration for all MCP servers."""
|
||||
|
||||
mcp_servers: dict[str, MCPServerConfig] = Field(
|
||||
default_factory=dict,
|
||||
description="Map of server names to their configurations",
|
||||
)
|
||||
|
||||
# Global defaults
|
||||
default_timeout: int = Field(
|
||||
default=30,
|
||||
description="Default timeout for all servers",
|
||||
)
|
||||
default_retry_attempts: int = Field(
|
||||
default=3,
|
||||
description="Default retry attempts for all servers",
|
||||
)
|
||||
connection_pool_size: int = Field(
|
||||
default=10,
|
||||
ge=1,
|
||||
le=100,
|
||||
description="Maximum connections per server",
|
||||
)
|
||||
health_check_interval: int = Field(
|
||||
default=30,
|
||||
ge=5,
|
||||
le=300,
|
||||
description="Seconds between health checks",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_yaml(cls, path: str | Path) -> "MCPConfig":
|
||||
"""Load configuration from a YAML file."""
|
||||
path = Path(path)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"MCP config file not found: {path}")
|
||||
|
||||
with path.open("r") as f:
|
||||
data = yaml.safe_load(f)
|
||||
|
||||
if data is None:
|
||||
data = {}
|
||||
|
||||
return cls.model_validate(data)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "MCPConfig":
|
||||
"""Load configuration from a dictionary."""
|
||||
return cls.model_validate(data)
|
||||
|
||||
def get_server(self, name: str) -> MCPServerConfig | None:
|
||||
"""Get a server configuration by name."""
|
||||
return self.mcp_servers.get(name)
|
||||
|
||||
def get_enabled_servers(self) -> dict[str, MCPServerConfig]:
|
||||
"""Get all enabled server configurations."""
|
||||
return {
|
||||
name: config
|
||||
for name, config in self.mcp_servers.items()
|
||||
if config.enabled
|
||||
}
|
||||
|
||||
def list_server_names(self) -> list[str]:
|
||||
"""Get list of all configured server names."""
|
||||
return list(self.mcp_servers.keys())
|
||||
|
||||
|
||||
# Default configuration path
|
||||
DEFAULT_CONFIG_PATH = Path(__file__).parent.parent.parent.parent / "mcp_servers.yaml"
|
||||
|
||||
|
||||
def load_mcp_config(path: str | Path | None = None) -> MCPConfig:
|
||||
"""
|
||||
Load MCP configuration from file or environment.
|
||||
|
||||
Priority:
|
||||
1. Explicit path parameter
|
||||
2. MCP_CONFIG_PATH environment variable
|
||||
3. Default path (backend/mcp_servers.yaml)
|
||||
4. Empty config if no file exists
|
||||
"""
|
||||
if path is None:
|
||||
path = os.environ.get("MCP_CONFIG_PATH", str(DEFAULT_CONFIG_PATH))
|
||||
|
||||
path = Path(path)
|
||||
|
||||
if not path.exists():
|
||||
# Return empty config if no file exists (allows runtime registration)
|
||||
return MCPConfig()
|
||||
|
||||
return MCPConfig.from_yaml(path)
|
||||
|
||||
|
||||
def create_default_config() -> MCPConfig:
|
||||
"""
|
||||
Create a default MCP configuration with standard servers.
|
||||
|
||||
This is useful for development and as a template.
|
||||
"""
|
||||
return MCPConfig(
|
||||
mcp_servers={
|
||||
"llm-gateway": MCPServerConfig(
|
||||
url="${LLM_GATEWAY_URL:-http://localhost:8001}",
|
||||
transport=TransportType.HTTP,
|
||||
timeout=60,
|
||||
description="LLM Gateway for multi-provider AI interactions",
|
||||
),
|
||||
"knowledge-base": MCPServerConfig(
|
||||
url="${KNOWLEDGE_BASE_URL:-http://localhost:8002}",
|
||||
transport=TransportType.HTTP,
|
||||
timeout=30,
|
||||
description="Knowledge Base for RAG and document retrieval",
|
||||
),
|
||||
"git-ops": MCPServerConfig(
|
||||
url="${GIT_OPS_URL:-http://localhost:8003}",
|
||||
transport=TransportType.HTTP,
|
||||
timeout=120,
|
||||
description="Git Operations for repository management",
|
||||
),
|
||||
"issues": MCPServerConfig(
|
||||
url="${ISSUES_URL:-http://localhost:8004}",
|
||||
transport=TransportType.HTTP,
|
||||
timeout=30,
|
||||
description="Issue Tracker for Gitea/GitHub/GitLab",
|
||||
),
|
||||
},
|
||||
default_timeout=30,
|
||||
default_retry_attempts=3,
|
||||
connection_pool_size=10,
|
||||
health_check_interval=30,
|
||||
)
|
||||
Reference in New Issue
Block a user