"""YAML config loading and validation. Config file path: ~/.config/mcp-synology-filestation/config.yaml Loading order: 1. Parse YAML file 2. Merge environment variable overrides 3. Validate with dataclasses """ from __future__ import annotations import logging import os from dataclasses import dataclass from pathlib import Path from typing import Any import yaml logger = logging.getLogger(__name__) CONFIG_DIR = Path.home() / ".config" / "mcp-synology-filestation" CONFIG_PATH = CONFIG_DIR / "config.yaml" CURRENT_SCHEMA_VERSION = 1 # Environment variable overrides (env var -> config key path) ENV_VAR_MAP: dict[str, str] = { "SYNOLOGY_HOST": "connection.host", "SYNOLOGY_PORT": "connection.port", "SYNOLOGY_HTTPS": "connection.https", "SYNOLOGY_VERIFY_SSL": "connection.verify_ssl", "SYNOLOGY_USERNAME": "auth.username", "SYNOLOGY_PASSWORD": "auth.password", } @dataclass class ConnectionConfig: """NAS connection settings.""" host: str port: int = 443 https: bool = True verify_ssl: bool = True timeout: int = 30 @dataclass class AppConfig: """Top-level application configuration.""" schema_version: int connection: ConnectionConfig alias: str | None = None @property def base_url(self) -> str: """Build base URL for DSM API calls.""" scheme = "https" if self.connection.https else "http" return f"{scheme}://{self.connection.host}:{self.connection.port}" @property def keyring_service(self) -> str: """Keyring service name.""" return "mcp-synology-filestation" def _merge_env_overrides(raw: dict[str, Any]) -> dict[str, Any]: """Merge environment variable overrides into raw config dict.""" for env_var, dotted_path in ENV_VAR_MAP.items(): value = os.environ.get(env_var) if value is None: continue logger.debug("Env override: %s -> %s", env_var, dotted_path) parts = dotted_path.split(".") target = raw for part in parts[:-1]: if part not in target or not isinstance(target[part], dict): target[part] = {} target = target[part] key = parts[-1] # Type coercion for known non-string values if key == "port" or key == "timeout": target[key] = int(value) elif key in ("https", "verify_ssl"): target[key] = value.lower() in ("true", "1", "yes") else: target[key] = value return raw def load_config(path: str | Path | None = None) -> AppConfig: """Load, validate, and return the application config. Args: path: Explicit config file path, or None to use default location. Raises: FileNotFoundError: If config file does not exist. ValueError: If config is invalid. """ config_path = Path(path) if path else CONFIG_PATH if not config_path.exists(): msg = ( f"Config file not found: {config_path}\n" "Run 'mcp-synology-filestation setup' to create it." ) raise FileNotFoundError(msg) logger.debug("Loading config from %s", config_path) raw_text = config_path.read_text(encoding="utf-8") raw: dict[str, Any] = yaml.safe_load(raw_text) or {} raw = _merge_env_overrides(raw) return _validate_config(raw) def _validate_config(raw: dict[str, Any]) -> AppConfig: """Validate raw config dict and return AppConfig. Args: raw: Raw config dictionary from YAML. Raises: ValueError: If required fields are missing or invalid. """ schema_version = raw.get("schema_version") if schema_version != CURRENT_SCHEMA_VERSION: msg = f"Config schema_version is {schema_version!r}, expected {CURRENT_SCHEMA_VERSION}." raise ValueError(msg) conn_raw = raw.get("connection", {}) if not conn_raw.get("host"): msg = ( "connection.host is required. " "Set it in the config file or via SYNOLOGY_HOST environment variable." ) raise ValueError(msg) connection = ConnectionConfig( host=conn_raw["host"], port=int(conn_raw.get("port", 443)), https=bool(conn_raw.get("https", True)), verify_ssl=bool(conn_raw.get("verify_ssl", True)), timeout=int(conn_raw.get("timeout", 30)), ) return AppConfig( schema_version=schema_version, connection=connection, alias=raw.get("alias"), ) def save_config(config: AppConfig, path: str | Path | None = None) -> None: """Save AppConfig to YAML file. Args: config: AppConfig instance to save. path: Target file path, or None to use default location. """ config_path = Path(path) if path else CONFIG_PATH config_path.parent.mkdir(parents=True, exist_ok=True) data: dict[str, Any] = { "schema_version": config.schema_version, "connection": { "host": config.connection.host, "port": config.connection.port, "https": config.connection.https, "verify_ssl": config.connection.verify_ssl, "timeout": config.connection.timeout, }, } if config.alias: data["alias"] = config.alias header = "# Generated by mcp-synology-filestation setup\n" config_path.write_text( header + yaml.dump(data, default_flow_style=False, sort_keys=False), encoding="utf-8", ) logger.debug("Config saved to %s", config_path)