Initial implementation
This commit is contained in:
@@ -0,0 +1,317 @@
|
||||
"""CLI entry point: setup, check, serve commands."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
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, AuthenticationError
|
||||
from mcp_synology_container.config import AppConfig, ConnectionConfig, CONFIG_PATH, 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
|
||||
try:
|
||||
await client.request("SYNO.API.Auth", "logout", version=6, params={})
|
||||
except SynologyError:
|
||||
pass
|
||||
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 AuthManager, AuthenticationError
|
||||
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."""
|
||||
_configure_logging("warning")
|
||||
asyncio.run(_run_serve(config_path))
|
||||
|
||||
|
||||
async def _run_serve(config_path: str | None) -> None:
|
||||
"""Initialize and run the MCP server."""
|
||||
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
|
||||
|
||||
try:
|
||||
config = load_config(config_path)
|
||||
except (FileNotFoundError, ValueError) as e:
|
||||
click.echo(click.style(f"Config error: {e}", fg="red"), err=True)
|
||||
sys.exit(1)
|
||||
|
||||
async with DsmClient(config.base_url, config.connection.verify_ssl) as client:
|
||||
await client.query_api_info()
|
||||
auth = AuthManager(config)
|
||||
client.set_auth_manager(auth)
|
||||
# Login on startup
|
||||
try:
|
||||
client.sid = await auth.login(client)
|
||||
except Exception as e:
|
||||
logger.error("Initial login failed: %s", e)
|
||||
sys.exit(1)
|
||||
|
||||
mcp_server = create_server(config, client)
|
||||
logger.info("MCP server starting (stdio mode)")
|
||||
await mcp_server.run_stdio_async()
|
||||
Reference in New Issue
Block a user