feat: add 28 GraphQL mutations across storage, info, docker, and new settings tool

- storage: flash_backup mutation (initiates rclone flash backup, destructive)
- info: update_server and update_ssh mutations
- docker: 11 organizer mutations (create_folder, set_folder_children,
  delete_entries, move_to_folder, move_to_position, rename_folder,
  create_folder_with_items, update_view_prefs, sync_templates,
  reset_template_mappings, refresh_digests); delete_entries and
  reset_template_mappings added to DESTRUCTIVE_ACTIONS
- settings: new unraid_settings tool with 9 mutations (update,
  update_temperature, update_time, configure_ups, update_api,
  connect_sign_in, connect_sign_out, setup_remote_access,
  enable_dynamic_remote_access); registered in server.py
- tests: 82 new tests (28 settings, 23 docker organizer, 7 info, 6 storage
  + 18 existing fixes for notification regex and safety audit list)
- bump version 0.3.0 → 0.4.0 (11 tools, ~104 actions)

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Jacob Magar
2026-03-13 03:03:37 -04:00
parent 4af1e74b4a
commit 9aee3a2448
11 changed files with 994 additions and 7 deletions

View File

@@ -58,7 +58,16 @@ QUERIES: dict[str, str] = {
""",
}
ALL_ACTIONS = set(QUERIES)
MUTATIONS: dict[str, str] = {
"flash_backup": """
mutation InitiateFlashBackup($input: InitiateFlashBackupInput!) {
initiateFlashBackup(input: $input) { status jobId }
}
""",
}
DESTRUCTIVE_ACTIONS = {"flash_backup"}
ALL_ACTIONS = set(QUERIES) | set(MUTATIONS)
STORAGE_ACTIONS = Literal[
"shares",
@@ -67,6 +76,7 @@ STORAGE_ACTIONS = Literal[
"unassigned",
"log_files",
"logs",
"flash_backup",
]
if set(get_args(STORAGE_ACTIONS)) != ALL_ACTIONS:
@@ -87,6 +97,11 @@ def register_storage_tool(mcp: FastMCP) -> None:
disk_id: str | None = None,
log_path: str | None = None,
tail_lines: int = 100,
confirm: bool = False,
remote_name: str | None = None,
source_path: str | None = None,
destination_path: str | None = None,
backup_options: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Manage Unraid storage, disks, and logs.
@@ -97,10 +112,14 @@ def register_storage_tool(mcp: FastMCP) -> None:
unassigned - List unassigned devices
log_files - List available log files
logs - Retrieve log content (requires log_path, optional tail_lines)
flash_backup - Initiate flash backup via rclone (requires remote_name, source_path, destination_path, confirm=True)
"""
if action not in ALL_ACTIONS:
raise ToolError(f"Invalid action '{action}'. Must be one of: {sorted(ALL_ACTIONS)}")
if action in DESTRUCTIVE_ACTIONS and not confirm:
raise ToolError(f"Action '{action}' is destructive. Set confirm=True to proceed.")
if action == "disk_details" and not disk_id:
raise ToolError("disk_id is required for 'disk_details' action")
@@ -121,6 +140,29 @@ def register_storage_tool(mcp: FastMCP) -> None:
)
log_path = normalized
if action == "flash_backup":
if not remote_name:
raise ToolError("remote_name is required for 'flash_backup' action")
if not source_path:
raise ToolError("source_path is required for 'flash_backup' action")
if not destination_path:
raise ToolError("destination_path is required for 'flash_backup' action")
input_data: dict[str, Any] = {
"remoteName": remote_name,
"sourcePath": source_path,
"destinationPath": destination_path,
}
if backup_options is not None:
input_data["options"] = backup_options
with tool_error_handler("storage", action, logger):
logger.info("Executing unraid_storage action=flash_backup")
data = await make_graphql_request(MUTATIONS["flash_backup"], {"input": input_data})
return {
"success": True,
"action": "flash_backup",
"data": data.get("initiateFlashBackup"),
}
query = QUERIES[action]
variables: dict[str, Any] | None = None
custom_timeout = DISK_TIMEOUT if action in ("disks", "disk_details") else None