"""CLI entry point: setup, check, serve commands.""" from __future__ import annotations import asyncio import contextlib import json import logging import sys from typing import Any import click logger = logging.getLogger(__name__) def _configure_logging(level: str = "warning") -> None: """Configure stderr logging.""" numeric = getattr(logging, level.upper(), logging.WARNING) logging.basicConfig( level=numeric, format="%(levelname)s %(name)s: %(message)s", stream=sys.stderr, ) @click.group() def main() -> None: """MCP server for Synology Container Manager.""" # ────────────────────────────────────────────────────────────────────────── # setup # ────────────────────────────────────────────────────────────────────────── @main.command() @click.option("--verbose", "-v", is_flag=True, help="Enable debug logging") def setup(verbose: bool) -> None: """Interactive setup wizard: configure connection and store credentials.""" _configure_logging("debug" if verbose else "warning") asyncio.run(_run_setup()) async def _run_setup() -> None: """Interactive setup flow.""" from mcp_synology_container.auth import AuthManager from mcp_synology_container.config import CONFIG_PATH, AppConfig, ConnectionConfig, save_config from mcp_synology_container.dsm_client import DsmClient, SynologyError click.echo("=== mcp-synology-container setup ===\n") # Connection details host = click.prompt("NAS hostname or IP") use_https = click.confirm("Use HTTPS?", default=True) default_port = 443 if use_https else 5000 port = click.prompt("Port", default=default_port, type=int) verify_ssl = True if use_https: verify_ssl = click.confirm("Verify SSL certificate?", default=True) compose_base = click.prompt( "Base path for compose projects on NAS", default="/volume1/docker", ) alias_input = click.prompt("Alias (friendly name, optional)", default="", show_default=False) alias: str | None = alias_input.strip() or None config = AppConfig( schema_version=1, connection=ConnectionConfig( host=host, port=port, https=use_https, verify_ssl=verify_ssl, ), compose_base_path=compose_base, alias=alias, ) click.echo() username = click.prompt("DSM username") password = click.prompt("DSM password", hide_input=True) # Store in keyring auth = AuthManager(config) keyring_ok = auth.store_credentials(username, password) if keyring_ok: click.echo("Credentials stored in OS keyring.") else: click.echo( click.style("Warning: keyring unavailable.", fg="yellow") + " Set SYNOLOGY_USERNAME and SYNOLOGY_PASSWORD environment variables instead." ) # Test connection click.echo("\nTesting connection...") base_url = config.base_url try: async with DsmClient(base_url, verify_ssl=verify_ssl) as client: await client.query_api_info() # Attempt login with possible 2FA handling params: dict[str, Any] = { "account": username, "passwd": password, "format": "sid", } try: data = await client.request("SYNO.API.Auth", "login", version=6, params=params) sid = data.get("sid") except SynologyError as e: if e.code == 403: # 2FA required click.echo( click.style("2FA is enabled.", fg="yellow") + " Enter the OTP code from your authenticator app." ) otp_code = click.prompt("OTP code") data = await client.request( "SYNO.API.Auth", "login", version=6, params={ "account": username, "passwd": password, "otp_code": otp_code, "enable_device_token": "yes", "device_name": "MCPSynologyContainer", "format": "sid", }, ) sid = data.get("sid") device_token = data.get("did", "") if device_token and keyring_ok: auth.store_device_token(device_token) click.echo(click.style("2FA device token stored in keyring.", fg="green")) else: click.echo(click.style(f"Login failed: {e}", fg="red"), err=True) sys.exit(1) if sid: client.sid = sid click.echo(click.style("Login successful!", fg="green")) # Logout cleanly with contextlib.suppress(SynologyError): await client.request("SYNO.API.Auth", "logout", version=6, params={}) else: click.echo(click.style("Login failed: no session ID returned.", fg="red"), err=True) sys.exit(1) except Exception as e: click.echo(click.style(f"Connection failed: {e}", fg="red"), err=True) sys.exit(1) # Save config save_config(config) click.echo(f"\nConfig saved to {CONFIG_PATH}") # Emit Claude Desktop snippet _emit_desktop_snippet() def _emit_desktop_snippet() -> None: """Print the Claude Desktop configuration snippet.""" import shutil cmd = shutil.which("mcp-synology-container") or "mcp-synology-container" snippet = { "mcpServers": { "synology-container": { "command": cmd, "args": ["serve"], } } } click.echo("\nAdd this to your Claude Desktop config (claude_desktop_config.json):\n") click.echo(json.dumps(snippet, indent=2)) # ────────────────────────────────────────────────────────────────────────── # check # ────────────────────────────────────────────────────────────────────────── @main.command() @click.option("--config", "-c", "config_path", type=click.Path(), help="Config file path") @click.option("--verbose", "-v", is_flag=True, help="Enable debug logging") def check(config_path: str | None, verbose: bool) -> None: """Test connection to DSM and print status.""" _configure_logging("debug" if verbose else "warning") ok = asyncio.run(_run_check(config_path)) sys.exit(0 if ok else 1) async def _run_check(config_path: str | None) -> bool: """Run connectivity check. Returns True on success.""" from mcp_synology_container.auth import AuthenticationError, AuthManager from mcp_synology_container.config import load_config from mcp_synology_container.dsm_client import DsmClient, SynologyError try: config = load_config(config_path) except (FileNotFoundError, ValueError) as e: click.echo(click.style(f"Config error: {e}", fg="red"), err=True) return False host = config.connection.host port = config.connection.port click.echo(f"Host: {host}:{port}") click.echo(f"HTTPS: {config.connection.https}") click.echo(f"Verify SSL: {config.connection.verify_ssl}") click.echo(f"Compose: {config.compose_base_path}") if config.alias: click.echo(f"Alias: {config.alias}") click.echo() auth = AuthManager(config) try: username, _, device_token = auth.resolve_credentials() click.echo(f"Credentials: found (user={username}, 2FA={'yes' if device_token else 'no'})") except AuthenticationError as e: click.echo(click.style(f"Credentials: {e}", fg="red"), err=True) return False try: async with DsmClient(config.base_url, config.connection.verify_ssl) as client: await client.query_api_info() click.echo("API info: fetched successfully") client.set_auth_manager(auth) sid = await auth.login(client) client.sid = sid click.echo(click.style("Login: successful", fg="green")) # Check Docker API availability docker_apis = [ "SYNO.Docker.Project", "SYNO.Docker.Container", "SYNO.Docker.Container.Log", "SYNO.Docker.Image", "SYNO.FileStation.Download", "SYNO.FileStation.Upload", ] click.echo("\nRequired APIs:") all_ok = True for api_name in docker_apis: if api_name in client._api_cache: info = client._api_cache[api_name] click.echo(f" {api_name}: v{info['minVersion']}-v{info['maxVersion']} ✓") else: click.echo(click.style(f" {api_name}: NOT FOUND", fg="red")) all_ok = False await auth.logout(client) if all_ok: click.echo(click.style("\nAll checks passed.", fg="green")) else: click.echo( click.style( "\nSome APIs not found. Ensure Container Manager is installed.", fg="yellow" ) ) return all_ok except SynologyError as e: click.echo(click.style(f"DSM error: {e}", fg="red"), err=True) return False except Exception as e: click.echo(click.style(f"Connection error: {e}", fg="red"), err=True) return False # ────────────────────────────────────────────────────────────────────────── # serve # ────────────────────────────────────────────────────────────────────────── @main.command() @click.option("--config", "-c", "config_path", type=click.Path(), help="Config file path") def serve(config_path: str | None) -> None: """Start the MCP server in stdio mode.""" sys.stderr.write("SERVER STARTING\n") sys.stderr.flush() _configure_logging("warning") # Use anyio.run() — FastMCP uses anyio internally (anyio.create_task_group), # and anyio.run() ensures the correct backend context is set up. # asyncio.run() can cause issues on Windows (ProactorEventLoop + anyio). import anyio anyio.run(_run_serve, config_path) async def _run_serve(config_path: str | None) -> None: """Initialize and run the MCP server.""" # Eagerly import all modules so any ImportError surfaces immediately on stderr # instead of silently killing the process. from mcp_synology_container.auth import AuthManager from mcp_synology_container.config import load_config from mcp_synology_container.dsm_client import DsmClient from mcp_synology_container.server import create_server logger.debug("Loading config from: %s", config_path or "default path") try: config = load_config(config_path) except (FileNotFoundError, ValueError) as e: sys.stderr.write(f"Config error: {e}\n") sys.stderr.flush() return # Open the HTTP client and register auth — but do NOT connect to the NAS yet. # Lazy init (_ensure_initialized) runs on the first tool call, so Claude Desktop # sees the server as ready immediately without waiting for NAS connectivity. async with DsmClient(config.base_url, config.connection.verify_ssl) as client: auth = AuthManager(config) client.set_auth_manager(auth) mcp_server = create_server(config, client) sys.stderr.write("MCP server ready\n") sys.stderr.flush() try: await mcp_server.run_stdio_async() except Exception as e: sys.stderr.write(f"MCP server error: {e}\n") sys.stderr.flush() raise