"""FileStation MCP tool registrations.""" from __future__ import annotations import asyncio import contextlib import json import logging from typing import TYPE_CHECKING, Any 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. """ # ── internal polling helper ─────────────────────────────────────────── async def _poll_task( api: str, version: int, taskid: str, initial_delay: float = 0.2, ) -> tuple[bool, dict[str, Any] | str]: """Poll a DSM async task until finished or timeout. Args: api: DSM API name (e.g. "SYNO.FileStation.CopyMove"). version: API version to use for the status call. taskid: Task ID returned by the corresponding start method. initial_delay: Seconds to wait before the first status poll. Set to 0.0 for tasks that may finish before the first poll interval (e.g. DirSize on small directories, MD5 on small files). Returns: ``(True, status_dict)`` on success, or ``(False, "Error: …")`` on DSM error or timeout. """ from mcp_synology_filestation.client import SynologyError as _SynologyError delay = 0.2 elapsed = initial_delay timeout = 60.0 if initial_delay > 0: await asyncio.sleep(initial_delay) while True: try: status_data = await client.request( api, "status", version=version, params={"taskid": taskid}, ) except _SynologyError as e: if e.code == 599: # DSM returns 599 while the async task is still initialising # or running (task-not-yet-available). Treat it the same as # finished=False and keep polling until the 60 s timeout. pass else: return False, f"Error: {e}" else: if status_data.get("finished"): return True, status_data if elapsed >= timeout: return ( False, "Error: Operation timed out after 60 seconds — check NAS manually.", ) await asyncio.sleep(delay) elapsed += delay delay = min(delay * 2, 2.0) @mcp.tool() async def list_shares(): """List all shared folders. Returns name/path/volume-usage table.""" from mcp_synology_filestation.client import SynologyError try: data = await client.request( "SYNO.FileStation.List", "list_share", params={"additional": json.dumps(["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("path", f"/{name}") 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", ): """List directory contents. path: share-relative (e.g. /docker). offset/limit for pagination, sort_by/sort_direction for ordering.""" 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(["size", "time"]), }, ) 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_str = "-" if is_dir else _fmt_size(add.get("size")) mtime_str = _fmt_time(add.get("time", {}).get("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) @mcp.tool() async def search( path: str, pattern: str, recursive: bool = True, max_results: int = 200, ): """Search files by glob pattern under path. pattern: e.g. "*.yaml". recursive/max_results optional.""" from mcp_synology_filestation.client import SynologyError limit = max(1, min(max_results, 1000)) # 1. Start search task try: start_data = await client.request( "SYNO.FileStation.Search", "start", params={ "folder_path": path, "recursive": "true" if recursive else "false", "pattern": pattern, }, ) except SynologyError as e: return f"Error: {e}" taskid: str = start_data.get("taskid", "") if not taskid: return "Error: DSM did not return a search task ID." # 2. Poll until finished=True (exponential backoff: 200 ms → 2 s, timeout 60 s) delay = 0.2 elapsed = 0.0 timeout = 60.0 files: list[dict] = [] while True: await asyncio.sleep(delay) elapsed += delay try: poll_data = await client.request( "SYNO.FileStation.Search", "list", params={ "taskid": taskid, "offset": 0, "limit": limit, "additional": json.dumps(["size", "time"]), }, ) except SynologyError as e: # Best-effort cleanup before surfacing the error with contextlib.suppress(SynologyError): await client.request( "SYNO.FileStation.Search", "clean", params={"taskid": taskid} ) return f"Error: {e}" current_files: list[dict] = poll_data.get("files", []) if current_files: files = current_files # keep the last non-empty result set finished: bool = poll_data.get("finished", False) if finished: break if elapsed >= timeout: with contextlib.suppress(SynologyError): await client.request( "SYNO.FileStation.Search", "clean", params={"taskid": taskid} ) return "Error: Search timed out after 60 seconds." delay = min(delay * 2, 2.0) # 3. Clean up the search task with contextlib.suppress(SynologyError): await client.request("SYNO.FileStation.Search", "clean", params={"taskid": taskid}) if not files: return f"No files matching '{pattern}' found under '{path}'." # 4. Format results rows = [] for f in files: item_path = f.get("path") or f.get("name", "") is_dir = f.get("isdir", False) ftype = "dir" if is_dir else "file" add = f.get("additional", {}) size_str = "-" if is_dir else _fmt_size(add.get("size")) mtime_str = _fmt_time((add.get("time") or {}).get("mtime")) rows.append((item_path, ftype, size_str, mtime_str)) w_path = max(len("Path"), *(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_path + 2)}" f"+{'-' * (w_type + 2)}" f"+{'-' * (w_size + 2)}" f"+{'-' * (w_mtime + 2)}+" ) header = ( f"| {'Path':<{w_path}} " f"| {'Type':<{w_type}} " f"| {'Size':<{w_size}} " f"| {'Modified':<{w_mtime}} |" ) lines = [f"Search: '{pattern}' under '{path}'", sep, header, sep] for item_path, ftype, size_str, mtime_str in rows: lines.append( f"| {item_path:<{w_path}} " f"| {ftype:<{w_type}} " f"| {size_str:<{w_size}} " f"| {mtime_str:<{w_mtime}} |" ) lines.append(sep) lines.append(f"\n{len(rows)} match(es) found.") return "\n".join(lines) @mcp.tool() async def download(path: str): """Download a file as base64 (max 10 MB). path: share-relative. Returns JSON {filename, size, content_base64}.""" import base64 from mcp_synology_filestation.client import SynologyError max_download_bytes = 10 * 1024 * 1024 # 10 MB try: filename, content = await client.download_bytes(path) except SynologyError as e: return f"Error: {e}" size = len(content) if size > max_download_bytes: return ( f"Error: File '{filename}' is {_fmt_size(size)}, which exceeds the 10 MB limit " "for MCP downloads. Use SFTP or another file-transfer method instead." ) return json.dumps( { "filename": filename, "size": size, "content_base64": base64.b64encode(content).decode(), } ) @mcp.tool() async def get_info(path: str): """Get metadata (type/size/owner/permissions/timestamps) for one or more paths. path: comma-separated share-relative paths.""" from mcp_synology_filestation.client import SynologyError paths = [p.strip() for p in path.split(",") if p.strip()] if not paths: return "Error: no path provided." try: data = await client.request( "SYNO.FileStation.List", "getinfo", params={ "path": json.dumps(paths), "additional": json.dumps( ["real_path", "size", "time", "perm", "owner", "type"] ), }, ) except SynologyError as e: return f"Error: {e}" files: list[dict] = data.get("files", []) if not files: return "No information returned for the given path(s)." rows = [] for f in files: item_path = f.get("path") or f.get("name", "") is_dir = f.get("isdir", False) ftype = "dir" if is_dir else "file" add = f.get("additional", {}) size_str = "-" if is_dir else _fmt_size(add.get("size")) mtime_str = _fmt_time((add.get("time") or {}).get("mtime")) crtime_str = _fmt_time((add.get("time") or {}).get("crtime")) owner_info = add.get("owner") or {} owner = owner_info.get("user", "-") group = owner_info.get("group", "-") perm_info = add.get("perm") or {} posix = perm_info.get("posix", 0) perm_str = oct(posix)[2:] if posix else "-" real_path = (add.get("real_path") or "").strip() or "-" rows.append( ( item_path, ftype, size_str, owner, group, perm_str, mtime_str, crtime_str, real_path, ) ) headers = ( "Path", "Type", "Size", "Owner", "Group", "Perm", "Modified", "Created", "Real path", ) col_widths = [max(len(h), *(len(r[i]) for r in rows)) for i, h in enumerate(headers)] def _sep() -> str: return "+" + "+".join("-" * (w + 2) for w in col_widths) + "+" def _row(vals: tuple[str, ...]) -> str: return "| " + " | ".join(f"{v:<{col_widths[i]}}" for i, v in enumerate(vals)) + " |" lines = [_sep(), _row(headers), _sep()] for r in rows: lines.append(_row(r)) lines.append(_sep()) lines.append(f"\n{len(rows)} item(s).") return "\n".join(lines) @mcp.tool() async def check_exist(path: str): """Check if one or more paths exist. path: comma-separated share-relative paths. Returns Yes/No table.""" from mcp_synology_filestation.client import SynologyError paths = [p.strip() for p in path.split(",") if p.strip()] if not paths: return "Error: no path provided." try: data = await client.request( "SYNO.FileStation.List", "getinfo", params={ "path": json.dumps(paths), "additional": json.dumps([]), }, ) except SynologyError as e: return f"Error: {e}" files: list[dict] = data.get("files", []) if not files: return "No information returned for the given path(s)." # A path that doesn't exist still gets an entry but with name=None rows = [(f.get("path", ""), "Yes" if f.get("name") is not None else "No") for f in files] w_path = max(len("Path"), *(len(r[0]) for r in rows)) w_exists = len("Exists") # "Yes" / "No" always shorter sep = f"+{'-' * (w_path + 2)}+{'-' * (w_exists + 2)}+" header = f"| {'Path':<{w_path}} | {'Exists':<{w_exists}} |" lines = [sep, header, sep] for item_path, exists_str in rows: lines.append(f"| {item_path:<{w_path}} | {exists_str:<{w_exists}} |") lines.append(sep) lines.append(f"\n{len(rows)} path(s) checked.") return "\n".join(lines) # ── write tools ─────────────────────────────────────────────────────── @mcp.tool() async def create_folder( path: str, name: str, create_parents: bool = False, ): """Create a folder. path: parent dir, name: folder name (not full path). create_parents: make missing parents.""" from mcp_synology_filestation.client import SynologyError try: data = await client.request( "SYNO.FileStation.CreateFolder", "create", params={ "folder_path": json.dumps(path), "name": json.dumps(name), "force_parent": "true" if create_parents else "false", }, ) except SynologyError as e: return f"Error: {e}" folders = data.get("folders", []) created_path = folders[0].get("path", f"{path}/{name}") if folders else f"{path}/{name}" return f"Created: {created_path}" @mcp.tool() async def rename(path: str, new_name: str): """Rename a file or folder. path: current share-relative path, new_name: bare name only (not full path).""" from mcp_synology_filestation.client import SynologyError try: data = await client.request( "SYNO.FileStation.Rename", "rename", params={ "path": json.dumps(path), "name": json.dumps(new_name), }, ) except SynologyError as e: return f"Error: {e}" files = data.get("files", []) parent = path.rsplit("/", 1)[0] or "/" new_path = files[0].get("path", f"{parent}/{new_name}") if files else f"{parent}/{new_name}" return f"Renamed to: {new_path}" @mcp.tool() async def copy(src: str, dst: str, overwrite: bool = False): """Copy src to dst directory. WARNING: overwrite=True replaces existing items (default False).""" from mcp_synology_filestation.client import SynologyError try: start_data = await client.request( "SYNO.FileStation.CopyMove", "start", version=3, params={ "path": json.dumps(src), "dest_folder_path": json.dumps(dst), "overwrite": "true" if overwrite else "false", "remove_src": "false", }, ) except SynologyError as e: return f"Error: {e}" taskid: str = start_data.get("taskid", "") if not taskid: return "Error: DSM did not return a task ID." ok, result = await _poll_task("SYNO.FileStation.CopyMove", 3, taskid) if not ok: return result # type: ignore[return-value] status: dict[str, Any] = result # type: ignore[assignment] dest_folder = status.get("dest_folder_path", dst) filename = src.rsplit("/", 1)[-1] return f"Copied to: {dest_folder}/{filename}" @mcp.tool() async def move(src: str, dst: str, overwrite: bool = False): """Move src to dst directory. WARNING: overwrite=True replaces existing items (default False).""" from mcp_synology_filestation.client import SynologyError try: start_data = await client.request( "SYNO.FileStation.CopyMove", "start", version=3, params={ "path": json.dumps(src), "dest_folder_path": json.dumps(dst), "overwrite": "true" if overwrite else "false", "remove_src": "true", }, ) except SynologyError as e: return f"Error: {e}" taskid = start_data.get("taskid", "") if not taskid: return "Error: DSM did not return a task ID." ok, result = await _poll_task("SYNO.FileStation.CopyMove", 3, taskid) if not ok: return result # type: ignore[return-value] status: dict[str, Any] = result # type: ignore[assignment] dest_folder = status.get("dest_folder_path", dst) filename = src.rsplit("/", 1)[-1] return f"Moved to: {dest_folder}/{filename}" @mcp.tool() async def delete(path: str, confirmed: bool = False): """Delete a file or folder. IRREVERSIBLE. confirmed=False (default) shows preview only; pass confirmed=True to actually delete.""" from mcp_synology_filestation.client import SynologyError if not confirmed: return ( f"Would permanently delete: {path}\n" "This cannot be undone. Pass confirmed=True to proceed." ) try: start_data = await client.request( "SYNO.FileStation.Delete", "start", params={ "path": json.dumps(path), "recursive": "true", "accurate_progress": "false", }, ) except SynologyError as e: return f"Error: {e}" taskid = start_data.get("taskid", "") if not taskid: return "Error: DSM did not return a task ID." ok, result = await _poll_task("SYNO.FileStation.Delete", 2, taskid) if not ok: return result # type: ignore[return-value] return f"Deleted: {path}" @mcp.tool() async def compress( paths: list[str], dest_file_path: str, level: str = "moderate", mode: str = "add", format: str = "zip", password: str = "", ): """Compress paths into an archive. dest_file_path: full path incl. filename. level: store/fastest/fast/normal/moderate/maximum. format: zip/7z.""" from mcp_synology_filestation.client import SynologyError _valid_levels = {"store", "fastest", "fast", "normal", "moderate", "maximum"} _valid_modes = {"add", "update", "refreshen"} _valid_formats = {"zip", "7z"} if level not in _valid_levels: return f"Error: level must be one of {sorted(_valid_levels)}" if mode not in _valid_modes: return f"Error: mode must be one of {sorted(_valid_modes)}" if format not in _valid_formats: return f"Error: format must be one of {sorted(_valid_formats)}" if not paths: return "Error: paths list must not be empty." try: start_data = await client.request( "SYNO.FileStation.Compress", "start", version=3, params={ "path": json.dumps(paths), "dest_file_path": json.dumps(dest_file_path), "level": level, "mode": mode, "format": format, "compress_password": password, }, ) except SynologyError as e: return f"Error: {e}" taskid: str = start_data.get("taskid", "") if not taskid: return "Error: DSM did not return a task ID." ok, result = await _poll_task("SYNO.FileStation.Compress", 3, taskid) if not ok: return result # type: ignore[return-value] return f"Compressed to: {dest_file_path}" @mcp.tool() async def extract( file_path: str, dest_folder_path: str, overwrite: bool = False, keep_dir: bool = True, create_subfolder: bool = False, password: str = "", ): """Extract a ZIP or 7z archive to dest_folder_path. overwrite/keep_dir/create_subfolder/password optional.""" from mcp_synology_filestation.client import SynologyError try: start_data = await client.request( "SYNO.FileStation.Extract", "start", version=2, params={ "file_path": json.dumps(file_path), "dest_folder_path": json.dumps(dest_folder_path), "overwrite": "true" if overwrite else "false", "keep_dir": "true" if keep_dir else "false", "create_subfolder": "true" if create_subfolder else "false", "codepage": "enu", "password": password, }, ) except SynologyError as e: return f"Error: {e}" taskid: str = start_data.get("taskid", "") if not taskid: return "Error: DSM did not return a task ID." ok, result = await _poll_task("SYNO.FileStation.Extract", 2, taskid) if not ok: return result # type: ignore[return-value] status: dict[str, Any] = result # type: ignore[assignment] dest = status.get("dest_folder_path", dest_folder_path) return f"Extracted to: {dest}" @mcp.tool() async def dir_size(path: str): """Get total size, file count and folder count for one or more directories. path: comma-separated share-relative paths.""" from mcp_synology_filestation.client import SynologyError paths = [p.strip() for p in path.split(",") if p.strip()] if not paths: return "Error: no path provided." try: start_data = await client.request( "SYNO.FileStation.DirSize", "start", version=2, params={"path": json.dumps(paths)}, ) except SynologyError as e: return f"Error: {e}" taskid: str = start_data.get("taskid", "") if not taskid: return "Error: DSM did not return a task ID." ok, result = await _poll_task("SYNO.FileStation.DirSize", 1, taskid, initial_delay=0.0) if not ok: return result # type: ignore[return-value] status: dict[str, Any] = result # type: ignore[assignment] num_dir = status.get("num_dir", 0) num_file = status.get("num_file", 0) total_size = status.get("total_size", 0) path_label = ", ".join(paths) w_path = max(len("Path"), len(path_label)) num_dir_str = str(num_dir) num_file_str = str(num_file) size_str = _fmt_size(total_size) sep = ( f"+{'-' * (w_path + 2)}" f"+{'-' * (max(len('Folders'), len(num_dir_str)) + 2)}" f"+{'-' * (max(len('Files'), len(num_file_str)) + 2)}" f"+{'-' * (max(len('Total Size'), len(size_str)) + 2)}+" ) w_dir = max(len("Folders"), len(num_dir_str)) w_file = max(len("Files"), len(num_file_str)) w_size = max(len("Total Size"), len(size_str)) sep = ( f"+{'-' * (w_path + 2)}+{'-' * (w_dir + 2)}+{'-' * (w_file + 2)}+{'-' * (w_size + 2)}+" ) header = ( f"| {'Path':<{w_path}} " f"| {'Folders':<{w_dir}} " f"| {'Files':<{w_file}} " f"| {'Total Size':<{w_size}} |" ) row = ( f"| {path_label:<{w_path}} " f"| {num_dir_str:<{w_dir}} " f"| {num_file_str:<{w_file}} " f"| {size_str:<{w_size}} |" ) return "\n".join([sep, header, sep, row, sep]) @mcp.tool() async def get_md5(path: str): """Compute the MD5 checksum of a file on the NAS. path: share-relative file path.""" from mcp_synology_filestation.client import SynologyError try: start_data = await client.request( "SYNO.FileStation.MD5", "start", version=2, params={"file_path": json.dumps(path)}, ) except SynologyError as e: return f"Error: {e}" taskid: str = start_data.get("taskid", "") if not taskid: return "Error: DSM did not return a task ID." ok, result = await _poll_task("SYNO.FileStation.MD5", 1, taskid, initial_delay=0.0) if not ok: return result # type: ignore[return-value] status: dict[str, Any] = result # type: ignore[assignment] md5 = status.get("md5", "") if not md5: return "Error: DSM returned no MD5 hash." return f"MD5 of {path}: {md5}" @mcp.tool() async def upload( path: str, filename: str, content_base64: str, overwrite: bool = False, create_parents: bool = True, ): """Upload base64-encoded content as filename into path (max 50 MB). WARNING: overwrite=True replaces existing file (default False).""" import base64 from mcp_synology_filestation.client import SynologyError max_upload_bytes = 50 * 1024 * 1024 # 50 MB try: content = base64.b64decode(content_base64) except Exception: return "Error: content_base64 is not valid base64." if len(content) > max_upload_bytes: return ( f"Error: File is {_fmt_size(len(content))}, which exceeds the 50 MB limit " "for MCP uploads. Use SFTP or another file-transfer method instead." ) try: await client.upload_bytes( dest_folder=path, filename=filename, content=content, overwrite=overwrite, create_parents=create_parents, ) except SynologyError as e: return f"Error: {e}" return f"Uploaded: {path}/{filename}"