59301ae760
- auth.py: AuthManager with OS keyring, env var fallback, 2FA device token flow - config.py: AppConfig/ConnectionConfig dataclasses, YAML load/save, env overrides - client.py: FileStationClient with lazy init, session re-auth, upload/download - cli.py: setup / check / serve subcommands (anyio.run throughout) - server.py: create_server factory wiring FastMCP to FileStation tools - tools/filestation.py: list_shares and list_dir with ASCII table output, pagination hints, input validation, DSM error mapping - tests: 30 unit tests, all passing (auth, config, tools) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
190 lines
5.4 KiB
Python
190 lines
5.4 KiB
Python
"""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)
|