forked from cardosofelipe/fast-next-template
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>
235 lines
6.6 KiB
Python
235 lines
6.6 KiB
Python
"""
|
|
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,
|
|
)
|