Source code for ralph.config.mcp_loader

"""Three-layer mcp.toml loader.

Merge order (lowest → highest priority):
  1. Bundled default  - ralph/policy/defaults/mcp.toml   (ships in wheel)
  2. User-global      - $XDG_CONFIG_HOME/ralph-workflow-mcp.toml
                        (default: ~/.config/ralph-workflow-mcp.toml)
  3. Project-local    - .agent/mcp.toml  (resolved via WorkspaceScope)

Unlike the main config loader, TOML parse errors here are fail-fast: any
malformed file triggers a typed exit rather than a silent empty-dict fallback.
"""

from __future__ import annotations

import tomllib
from os import getenv
from pathlib import Path
from typing import TYPE_CHECKING, cast

from loguru import logger
from pydantic import ValidationError

from ralph.config.loader import deep_merge as _deep_merge
from ralph.config.mcp_models import McpConfig

if TYPE_CHECKING:
    from ralph.workspace.scope import WorkspaceScope

_GLOBAL_MCP_FILENAME = "ralph-workflow-mcp.toml"
_LOCAL_MCP_FILENAME = "mcp.toml"
_TOML_DECODE_ERROR = cast("type[ValueError]", tomllib.TOMLDecodeError)


[docs] class McpConfigError(SystemExit): """Typed fail-fast exit for invalid MCP configuration.""" def __init__(self, message: str, *, code: int = 1) -> None: super().__init__(code) self.message = message self.code = code def __str__(self) -> str: return self.message
[docs] def bundled_default_mcp_config_path() -> Path: """Return the path to the bundled default MCP configuration file.""" return Path(__file__).parent.parent / "policy" / "defaults" / _LOCAL_MCP_FILENAME
[docs] def global_mcp_config_path() -> Path: """Return the user-level global MCP config path, respecting XDG_CONFIG_HOME.""" xdg = getenv("XDG_CONFIG_HOME") if xdg: return Path(xdg) / _GLOBAL_MCP_FILENAME return Path.home() / ".config" / _GLOBAL_MCP_FILENAME
[docs] def local_mcp_config_path(workspace_scope: WorkspaceScope) -> Path: """Return the workspace-local MCP config path for the given workspace scope.""" if hasattr(workspace_scope, "resolve_agent_file"): return workspace_scope.resolve_agent_file(_LOCAL_MCP_FILENAME) return workspace_scope.local_config_path.parent / _LOCAL_MCP_FILENAME
def _load_mcp_toml(path: Path) -> dict[str, object]: if not path.exists(): logger.debug("MCP config not found, skipping: {}", path) return {} logger.debug("Loading MCP config from {}", path) with path.open("rb") as fh: try: data: dict[str, object] = tomllib.load(fh) except _TOML_DECODE_ERROR as exc: message = f"MCP config parse error at {path}: {exc}" logger.error(message) raise McpConfigError(message) from exc return data def _validate_fallback_backends(config: McpConfig) -> None: for entry in config.web_search.fallback: if entry != "ddgs" and entry not in config.web_search.backends: message = ( f"MCP config: fallback backend '{entry}' is not configured in" f" [web_search.backends]; add a [web_search.backends.{entry}] section" " or remove it from the fallback list" ) logger.error(message) raise McpConfigError(message) def _inject_mcp_server_names(merged: dict[str, object]) -> None: raw_servers = merged.get("mcp_servers") if not isinstance(raw_servers, dict): return for server_name, server_spec in raw_servers.items(): if isinstance(server_spec, dict) and "name" not in server_spec: server_spec["name"] = server_name
[docs] def load_mcp_config( workspace_scope: WorkspaceScope | None = None, config_path: Path | None = None, ) -> McpConfig: """Build merged McpConfig from all layers. Args: workspace_scope: Provides the project-local .agent/ root. Not used when config_path is given. config_path: Explicit override for the project-local layer. Returns: Validated McpConfig. Raises: McpConfigError: On TOML parse error, schema validation failure, or unknown fallback backend reference. """ bundled = _load_mcp_toml(bundled_default_mcp_config_path()) global_data = _load_mcp_toml(global_mcp_config_path()) if config_path is not None: local_data = _load_mcp_toml(config_path) elif workspace_scope is not None: local_data = _load_mcp_toml(local_mcp_config_path(workspace_scope)) else: local_data = {} merged = _deep_merge(bundled, global_data) merged = _deep_merge(merged, local_data) _inject_mcp_server_names(merged) try: config = McpConfig.model_validate(merged) except ValidationError as exc: message = f"MCP config validation failed:\n{exc}" logger.error(message) raise McpConfigError(message) from exc logger.debug( "MCP config loaded: {} server(s), web_search.enabled={}", len(config.mcp_servers), config.web_search.enabled, ) _validate_fallback_backends(config) return config