feat: implement auth infrastructure and first two FileStation tools
- 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>
This commit is contained in:
@@ -1,2 +1,189 @@
|
||||
"""Application configuration: YAML loading, validation, env var overrides."""
|
||||
# Implementation pending approval — see SPEC.md
|
||||
"""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)
|
||||
|
||||
Reference in New Issue
Block a user