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,191 @@
|
||||
"""Credential management: OS keyring, env var overrides, 2FA device token."""
|
||||
# Implementation pending approval — see SPEC.md
|
||||
"""Authentication manager: credentials, keyring, login, 2FA device token.
|
||||
|
||||
Credential resolution order:
|
||||
1. Environment variables (SYNOLOGY_USERNAME, SYNOLOGY_PASSWORD)
|
||||
2. OS Keyring (set by 'setup' command)
|
||||
|
||||
Session tokens are held in memory only — never persisted to disk.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from mcp_synology_filestation.client import FileStationClient
|
||||
from mcp_synology_filestation.config import AppConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# DSM Auth API error code for 2FA required
|
||||
_ERROR_2FA_REQUIRED = 403
|
||||
|
||||
# Keyring keys
|
||||
_KEY_USERNAME = "username"
|
||||
_KEY_PASSWORD = "password"
|
||||
_KEY_DEVICE_TOKEN = "device_token"
|
||||
|
||||
|
||||
class AuthenticationError(Exception):
|
||||
"""Raised when authentication fails."""
|
||||
|
||||
def __init__(self, message: str, code: int | None = None) -> None:
|
||||
self.code = code
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class AuthManager:
|
||||
"""Manages DSM credentials and session lifecycle."""
|
||||
|
||||
def __init__(self, config: AppConfig) -> None:
|
||||
self._config = config
|
||||
|
||||
def resolve_credentials(self) -> tuple[str, str, str | None]:
|
||||
"""Resolve username, password, and optional device token.
|
||||
|
||||
Returns:
|
||||
Tuple of (username, password, device_token_or_None).
|
||||
|
||||
Raises:
|
||||
AuthenticationError: If no credentials are available.
|
||||
"""
|
||||
username: str | None = None
|
||||
password: str | None = None
|
||||
device_token: str | None = None
|
||||
|
||||
# 1. Environment variables (highest priority)
|
||||
username = os.environ.get("SYNOLOGY_USERNAME")
|
||||
password = os.environ.get("SYNOLOGY_PASSWORD")
|
||||
if username:
|
||||
logger.debug("Username from env var SYNOLOGY_USERNAME")
|
||||
if password:
|
||||
logger.debug("Password from env var SYNOLOGY_PASSWORD")
|
||||
|
||||
# 2. OS Keyring
|
||||
if not username or not password:
|
||||
try:
|
||||
import keyring
|
||||
|
||||
service = self._config.keyring_service
|
||||
kr_user = keyring.get_password(service, _KEY_USERNAME)
|
||||
kr_pass = keyring.get_password(service, _KEY_PASSWORD)
|
||||
kr_device = keyring.get_password(service, _KEY_DEVICE_TOKEN)
|
||||
|
||||
if kr_user and not username:
|
||||
username = kr_user
|
||||
logger.debug("Username from keyring")
|
||||
if kr_pass and not password:
|
||||
password = kr_pass
|
||||
logger.debug("Password from keyring")
|
||||
if kr_device and not device_token:
|
||||
device_token = kr_device
|
||||
logger.debug("Device token from keyring")
|
||||
except Exception:
|
||||
logger.debug("Keyring not available or failed")
|
||||
|
||||
if not username or not password:
|
||||
msg = (
|
||||
"No credentials found. Run 'mcp-synology-filestation setup' to store "
|
||||
"credentials in the OS keyring, or set SYNOLOGY_USERNAME and "
|
||||
"SYNOLOGY_PASSWORD environment variables."
|
||||
)
|
||||
raise AuthenticationError(msg)
|
||||
|
||||
return username, password, device_token
|
||||
|
||||
def store_credentials(self, username: str, password: str) -> bool:
|
||||
"""Store credentials in the OS keyring.
|
||||
|
||||
Returns:
|
||||
True on success, False if keyring is unavailable.
|
||||
"""
|
||||
try:
|
||||
import keyring
|
||||
|
||||
service = self._config.keyring_service
|
||||
keyring.set_password(service, _KEY_USERNAME, username)
|
||||
keyring.set_password(service, _KEY_PASSWORD, password)
|
||||
logger.debug("Credentials stored in keyring: service=%s", service)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning("Failed to store credentials in keyring: %s", e)
|
||||
return False
|
||||
|
||||
def store_device_token(self, device_token: str) -> None:
|
||||
"""Store 2FA device token in the OS keyring."""
|
||||
import keyring
|
||||
|
||||
service = self._config.keyring_service
|
||||
keyring.set_password(service, _KEY_DEVICE_TOKEN, device_token)
|
||||
logger.debug("Device token stored in keyring: service=%s", service)
|
||||
|
||||
async def login(self, client: FileStationClient) -> str:
|
||||
"""Authenticate against DSM API and return session ID.
|
||||
|
||||
Handles both simple login and 2FA device token flow.
|
||||
The caller is responsible for storing the returned SID.
|
||||
|
||||
Args:
|
||||
client: FileStationClient instance to use for the request.
|
||||
|
||||
Returns:
|
||||
Session ID string.
|
||||
|
||||
Raises:
|
||||
AuthenticationError: If login fails.
|
||||
"""
|
||||
username, password, device_token = self.resolve_credentials()
|
||||
|
||||
params: dict[str, Any] = {
|
||||
"account": username,
|
||||
"passwd": password,
|
||||
"format": "sid",
|
||||
}
|
||||
|
||||
if device_token:
|
||||
logger.debug("Login: using 2FA device token")
|
||||
params["device_id"] = device_token
|
||||
params["device_name"] = "MCPSynologyFileStation"
|
||||
else:
|
||||
logger.debug("Login: simple (no 2FA device token)")
|
||||
|
||||
from mcp_synology_filestation.client import SynologyError
|
||||
|
||||
try:
|
||||
data = await client.request("SYNO.API.Auth", "login", version=7, params=params)
|
||||
except SynologyError as e:
|
||||
if e.code == _ERROR_2FA_REQUIRED:
|
||||
raise AuthenticationError(
|
||||
"2FA is required but no device token is available. "
|
||||
"Run 'mcp-synology-filestation setup' to complete 2FA setup.",
|
||||
code=_ERROR_2FA_REQUIRED,
|
||||
) from e
|
||||
raise AuthenticationError(str(e), code=e.code) from e
|
||||
|
||||
sid: str | None = data.get("sid")
|
||||
if not sid:
|
||||
raise AuthenticationError("Login succeeded but no session ID was returned.")
|
||||
|
||||
logger.info("Authenticated as '%s'", username)
|
||||
return sid
|
||||
|
||||
async def logout(self, client: FileStationClient) -> None:
|
||||
"""Log out the current session.
|
||||
|
||||
Args:
|
||||
client: FileStationClient instance with active session.
|
||||
"""
|
||||
if not client.sid:
|
||||
return
|
||||
|
||||
logger.debug("Logging out session")
|
||||
try:
|
||||
from mcp_synology_filestation.client import SynologyError
|
||||
|
||||
await client.request("SYNO.API.Auth", "logout", version=7, params={})
|
||||
except SynologyError:
|
||||
logger.debug("Logout failed (session may have already expired)")
|
||||
finally:
|
||||
client.sid = None
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
"""CLI entry point: setup, check, serve commands."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
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 FileStation."""
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# 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")
|
||||
import anyio
|
||||
|
||||
anyio.run(_run_setup)
|
||||
|
||||
|
||||
async def _run_setup() -> None:
|
||||
"""Interactive setup flow."""
|
||||
from mcp_synology_filestation.auth import AuthManager
|
||||
from mcp_synology_filestation.client import FileStationClient, SynologyError
|
||||
from mcp_synology_filestation.config import (
|
||||
CONFIG_PATH,
|
||||
AppConfig,
|
||||
ConnectionConfig,
|
||||
save_config,
|
||||
)
|
||||
|
||||
click.echo("=== mcp-synology-filestation 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)
|
||||
|
||||
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,
|
||||
),
|
||||
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 FileStationClient(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",
|
||||
}
|
||||
sid: str | None = None
|
||||
try:
|
||||
data = await client.request("SYNO.API.Auth", "login", version=7, 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=7,
|
||||
params={
|
||||
"account": username,
|
||||
"passwd": password,
|
||||
"otp_code": otp_code,
|
||||
"enable_device_token": "yes",
|
||||
"device_name": "MCPSynologyFileStation",
|
||||
"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
|
||||
|
||||
# Verify FileStation API is available
|
||||
click.echo("Checking FileStation API availability...")
|
||||
try:
|
||||
await client.request(
|
||||
"SYNO.FileStation.List",
|
||||
"list_share",
|
||||
params={"additional": '["real_path"]'},
|
||||
)
|
||||
click.echo(click.style("FileStation API: available", fg="green"))
|
||||
except SynologyError as e:
|
||||
click.echo(
|
||||
click.style(f"FileStation API check failed: {e}", fg="yellow"),
|
||||
err=True,
|
||||
)
|
||||
|
||||
click.echo(click.style("Login successful!", fg="green"))
|
||||
import contextlib
|
||||
|
||||
with contextlib.suppress(SynologyError):
|
||||
await client.request("SYNO.API.Auth", "logout", version=7, 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_desktop_snippet()
|
||||
|
||||
|
||||
def _emit_desktop_snippet() -> None:
|
||||
"""Print the Claude Desktop configuration snippet."""
|
||||
import shutil
|
||||
|
||||
cmd = shutil.which("mcp-synology-filestation") or "mcp-synology-filestation"
|
||||
snippet = {
|
||||
"mcpServers": {
|
||||
"synology-filestation": {
|
||||
"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")
|
||||
import anyio
|
||||
|
||||
ok = anyio.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_filestation.auth import AuthenticationError, AuthManager
|
||||
from mcp_synology_filestation.client import FileStationClient, SynologyError
|
||||
from mcp_synology_filestation.config import load_config # noqa: I001
|
||||
|
||||
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}")
|
||||
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 FileStationClient(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"))
|
||||
|
||||
filestation_apis = [
|
||||
"SYNO.FileStation.Info",
|
||||
"SYNO.FileStation.List",
|
||||
"SYNO.FileStation.Stat",
|
||||
"SYNO.FileStation.Search",
|
||||
"SYNO.FileStation.Download",
|
||||
"SYNO.FileStation.Upload",
|
||||
"SYNO.FileStation.CreateFolder",
|
||||
"SYNO.FileStation.Rename",
|
||||
"SYNO.FileStation.CopyMove",
|
||||
"SYNO.FileStation.Delete",
|
||||
]
|
||||
click.echo("\nFileStation APIs:")
|
||||
all_ok = True
|
||||
for api_name in filestation_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']} \u2713")
|
||||
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 FileStation is available on your NAS.",
|
||||
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")
|
||||
import anyio
|
||||
|
||||
anyio.run(_run_serve, config_path)
|
||||
|
||||
|
||||
async def _run_serve(config_path: str | None) -> None:
|
||||
"""Initialize and run the MCP server."""
|
||||
from mcp_synology_filestation.auth import AuthManager
|
||||
from mcp_synology_filestation.client import FileStationClient
|
||||
from mcp_synology_filestation.config import load_config
|
||||
from mcp_synology_filestation.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 HTTP client and register auth — no NAS connection yet.
|
||||
# Lazy init runs on the first tool call so Claude Desktop sees the server as
|
||||
# ready immediately without waiting for NAS connectivity.
|
||||
async with FileStationClient(
|
||||
config.base_url,
|
||||
config.connection.verify_ssl,
|
||||
config.connection.timeout,
|
||||
) 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
|
||||
@@ -1,2 +1,431 @@
|
||||
"""Async HTTP client for Synology DSM / FileStation API."""
|
||||
# Implementation pending approval — see SPEC.md
|
||||
"""Async HTTP client for Synology DSM / FileStation API.
|
||||
|
||||
Thin async client wrapping Synology DSM Web API conventions:
|
||||
- GET requests for standard API calls
|
||||
- Session ID injection (_sid parameter)
|
||||
- Automatic re-login on session errors (codes 106, 107, 119)
|
||||
- File upload via POST multipart (SYNO.FileStation.Upload)
|
||||
- File download via streaming GET (SYNO.FileStation.Download)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import httpx
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from mcp_synology_filestation.auth import AuthManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Session error codes that trigger transparent re-auth
|
||||
_SESSION_ERROR_CODES = frozenset({106, 107, 119})
|
||||
|
||||
# Parameters to mask in debug logging
|
||||
_SENSITIVE_PARAMS = frozenset({"passwd", "_sid", "device_id", "otp_code", "device_token"})
|
||||
|
||||
|
||||
class SynologyError(Exception):
|
||||
"""Raised when DSM API returns a non-success response."""
|
||||
|
||||
def __init__(self, message: str, code: int | None = None) -> None:
|
||||
self.code = code
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
def _error_message(code: int, api: str = "") -> str:
|
||||
"""Map DSM error code to human-readable message."""
|
||||
common = {
|
||||
100: "Unknown error",
|
||||
101: "Invalid parameter",
|
||||
102: "API does not exist on this NAS",
|
||||
103: "Method does not exist",
|
||||
104: "API version not supported",
|
||||
105: "Permission denied — check DSM user permissions",
|
||||
106: "Session timeout",
|
||||
107: "Session displaced by another login",
|
||||
119: "Session invalid",
|
||||
}
|
||||
auth = {
|
||||
400: "Incorrect username or password",
|
||||
401: "Account disabled",
|
||||
402: "Permission denied for this service",
|
||||
403: "2FA code required",
|
||||
404: "2FA code incorrect or expired",
|
||||
407: "Too many failed login attempts — account temporarily locked",
|
||||
408: "IP blocked due to excessive failed attempts",
|
||||
}
|
||||
filestation = {
|
||||
1800: "File or folder not found",
|
||||
1801: "No write permission",
|
||||
1802: "File already exists at this path",
|
||||
1803: "Disk quota exceeded",
|
||||
1804: "No space left on the target volume",
|
||||
1805: "Filename too long",
|
||||
1806: "Filename contains illegal characters",
|
||||
1807: "File is read-only",
|
||||
}
|
||||
if "Auth" in api and code in auth:
|
||||
return auth[code]
|
||||
if code in filestation:
|
||||
return filestation[code]
|
||||
if code in common:
|
||||
return common[code]
|
||||
return f"DSM error code {code}"
|
||||
|
||||
|
||||
class FileStationClient:
|
||||
"""Async HTTP client for Synology DSM / FileStation API.
|
||||
|
||||
Usage:
|
||||
async with FileStationClient(base_url, verify_ssl=True) as client:
|
||||
await client.query_api_info()
|
||||
auth_manager = AuthManager(config)
|
||||
await auth_manager.login(client)
|
||||
data = await client.request("SYNO.FileStation.List", "list_share")
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
verify_ssl: bool = True,
|
||||
timeout: int = 30,
|
||||
) -> None:
|
||||
self._base_url = base_url.rstrip("/")
|
||||
self._verify_ssl = verify_ssl
|
||||
self._timeout = timeout
|
||||
self._http: httpx.AsyncClient | None = None
|
||||
self._api_cache: dict[str, dict[str, Any]] = {}
|
||||
self._sid: str | None = None
|
||||
self._auth_manager: AuthManager | None = None
|
||||
self._reauth_lock = asyncio.Lock()
|
||||
self._init_lock = asyncio.Lock()
|
||||
self._initialized = False
|
||||
self._initializing = False
|
||||
logger.debug(
|
||||
"FileStationClient: base_url=%s verify_ssl=%s timeout=%d",
|
||||
self._base_url,
|
||||
verify_ssl,
|
||||
timeout,
|
||||
)
|
||||
|
||||
@property
|
||||
def sid(self) -> str | None:
|
||||
"""Current session ID."""
|
||||
return self._sid
|
||||
|
||||
@sid.setter
|
||||
def sid(self, value: str | None) -> None:
|
||||
self._sid = value
|
||||
|
||||
def set_auth_manager(self, auth_manager: AuthManager) -> None:
|
||||
"""Register the AuthManager for automatic re-login on session errors."""
|
||||
self._auth_manager = auth_manager
|
||||
|
||||
async def _ensure_initialized(self) -> None:
|
||||
"""Connect to NAS and authenticate on first use (lazy init).
|
||||
|
||||
Subsequent calls are no-ops. Thread-safe via asyncio.Lock.
|
||||
"""
|
||||
if self._initialized:
|
||||
return
|
||||
async with self._init_lock:
|
||||
if self._initialized:
|
||||
return
|
||||
self._initializing = True
|
||||
try:
|
||||
sys.stderr.write(f"[dsm] Connecting to {self._base_url}...\n")
|
||||
sys.stderr.flush()
|
||||
logger.debug("Lazy init: querying API info from %s", self._base_url)
|
||||
await self.query_api_info()
|
||||
sys.stderr.write(f"[dsm] API info OK ({len(self._api_cache)} APIs)\n")
|
||||
sys.stderr.flush()
|
||||
if self._auth_manager:
|
||||
sys.stderr.write("[dsm] Authenticating...\n")
|
||||
sys.stderr.flush()
|
||||
logger.debug("Lazy init: authenticating")
|
||||
self._sid = await self._auth_manager.login(self)
|
||||
sys.stderr.write("[dsm] Auth OK\n")
|
||||
sys.stderr.flush()
|
||||
self._initialized = True
|
||||
logger.debug("Lazy init complete")
|
||||
finally:
|
||||
self._initializing = False
|
||||
|
||||
async def __aenter__(self) -> FileStationClient:
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
||||
self._http = httpx.AsyncClient(
|
||||
verify=self._verify_ssl,
|
||||
timeout=httpx.Timeout(
|
||||
connect=10.0,
|
||||
read=float(self._timeout),
|
||||
write=10.0,
|
||||
pool=5.0,
|
||||
),
|
||||
)
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *args: object) -> None:
|
||||
if self._http:
|
||||
await self._http.aclose()
|
||||
self._http = None
|
||||
|
||||
def _get_http(self) -> httpx.AsyncClient:
|
||||
if self._http is None:
|
||||
msg = "FileStationClient must be used as an async context manager."
|
||||
raise RuntimeError(msg)
|
||||
return self._http
|
||||
|
||||
async def query_api_info(self) -> dict[str, dict[str, Any]]:
|
||||
"""Query SYNO.API.Info to discover all available APIs and cache them.
|
||||
|
||||
Must be called before any other API requests.
|
||||
|
||||
Returns:
|
||||
Dict mapping API name -> {path, minVersion, maxVersion}.
|
||||
"""
|
||||
http = self._get_http()
|
||||
url = f"{self._base_url}/webapi/query.cgi"
|
||||
params = {
|
||||
"api": "SYNO.API.Info",
|
||||
"version": "1",
|
||||
"method": "query",
|
||||
"query": "ALL",
|
||||
}
|
||||
|
||||
logger.debug("Querying API info from %s", url)
|
||||
resp = await http.get(url, params=params)
|
||||
resp.raise_for_status()
|
||||
body = resp.json()
|
||||
|
||||
if not body.get("success"):
|
||||
code = body.get("error", {}).get("code", 0)
|
||||
raise SynologyError(_error_message(code, "SYNO.API.Info"), code=code)
|
||||
|
||||
data: dict[str, Any] = body.get("data", {})
|
||||
self._api_cache = {
|
||||
name: {
|
||||
"path": info["path"],
|
||||
"minVersion": info.get("minVersion", 1),
|
||||
"maxVersion": info.get("maxVersion", 1),
|
||||
}
|
||||
for name, info in data.items()
|
||||
}
|
||||
logger.debug("API info cached: %d APIs available", len(self._api_cache))
|
||||
return self._api_cache
|
||||
|
||||
async def request(
|
||||
self,
|
||||
api: str,
|
||||
method: str,
|
||||
version: int | None = None,
|
||||
params: dict[str, Any] | None = None,
|
||||
*,
|
||||
_is_retry: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Make a GET request to the DSM API.
|
||||
|
||||
Resolves the API path from the cache, injects session ID,
|
||||
parses the response envelope, and handles errors.
|
||||
|
||||
On session errors (106/107/119), re-authenticates and retries once.
|
||||
|
||||
Args:
|
||||
api: DSM API name (e.g. "SYNO.FileStation.List").
|
||||
method: API method (e.g. "list_share").
|
||||
version: API version. Defaults to maxVersion from API info.
|
||||
params: Additional query parameters.
|
||||
|
||||
Returns:
|
||||
Response data dict from the "data" field of the envelope.
|
||||
|
||||
Raises:
|
||||
SynologyError: On API errors.
|
||||
"""
|
||||
sys.stderr.write(f"[dsm] request: {api}/{method}\n")
|
||||
sys.stderr.flush()
|
||||
if not self._initializing:
|
||||
await self._ensure_initialized()
|
||||
http = self._get_http()
|
||||
|
||||
if api not in self._api_cache:
|
||||
raise SynologyError(
|
||||
f"API '{api}' not found. Call query_api_info() first.",
|
||||
code=102,
|
||||
)
|
||||
|
||||
info = self._api_cache[api]
|
||||
resolved_version = version if version is not None else info["maxVersion"]
|
||||
url = f"{self._base_url}/webapi/{info['path']}"
|
||||
|
||||
req_params: dict[str, Any] = {
|
||||
"api": api,
|
||||
"version": str(resolved_version),
|
||||
"method": method,
|
||||
}
|
||||
if params:
|
||||
req_params.update(params)
|
||||
if self._sid:
|
||||
req_params["_sid"] = self._sid
|
||||
|
||||
log_params = {k: ("***" if k in _SENSITIVE_PARAMS else v) for k, v in req_params.items()}
|
||||
retry_tag = " (retry)" if _is_retry else ""
|
||||
logger.debug(
|
||||
"DSM GET%s: %s/%s v%d — %s",
|
||||
retry_tag,
|
||||
api,
|
||||
method,
|
||||
resolved_version,
|
||||
log_params,
|
||||
)
|
||||
|
||||
resp = await http.get(url, params=req_params)
|
||||
resp.raise_for_status()
|
||||
body = resp.json()
|
||||
|
||||
if body.get("success"):
|
||||
data: dict[str, Any] = body.get("data") or {}
|
||||
logger.debug("DSM response: %s/%s — success", api, method)
|
||||
return data
|
||||
|
||||
code = body.get("error", {}).get("code", 0)
|
||||
logger.debug("DSM response: %s/%s — error code %d", api, method, code)
|
||||
|
||||
# Transparent re-auth on session errors (one retry only)
|
||||
if code in _SESSION_ERROR_CODES and not _is_retry and self._auth_manager:
|
||||
logger.info("Session error %d on %s/%s, re-authenticating...", code, api, method)
|
||||
async with self._reauth_lock:
|
||||
self._sid = None
|
||||
try:
|
||||
self._sid = await self._auth_manager.login(self)
|
||||
except Exception as e:
|
||||
raise SynologyError(f"Re-authentication failed: {e}", code=code) from e
|
||||
return await self.request(api, method, version, params, _is_retry=True)
|
||||
|
||||
raise SynologyError(_error_message(code, api), code=code)
|
||||
|
||||
async def download_bytes(self, path: str) -> tuple[str, bytes]:
|
||||
"""Download a file from the NAS via SYNO.FileStation.Download.
|
||||
|
||||
Args:
|
||||
path: Full file path on NAS.
|
||||
|
||||
Returns:
|
||||
Tuple of (filename, raw_bytes).
|
||||
|
||||
Raises:
|
||||
SynologyError: On API errors.
|
||||
"""
|
||||
api = "SYNO.FileStation.Download"
|
||||
await self._ensure_initialized()
|
||||
http = self._get_http()
|
||||
|
||||
if api not in self._api_cache:
|
||||
raise SynologyError(f"API '{api}' not found.", code=102)
|
||||
|
||||
info = self._api_cache[api]
|
||||
resolved_version = info["maxVersion"]
|
||||
url = f"{self._base_url}/webapi/{info['path']}"
|
||||
|
||||
req_params: dict[str, str] = {
|
||||
"api": api,
|
||||
"version": str(resolved_version),
|
||||
"method": "download",
|
||||
"path": path,
|
||||
"mode": "download",
|
||||
}
|
||||
if self._sid:
|
||||
req_params["_sid"] = self._sid
|
||||
|
||||
log_params = {k: ("***" if k in _SENSITIVE_PARAMS else v) for k, v in req_params.items()}
|
||||
logger.debug("DSM GET: %s/download v%d — %s", api, resolved_version, log_params)
|
||||
|
||||
resp = await http.get(url, params=req_params, timeout=httpx.Timeout(120.0))
|
||||
resp.raise_for_status()
|
||||
|
||||
content_type = resp.headers.get("content-type", "")
|
||||
if "application/json" in content_type:
|
||||
body = resp.json()
|
||||
code = body.get("error", {}).get("code", 0)
|
||||
raise SynologyError(_error_message(code, api), code=code)
|
||||
|
||||
filename = path.rsplit("/", 1)[-1]
|
||||
content_disp = resp.headers.get("content-disposition", "")
|
||||
if 'filename="' in content_disp:
|
||||
filename = content_disp.split('filename="')[1].rstrip('"')
|
||||
|
||||
return filename, resp.content
|
||||
|
||||
async def upload_bytes(
|
||||
self,
|
||||
dest_folder: str,
|
||||
filename: str,
|
||||
content: bytes,
|
||||
*,
|
||||
overwrite: bool = False,
|
||||
create_parents: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Upload raw bytes as a file via SYNO.FileStation.Upload.
|
||||
|
||||
Args:
|
||||
dest_folder: Target folder path on NAS (e.g. "/volume1/data").
|
||||
filename: Name for the uploaded file.
|
||||
content: Raw bytes to upload.
|
||||
overwrite: Whether to overwrite an existing file.
|
||||
create_parents: Whether to create missing parent directories.
|
||||
|
||||
Returns:
|
||||
Response data dict.
|
||||
"""
|
||||
api = "SYNO.FileStation.Upload"
|
||||
await self._ensure_initialized()
|
||||
http = self._get_http()
|
||||
|
||||
if api not in self._api_cache:
|
||||
raise SynologyError(f"API '{api}' not found.", code=102)
|
||||
|
||||
info = self._api_cache[api]
|
||||
resolved_version = info["maxVersion"]
|
||||
url = f"{self._base_url}/webapi/{info['path']}"
|
||||
|
||||
form_data: dict[str, str] = {
|
||||
"api": api,
|
||||
"version": str(resolved_version),
|
||||
"method": "upload",
|
||||
"path": dest_folder,
|
||||
"overwrite": "true" if overwrite else "false",
|
||||
"create_parents": "true" if create_parents else "false",
|
||||
}
|
||||
query_params: dict[str, str] = {}
|
||||
if self._sid:
|
||||
query_params["_sid"] = self._sid
|
||||
|
||||
logger.debug(
|
||||
"DSM POST: %s/upload v%d — path=%s filename=%s size=%d",
|
||||
api,
|
||||
resolved_version,
|
||||
dest_folder,
|
||||
filename,
|
||||
len(content),
|
||||
)
|
||||
|
||||
resp = await http.post(
|
||||
url,
|
||||
params=query_params,
|
||||
data=form_data,
|
||||
files={"file": (filename, content, "application/octet-stream")},
|
||||
timeout=httpx.Timeout(120.0),
|
||||
)
|
||||
resp.raise_for_status()
|
||||
body = resp.json()
|
||||
|
||||
if body.get("success"):
|
||||
return body.get("data") or {}
|
||||
|
||||
code = body.get("error", {}).get("code", 0)
|
||||
raise SynologyError(_error_message(code, api), code=code)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,2 +1,34 @@
|
||||
"""MCP server factory: creates and configures the FastMCP instance."""
|
||||
# Implementation pending approval — see SPEC.md
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from mcp_synology_filestation.client import FileStationClient
|
||||
from mcp_synology_filestation.config import AppConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_server(config: AppConfig, client: FileStationClient) -> FastMCP:
|
||||
"""Create and configure the MCP server with all tools registered.
|
||||
|
||||
Args:
|
||||
config: Application configuration.
|
||||
client: FileStationClient instance (lazy-initialized on first use).
|
||||
|
||||
Returns:
|
||||
Configured FastMCP server ready to run.
|
||||
"""
|
||||
mcp = FastMCP("mcp-synology-filestation")
|
||||
|
||||
from mcp_synology_filestation.tools.filestation import register_filestation
|
||||
|
||||
register_filestation(mcp, config, client)
|
||||
|
||||
logger.info("MCP server configured with FileStation tools")
|
||||
return mcp
|
||||
|
||||
@@ -1,2 +1,212 @@
|
||||
"""FileStation MCP tool registrations."""
|
||||
# Implementation pending approval — see SPEC.md
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
from mcp_synology_filestation.client import FileStationClient
|
||||
from mcp_synology_filestation.config import AppConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Valid sort_by values accepted by SYNO.FileStation.List::list
|
||||
_VALID_SORT_BY = frozenset(
|
||||
{"name", "size", "user", "group", "mtime", "atime", "crtime", "posix", "type"}
|
||||
)
|
||||
_VALID_SORT_DIR = frozenset({"asc", "desc"})
|
||||
|
||||
# Cap on items returned by list_dir — DSM hard limit is 10000, we enforce lower.
|
||||
_MAX_LIMIT = 500
|
||||
|
||||
|
||||
def _fmt_size(size: int | None) -> str:
|
||||
"""Format a byte count as a human-readable string."""
|
||||
if size is None:
|
||||
return "-"
|
||||
for unit in ("B", "KB", "MB", "GB", "TB"):
|
||||
if size < 1024:
|
||||
return f"{size:.0f} {unit}"
|
||||
size /= 1024 # type: ignore[assignment]
|
||||
return f"{size:.1f} PB"
|
||||
|
||||
|
||||
def _fmt_time(epoch: int | None) -> str:
|
||||
"""Format a Unix timestamp as a local date-time string."""
|
||||
if epoch is None:
|
||||
return "-"
|
||||
import datetime
|
||||
|
||||
return datetime.datetime.fromtimestamp(epoch).strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
|
||||
def register_filestation(
|
||||
mcp: FastMCP,
|
||||
config: AppConfig, # noqa: ARG001 — kept for consistency with other modules
|
||||
client: FileStationClient,
|
||||
) -> None:
|
||||
"""Register all FileStation tools with the MCP server.
|
||||
|
||||
Args:
|
||||
mcp: FastMCP server instance.
|
||||
config: Application configuration (reserved for future use).
|
||||
client: FileStationClient for DSM API calls.
|
||||
"""
|
||||
|
||||
@mcp.tool()
|
||||
async def list_shares() -> str:
|
||||
"""List all shared folders visible to the authenticated user.
|
||||
|
||||
Returns a formatted table with share name, path, and volume status.
|
||||
"""
|
||||
from mcp_synology_filestation.client import SynologyError
|
||||
|
||||
try:
|
||||
data = await client.request(
|
||||
"SYNO.FileStation.List",
|
||||
"list_share",
|
||||
params={"additional": json.dumps(["real_path", "volume_status"])},
|
||||
)
|
||||
except SynologyError as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
shares: list[dict] = data.get("shares", [])
|
||||
if not shares:
|
||||
return "No shared folders found."
|
||||
|
||||
# Build table manually with consistent column widths
|
||||
rows = []
|
||||
for share in shares:
|
||||
name = share.get("name", "")
|
||||
path = share.get("additional", {}).get("real_path") or share.get("path", "")
|
||||
vol = share.get("additional", {}).get("volume_status", {})
|
||||
total = vol.get("totalspace")
|
||||
used = vol.get("usedspace")
|
||||
if total and used:
|
||||
pct = used / total * 100
|
||||
usage = f"{_fmt_size(used)} / {_fmt_size(total)} ({pct:.0f}%)"
|
||||
else:
|
||||
usage = "-"
|
||||
rows.append((name, path, usage))
|
||||
|
||||
# Column widths
|
||||
w_name = max(len("Share"), *(len(r[0]) for r in rows))
|
||||
w_path = max(len("Path"), *(len(r[1]) for r in rows))
|
||||
w_usage = max(len("Usage"), *(len(r[2]) for r in rows))
|
||||
|
||||
sep = f"+{'-' * (w_name + 2)}+{'-' * (w_path + 2)}+{'-' * (w_usage + 2)}+"
|
||||
header = f"| {'Share':<{w_name}} | {'Path':<{w_path}} | {'Usage':<{w_usage}} |"
|
||||
|
||||
lines = [sep, header, sep]
|
||||
for name, path, usage in rows:
|
||||
lines.append(f"| {name:<{w_name}} | {path:<{w_path}} | {usage:<{w_usage}} |")
|
||||
lines.append(sep)
|
||||
lines.append(f"\n{len(shares)} share(s) found.")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
@mcp.tool()
|
||||
async def list_dir(
|
||||
path: str,
|
||||
offset: int = 0,
|
||||
limit: int = 100,
|
||||
sort_by: str = "name",
|
||||
sort_direction: str = "asc",
|
||||
) -> str:
|
||||
"""List the contents of a directory on the NAS.
|
||||
|
||||
Args:
|
||||
path: Absolute path on the NAS (e.g. "/volume1/data").
|
||||
offset: Number of items to skip (for pagination).
|
||||
limit: Maximum items to return (1-500, default 100).
|
||||
sort_by: Sort field — one of: name, size, user, group, mtime, atime,
|
||||
crtime, posix, type.
|
||||
sort_direction: "asc" or "desc".
|
||||
|
||||
Returns:
|
||||
Formatted table with name, type, size, and modification time,
|
||||
plus the total item count for pagination context.
|
||||
"""
|
||||
from mcp_synology_filestation.client import SynologyError
|
||||
|
||||
# Validate inputs
|
||||
if sort_by not in _VALID_SORT_BY:
|
||||
return f"Error: sort_by must be one of {sorted(_VALID_SORT_BY)}"
|
||||
if sort_direction not in _VALID_SORT_DIR:
|
||||
return "Error: sort_direction must be 'asc' or 'desc'"
|
||||
limit = max(1, min(limit, _MAX_LIMIT))
|
||||
offset = max(0, offset)
|
||||
|
||||
try:
|
||||
data = await client.request(
|
||||
"SYNO.FileStation.List",
|
||||
"list",
|
||||
params={
|
||||
"folder_path": path,
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
"sort_by": sort_by,
|
||||
"sort_direction": sort_direction,
|
||||
"additional": json.dumps(["real_path", "size", "time", "perm", "type"]),
|
||||
},
|
||||
)
|
||||
except SynologyError as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
files: list[dict] = data.get("files", [])
|
||||
total: int = data.get("total", len(files))
|
||||
|
||||
if not files:
|
||||
return f"Directory '{path}' is empty (or does not exist)."
|
||||
|
||||
# Build table
|
||||
rows = []
|
||||
for f in files:
|
||||
name = f.get("name", "")
|
||||
is_dir = f.get("isdir", False)
|
||||
ftype = "dir" if is_dir else "file"
|
||||
add = f.get("additional", {})
|
||||
size_val = add.get("size")
|
||||
size_str = "-" if is_dir else _fmt_size(size_val)
|
||||
mtime = add.get("time", {}).get("mtime")
|
||||
mtime_str = _fmt_time(mtime)
|
||||
rows.append((name, ftype, size_str, mtime_str))
|
||||
|
||||
w_name = max(len("Name"), *(len(r[0]) for r in rows))
|
||||
w_type = max(len("Type"), *(len(r[1]) for r in rows))
|
||||
w_size = max(len("Size"), *(len(r[2]) for r in rows))
|
||||
w_mtime = max(len("Modified"), *(len(r[3]) for r in rows))
|
||||
|
||||
sep = (
|
||||
f"+{'-' * (w_name + 2)}"
|
||||
f"+{'-' * (w_type + 2)}"
|
||||
f"+{'-' * (w_size + 2)}"
|
||||
f"+{'-' * (w_mtime + 2)}+"
|
||||
)
|
||||
header = (
|
||||
f"| {'Name':<{w_name}} "
|
||||
f"| {'Type':<{w_type}} "
|
||||
f"| {'Size':<{w_size}} "
|
||||
f"| {'Modified':<{w_mtime}} |"
|
||||
)
|
||||
|
||||
lines = [f"Path: {path}", sep, header, sep]
|
||||
for name, ftype, size_str, mtime_str in rows:
|
||||
lines.append(
|
||||
f"| {name:<{w_name}} "
|
||||
f"| {ftype:<{w_type}} "
|
||||
f"| {size_str:<{w_size}} "
|
||||
f"| {mtime_str:<{w_mtime}} |"
|
||||
)
|
||||
lines.append(sep)
|
||||
|
||||
end = offset + len(files)
|
||||
lines.append(f"\nShowing {offset + 1}–{end} of {total} item(s).")
|
||||
if end < total:
|
||||
lines.append(f"Use offset={end} to fetch the next page.")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
Reference in New Issue
Block a user