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:
2026-04-14 08:23:34 +02:00
parent 9fc5a3d68c
commit 59301ae760
10 changed files with 2945 additions and 8 deletions
+189 -2
View File
@@ -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)