""" 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, )