forked from HomeLab/unraid-mcp
feat: harden API safety and expand command docs with full test coverage
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
"""Array operations and system power management.
|
||||
"""Array parity check operations.
|
||||
|
||||
Provides the `unraid_array` tool with 12 actions for array lifecycle,
|
||||
parity operations, disk management, and system power control.
|
||||
Provides the `unraid_array` tool with 5 actions for parity check management.
|
||||
"""
|
||||
|
||||
from typing import Any, Literal
|
||||
@@ -22,16 +21,6 @@ QUERIES: dict[str, str] = {
|
||||
}
|
||||
|
||||
MUTATIONS: dict[str, str] = {
|
||||
"start": """
|
||||
mutation StartArray {
|
||||
setState(input: { desiredState: STARTED }) { state }
|
||||
}
|
||||
""",
|
||||
"stop": """
|
||||
mutation StopArray {
|
||||
setState(input: { desiredState: STOPPED }) { state }
|
||||
}
|
||||
""",
|
||||
"parity_start": """
|
||||
mutation StartParityCheck($correct: Boolean) {
|
||||
parityCheck { start(correct: $correct) }
|
||||
@@ -52,42 +41,16 @@ MUTATIONS: dict[str, str] = {
|
||||
parityCheck { cancel }
|
||||
}
|
||||
""",
|
||||
"mount_disk": """
|
||||
mutation MountDisk($id: PrefixedID!) {
|
||||
mountArrayDisk(id: $id)
|
||||
}
|
||||
""",
|
||||
"unmount_disk": """
|
||||
mutation UnmountDisk($id: PrefixedID!) {
|
||||
unmountArrayDisk(id: $id)
|
||||
}
|
||||
""",
|
||||
"clear_stats": """
|
||||
mutation ClearStats($id: PrefixedID!) {
|
||||
clearArrayDiskStatistics(id: $id)
|
||||
}
|
||||
""",
|
||||
"shutdown": """
|
||||
mutation Shutdown {
|
||||
shutdown
|
||||
}
|
||||
""",
|
||||
"reboot": """
|
||||
mutation Reboot {
|
||||
reboot
|
||||
}
|
||||
""",
|
||||
}
|
||||
|
||||
DESTRUCTIVE_ACTIONS = {"start", "stop", "shutdown", "reboot"}
|
||||
DISK_ACTIONS = {"mount_disk", "unmount_disk", "clear_stats"}
|
||||
ALL_ACTIONS = set(QUERIES) | set(MUTATIONS)
|
||||
|
||||
ARRAY_ACTIONS = Literal[
|
||||
"start", "stop",
|
||||
"parity_start", "parity_pause", "parity_resume", "parity_cancel", "parity_status",
|
||||
"mount_disk", "unmount_disk", "clear_stats",
|
||||
"shutdown", "reboot",
|
||||
"parity_start",
|
||||
"parity_pause",
|
||||
"parity_resume",
|
||||
"parity_cancel",
|
||||
"parity_status",
|
||||
]
|
||||
|
||||
|
||||
@@ -97,52 +60,31 @@ def register_array_tool(mcp: FastMCP) -> None:
|
||||
@mcp.tool()
|
||||
async def unraid_array(
|
||||
action: ARRAY_ACTIONS,
|
||||
confirm: bool = False,
|
||||
disk_id: str | None = None,
|
||||
correct: bool | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Manage the Unraid array and system power.
|
||||
"""Manage Unraid array parity checks.
|
||||
|
||||
Actions:
|
||||
start - Start the array (destructive, requires confirm=True)
|
||||
stop - Stop the array (destructive, requires confirm=True)
|
||||
parity_start - Start parity check (optional correct=True to fix errors)
|
||||
parity_pause - Pause running parity check
|
||||
parity_resume - Resume paused parity check
|
||||
parity_cancel - Cancel running parity check
|
||||
parity_status - Get current parity check status
|
||||
mount_disk - Mount an array disk (requires disk_id)
|
||||
unmount_disk - Unmount an array disk (requires disk_id)
|
||||
clear_stats - Clear disk statistics (requires disk_id)
|
||||
shutdown - Shut down the server (destructive, requires confirm=True)
|
||||
reboot - Reboot the server (destructive, requires 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 in DISK_ACTIONS and not disk_id:
|
||||
raise ToolError(f"disk_id is required for '{action}' action")
|
||||
|
||||
try:
|
||||
logger.info(f"Executing unraid_array action={action}")
|
||||
|
||||
# Read-only query
|
||||
if action in QUERIES:
|
||||
data = await make_graphql_request(QUERIES[action])
|
||||
return {"success": True, "action": action, "data": data}
|
||||
|
||||
# Mutations
|
||||
query = MUTATIONS[action]
|
||||
variables: dict[str, Any] | None = None
|
||||
|
||||
if action in DISK_ACTIONS:
|
||||
variables = {"id": disk_id}
|
||||
elif action == "parity_start" and correct is not None:
|
||||
if action == "parity_start" and correct is not None:
|
||||
variables = {"correct": correct}
|
||||
|
||||
data = await make_graphql_request(query, variables)
|
||||
|
||||
@@ -99,13 +99,35 @@ MUTATIONS: dict[str, str] = {
|
||||
}
|
||||
|
||||
DESTRUCTIVE_ACTIONS = {"remove"}
|
||||
_ACTIONS_REQUIRING_CONTAINER_ID = {"start", "stop", "restart", "pause", "unpause", "remove", "update", "details", "logs"}
|
||||
_ACTIONS_REQUIRING_CONTAINER_ID = {
|
||||
"start",
|
||||
"stop",
|
||||
"restart",
|
||||
"pause",
|
||||
"unpause",
|
||||
"remove",
|
||||
"update",
|
||||
"details",
|
||||
"logs",
|
||||
}
|
||||
ALL_ACTIONS = set(QUERIES) | set(MUTATIONS) | {"restart"}
|
||||
|
||||
DOCKER_ACTIONS = Literal[
|
||||
"list", "details", "start", "stop", "restart", "pause", "unpause",
|
||||
"remove", "update", "update_all", "logs",
|
||||
"networks", "network_details", "port_conflicts", "check_updates",
|
||||
"list",
|
||||
"details",
|
||||
"start",
|
||||
"stop",
|
||||
"restart",
|
||||
"pause",
|
||||
"unpause",
|
||||
"remove",
|
||||
"update",
|
||||
"update_all",
|
||||
"logs",
|
||||
"networks",
|
||||
"network_details",
|
||||
"port_conflicts",
|
||||
"check_updates",
|
||||
]
|
||||
|
||||
# Docker container IDs: 64 hex chars + optional suffix (e.g., ":local")
|
||||
@@ -246,9 +268,7 @@ def register_docker_tool(mcp: FastMCP) -> None:
|
||||
return {"networks": list(networks) if isinstance(networks, list) else []}
|
||||
|
||||
if action == "network_details":
|
||||
data = await make_graphql_request(
|
||||
QUERIES["network_details"], {"id": network_id}
|
||||
)
|
||||
data = await make_graphql_request(QUERIES["network_details"], {"id": network_id})
|
||||
return dict(data.get("dockerNetwork", {}))
|
||||
|
||||
if action == "port_conflicts":
|
||||
@@ -266,13 +286,15 @@ def register_docker_tool(mcp: FastMCP) -> None:
|
||||
actual_id = await _resolve_container_id(container_id or "")
|
||||
# Stop (idempotent: treat "already stopped" as success)
|
||||
stop_data = await make_graphql_request(
|
||||
MUTATIONS["stop"], {"id": actual_id},
|
||||
MUTATIONS["stop"],
|
||||
{"id": actual_id},
|
||||
operation_context={"operation": "stop"},
|
||||
)
|
||||
stop_was_idempotent = stop_data.get("idempotent_success", False)
|
||||
# Start (idempotent: treat "already running" as success)
|
||||
start_data = await make_graphql_request(
|
||||
MUTATIONS["start"], {"id": actual_id},
|
||||
MUTATIONS["start"],
|
||||
{"id": actual_id},
|
||||
operation_context={"operation": "start"},
|
||||
)
|
||||
if start_data.get("idempotent_success"):
|
||||
@@ -280,7 +302,9 @@ def register_docker_tool(mcp: FastMCP) -> None:
|
||||
else:
|
||||
result = start_data.get("docker", {}).get("start", {})
|
||||
response: dict[str, Any] = {
|
||||
"success": True, "action": "restart", "container": result,
|
||||
"success": True,
|
||||
"action": "restart",
|
||||
"container": result,
|
||||
}
|
||||
if stop_was_idempotent:
|
||||
response["note"] = "Container was already stopped before restart"
|
||||
@@ -294,9 +318,12 @@ def register_docker_tool(mcp: FastMCP) -> None:
|
||||
# Single-container mutations
|
||||
if action in MUTATIONS:
|
||||
actual_id = await _resolve_container_id(container_id or "")
|
||||
op_context: dict[str, str] | None = {"operation": action} if action in ("start", "stop") else None
|
||||
op_context: dict[str, str] | None = (
|
||||
{"operation": action} if action in ("start", "stop") else None
|
||||
)
|
||||
data = await make_graphql_request(
|
||||
MUTATIONS[action], {"id": actual_id},
|
||||
MUTATIONS[action],
|
||||
{"id": actual_id},
|
||||
operation_context=op_context,
|
||||
)
|
||||
|
||||
|
||||
@@ -247,11 +247,13 @@ async def _diagnose_subscriptions() -> dict[str, Any]:
|
||||
if conn_state in ("error", "auth_failed", "timeout", "max_retries_exceeded"):
|
||||
diagnostic_info["summary"]["in_error_state"] += 1
|
||||
if runtime.get("last_error"):
|
||||
connection_issues.append({
|
||||
"subscription": sub_name,
|
||||
"state": conn_state,
|
||||
"error": runtime["last_error"],
|
||||
})
|
||||
connection_issues.append(
|
||||
{
|
||||
"subscription": sub_name,
|
||||
"state": conn_state,
|
||||
"error": runtime["last_error"],
|
||||
}
|
||||
)
|
||||
|
||||
return diagnostic_info
|
||||
|
||||
|
||||
@@ -157,10 +157,25 @@ QUERIES: dict[str, str] = {
|
||||
}
|
||||
|
||||
INFO_ACTIONS = Literal[
|
||||
"overview", "array", "network", "registration", "connect", "variables",
|
||||
"metrics", "services", "display", "config", "online", "owner",
|
||||
"settings", "server", "servers", "flash",
|
||||
"ups_devices", "ups_device", "ups_config",
|
||||
"overview",
|
||||
"array",
|
||||
"network",
|
||||
"registration",
|
||||
"connect",
|
||||
"variables",
|
||||
"metrics",
|
||||
"services",
|
||||
"display",
|
||||
"config",
|
||||
"online",
|
||||
"owner",
|
||||
"settings",
|
||||
"server",
|
||||
"servers",
|
||||
"flash",
|
||||
"ups_devices",
|
||||
"ups_device",
|
||||
"ups_config",
|
||||
]
|
||||
|
||||
assert set(QUERIES.keys()) == set(INFO_ACTIONS.__args__), (
|
||||
@@ -209,7 +224,15 @@ def _process_system_info(raw_info: dict[str, Any]) -> dict[str, Any]:
|
||||
|
||||
def _analyze_disk_health(disks: list[dict[str, Any]]) -> dict[str, int]:
|
||||
"""Analyze health status of disk arrays."""
|
||||
counts = {"healthy": 0, "failed": 0, "missing": 0, "new": 0, "warning": 0, "critical": 0, "unknown": 0}
|
||||
counts = {
|
||||
"healthy": 0,
|
||||
"failed": 0,
|
||||
"missing": 0,
|
||||
"new": 0,
|
||||
"warning": 0,
|
||||
"critical": 0,
|
||||
"unknown": 0,
|
||||
}
|
||||
for disk in disks:
|
||||
status = disk.get("status", "").upper()
|
||||
warning = disk.get("warning")
|
||||
@@ -263,7 +286,11 @@ def _process_array_status(raw: dict[str, Any]) -> dict[str, Any]:
|
||||
summary["num_cache_pools"] = len(raw.get("caches", []))
|
||||
|
||||
health_summary: dict[str, Any] = {}
|
||||
for key, label in [("parities", "parity_health"), ("disks", "data_health"), ("caches", "cache_health")]:
|
||||
for key, label in [
|
||||
("parities", "parity_health"),
|
||||
("disks", "data_health"),
|
||||
("caches", "cache_health"),
|
||||
]:
|
||||
if raw.get(key):
|
||||
health_summary[label] = _analyze_disk_health(raw[key])
|
||||
|
||||
@@ -377,10 +404,14 @@ def register_info_tool(mcp: FastMCP) -> None:
|
||||
if action == "settings":
|
||||
settings = data.get("settings") or {}
|
||||
if not settings:
|
||||
raise ToolError("No settings data returned from Unraid API. Check API permissions.")
|
||||
raise ToolError(
|
||||
"No settings data returned from Unraid API. Check API permissions."
|
||||
)
|
||||
if not settings.get("unified"):
|
||||
logger.warning(f"Settings returned unexpected structure: {settings.keys()}")
|
||||
raise ToolError(f"Unexpected settings structure. Expected 'unified' key, got: {list(settings.keys())}")
|
||||
raise ToolError(
|
||||
f"Unexpected settings structure. Expected 'unified' key, got: {list(settings.keys())}"
|
||||
)
|
||||
values = settings["unified"].get("values") or {}
|
||||
return dict(values) if isinstance(values, dict) else {"raw": values}
|
||||
|
||||
|
||||
@@ -47,7 +47,11 @@ MUTATIONS: dict[str, str] = {
|
||||
DESTRUCTIVE_ACTIONS = {"delete"}
|
||||
|
||||
KEY_ACTIONS = Literal[
|
||||
"list", "get", "create", "update", "delete",
|
||||
"list",
|
||||
"get",
|
||||
"create",
|
||||
"update",
|
||||
"delete",
|
||||
]
|
||||
|
||||
|
||||
@@ -101,9 +105,7 @@ def register_keys_tool(mcp: FastMCP) -> None:
|
||||
input_data["roles"] = roles
|
||||
if permissions:
|
||||
input_data["permissions"] = permissions
|
||||
data = await make_graphql_request(
|
||||
MUTATIONS["create"], {"input": input_data}
|
||||
)
|
||||
data = await make_graphql_request(MUTATIONS["create"], {"input": input_data})
|
||||
return {
|
||||
"success": True,
|
||||
"key": data.get("createApiKey", {}),
|
||||
@@ -117,9 +119,7 @@ def register_keys_tool(mcp: FastMCP) -> None:
|
||||
input_data["name"] = name
|
||||
if roles:
|
||||
input_data["roles"] = roles
|
||||
data = await make_graphql_request(
|
||||
MUTATIONS["update"], {"input": input_data}
|
||||
)
|
||||
data = await make_graphql_request(MUTATIONS["update"], {"input": input_data})
|
||||
return {
|
||||
"success": True,
|
||||
"key": data.get("updateApiKey", {}),
|
||||
@@ -128,12 +128,12 @@ def register_keys_tool(mcp: FastMCP) -> None:
|
||||
if action == "delete":
|
||||
if not key_id:
|
||||
raise ToolError("key_id is required for 'delete' action")
|
||||
data = await make_graphql_request(
|
||||
MUTATIONS["delete"], {"input": {"ids": [key_id]}}
|
||||
)
|
||||
data = await make_graphql_request(MUTATIONS["delete"], {"input": {"ids": [key_id]}})
|
||||
result = data.get("deleteApiKeys")
|
||||
if not result:
|
||||
raise ToolError(f"Failed to delete API key '{key_id}': no confirmation from server")
|
||||
raise ToolError(
|
||||
f"Failed to delete API key '{key_id}': no confirmation from server"
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"API key '{key_id}' deleted",
|
||||
|
||||
@@ -78,8 +78,15 @@ MUTATIONS: dict[str, str] = {
|
||||
DESTRUCTIVE_ACTIONS = {"delete", "delete_archived"}
|
||||
|
||||
NOTIFICATION_ACTIONS = Literal[
|
||||
"overview", "list", "warnings",
|
||||
"create", "archive", "unread", "delete", "delete_archived", "archive_all",
|
||||
"overview",
|
||||
"list",
|
||||
"warnings",
|
||||
"create",
|
||||
"archive",
|
||||
"unread",
|
||||
"delete",
|
||||
"delete_archived",
|
||||
"archive_all",
|
||||
]
|
||||
|
||||
|
||||
@@ -115,7 +122,9 @@ def register_notifications_tool(mcp: FastMCP) -> None:
|
||||
"""
|
||||
all_actions = {**QUERIES, **MUTATIONS}
|
||||
if action not in all_actions:
|
||||
raise ToolError(f"Invalid action '{action}'. Must be one of: {list(all_actions.keys())}")
|
||||
raise ToolError(
|
||||
f"Invalid action '{action}'. Must be one of: {list(all_actions.keys())}"
|
||||
)
|
||||
|
||||
if action in DESTRUCTIVE_ACTIONS and not confirm:
|
||||
raise ToolError(f"Action '{action}' is destructive. Set confirm=True to proceed.")
|
||||
@@ -136,9 +145,7 @@ def register_notifications_tool(mcp: FastMCP) -> None:
|
||||
}
|
||||
if importance:
|
||||
filter_vars["importance"] = importance.upper()
|
||||
data = await make_graphql_request(
|
||||
QUERIES["list"], {"filter": filter_vars}
|
||||
)
|
||||
data = await make_graphql_request(QUERIES["list"], {"filter": filter_vars})
|
||||
notifications = data.get("notifications", {})
|
||||
result = notifications.get("list", [])
|
||||
return {"notifications": list(result) if isinstance(result, list) else []}
|
||||
@@ -151,33 +158,25 @@ def register_notifications_tool(mcp: FastMCP) -> None:
|
||||
|
||||
if action == "create":
|
||||
if title is None or subject is None or description is None or importance is None:
|
||||
raise ToolError(
|
||||
"create requires title, subject, description, and importance"
|
||||
)
|
||||
raise ToolError("create requires title, subject, description, and importance")
|
||||
input_data = {
|
||||
"title": title,
|
||||
"subject": subject,
|
||||
"description": description,
|
||||
"importance": importance.upper(),
|
||||
}
|
||||
data = await make_graphql_request(
|
||||
MUTATIONS["create"], {"input": input_data}
|
||||
)
|
||||
data = await make_graphql_request(MUTATIONS["create"], {"input": input_data})
|
||||
return {"success": True, "data": data}
|
||||
|
||||
if action in ("archive", "unread"):
|
||||
if not notification_id:
|
||||
raise ToolError(f"notification_id is required for '{action}' action")
|
||||
data = await make_graphql_request(
|
||||
MUTATIONS[action], {"id": notification_id}
|
||||
)
|
||||
data = await make_graphql_request(MUTATIONS[action], {"id": notification_id})
|
||||
return {"success": True, "action": action, "data": data}
|
||||
|
||||
if action == "delete":
|
||||
if not notification_id or not notification_type:
|
||||
raise ToolError(
|
||||
"delete requires notification_id and notification_type"
|
||||
)
|
||||
raise ToolError("delete requires notification_id and notification_type")
|
||||
data = await make_graphql_request(
|
||||
MUTATIONS["delete"],
|
||||
{"id": notification_id, "type": notification_type.upper()},
|
||||
|
||||
@@ -43,7 +43,10 @@ DESTRUCTIVE_ACTIONS = {"delete_remote"}
|
||||
ALL_ACTIONS = set(QUERIES) | set(MUTATIONS)
|
||||
|
||||
RCLONE_ACTIONS = Literal[
|
||||
"list_remotes", "config_form", "create_remote", "delete_remote",
|
||||
"list_remotes",
|
||||
"config_form",
|
||||
"create_remote",
|
||||
"delete_remote",
|
||||
]
|
||||
|
||||
|
||||
@@ -84,9 +87,7 @@ def register_rclone_tool(mcp: FastMCP) -> None:
|
||||
variables: dict[str, Any] = {}
|
||||
if provider_type:
|
||||
variables["formOptions"] = {"providerType": provider_type}
|
||||
data = await make_graphql_request(
|
||||
QUERIES["config_form"], variables or None
|
||||
)
|
||||
data = await make_graphql_request(QUERIES["config_form"], variables or None)
|
||||
form = data.get("rclone", {}).get("configForm", {})
|
||||
if not form:
|
||||
raise ToolError("No RClone config form data received")
|
||||
@@ -94,16 +95,16 @@ def register_rclone_tool(mcp: FastMCP) -> None:
|
||||
|
||||
if action == "create_remote":
|
||||
if name is None or provider_type is None or config_data is None:
|
||||
raise ToolError(
|
||||
"create_remote requires name, provider_type, and config_data"
|
||||
)
|
||||
raise ToolError("create_remote requires name, provider_type, and config_data")
|
||||
data = await make_graphql_request(
|
||||
MUTATIONS["create_remote"],
|
||||
{"input": {"name": name, "type": provider_type, "config": config_data}},
|
||||
)
|
||||
remote = data.get("rclone", {}).get("createRCloneRemote")
|
||||
if not remote:
|
||||
raise ToolError(f"Failed to create remote '{name}': no confirmation from server")
|
||||
raise ToolError(
|
||||
f"Failed to create remote '{name}': no confirmation from server"
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Remote '{name}' created successfully",
|
||||
|
||||
@@ -57,7 +57,12 @@ QUERIES: dict[str, str] = {
|
||||
}
|
||||
|
||||
STORAGE_ACTIONS = Literal[
|
||||
"shares", "disks", "disk_details", "unassigned", "log_files", "logs",
|
||||
"shares",
|
||||
"disks",
|
||||
"disk_details",
|
||||
"unassigned",
|
||||
"log_files",
|
||||
"logs",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""User management.
|
||||
"""User account query.
|
||||
|
||||
Provides the `unraid_users` tool with 8 actions for managing users,
|
||||
cloud access, remote access settings, and allowed origins.
|
||||
Provides the `unraid_users` tool with 1 action for querying the current authenticated user.
|
||||
Note: Unraid GraphQL API does not support user management operations (list, add, delete).
|
||||
"""
|
||||
|
||||
from typing import Any, Literal
|
||||
@@ -19,146 +19,37 @@ QUERIES: dict[str, str] = {
|
||||
me { id name description roles }
|
||||
}
|
||||
""",
|
||||
"list": """
|
||||
query ListUsers {
|
||||
users { id name description roles }
|
||||
}
|
||||
""",
|
||||
"get": """
|
||||
query GetUser($id: ID!) {
|
||||
user(id: $id) { id name description roles }
|
||||
}
|
||||
""",
|
||||
"cloud": """
|
||||
query GetCloud {
|
||||
cloud { status error }
|
||||
}
|
||||
""",
|
||||
"remote_access": """
|
||||
query GetRemoteAccess {
|
||||
remoteAccess { enabled url }
|
||||
}
|
||||
""",
|
||||
"origins": """
|
||||
query GetAllowedOrigins {
|
||||
allowedOrigins
|
||||
}
|
||||
""",
|
||||
}
|
||||
|
||||
MUTATIONS: dict[str, str] = {
|
||||
"add": """
|
||||
mutation AddUser($input: addUserInput!) {
|
||||
addUser(input: $input) { id name description roles }
|
||||
}
|
||||
""",
|
||||
"delete": """
|
||||
mutation DeleteUser($input: deleteUserInput!) {
|
||||
deleteUser(input: $input) { id name }
|
||||
}
|
||||
""",
|
||||
}
|
||||
ALL_ACTIONS = set(QUERIES)
|
||||
|
||||
DESTRUCTIVE_ACTIONS = {"delete"}
|
||||
|
||||
USER_ACTIONS = Literal[
|
||||
"me", "list", "get", "add", "delete", "cloud", "remote_access", "origins",
|
||||
]
|
||||
USER_ACTIONS = Literal["me"]
|
||||
|
||||
|
||||
def register_users_tool(mcp: FastMCP) -> None:
|
||||
"""Register the unraid_users tool with the FastMCP instance."""
|
||||
|
||||
@mcp.tool()
|
||||
async def unraid_users(
|
||||
action: USER_ACTIONS,
|
||||
confirm: bool = False,
|
||||
user_id: str | None = None,
|
||||
name: str | None = None,
|
||||
password: str | None = None,
|
||||
role: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Manage Unraid users and access settings.
|
||||
async def unraid_users(action: USER_ACTIONS = "me") -> dict[str, Any]:
|
||||
"""Query current authenticated user.
|
||||
|
||||
Actions:
|
||||
me - Get current authenticated user info
|
||||
list - List all users
|
||||
get - Get a specific user (requires user_id)
|
||||
add - Add a new user (requires name, password; optional role)
|
||||
delete - Delete a user (requires user_id, confirm=True)
|
||||
cloud - Get Unraid Connect cloud status
|
||||
remote_access - Get remote access settings
|
||||
origins - Get allowed origins
|
||||
"""
|
||||
all_actions = set(QUERIES) | set(MUTATIONS)
|
||||
if action not in all_actions:
|
||||
raise ToolError(f"Invalid action '{action}'. Must be one of: {sorted(all_actions)}")
|
||||
me - Get current authenticated user info (id, name, description, roles)
|
||||
|
||||
if action in DESTRUCTIVE_ACTIONS and not confirm:
|
||||
raise ToolError(f"Action '{action}' is destructive. Set confirm=True to proceed.")
|
||||
Note: Unraid API does not support user management operations (list, add, delete).
|
||||
"""
|
||||
if action not in ALL_ACTIONS:
|
||||
raise ToolError(f"Invalid action '{action}'. Must be: me")
|
||||
|
||||
try:
|
||||
logger.info(f"Executing unraid_users action={action}")
|
||||
|
||||
if action == "me":
|
||||
data = await make_graphql_request(QUERIES["me"])
|
||||
return data.get("me") or {}
|
||||
|
||||
if action == "list":
|
||||
data = await make_graphql_request(QUERIES["list"])
|
||||
users = data.get("users", [])
|
||||
return {"users": list(users) if isinstance(users, list) else []}
|
||||
|
||||
if action == "get":
|
||||
if not user_id:
|
||||
raise ToolError("user_id is required for 'get' action")
|
||||
data = await make_graphql_request(QUERIES["get"], {"id": user_id})
|
||||
return data.get("user") or {}
|
||||
|
||||
if action == "add":
|
||||
if not name or not password:
|
||||
raise ToolError("add requires name and password")
|
||||
input_data: dict[str, Any] = {"name": name, "password": password}
|
||||
if role:
|
||||
input_data["role"] = role.upper()
|
||||
data = await make_graphql_request(
|
||||
MUTATIONS["add"], {"input": input_data}
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"user": data.get("addUser", {}),
|
||||
}
|
||||
|
||||
if action == "delete":
|
||||
if not user_id:
|
||||
raise ToolError("user_id is required for 'delete' action")
|
||||
data = await make_graphql_request(
|
||||
MUTATIONS["delete"], {"input": {"id": user_id}}
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"User '{user_id}' deleted",
|
||||
}
|
||||
|
||||
if action == "cloud":
|
||||
data = await make_graphql_request(QUERIES["cloud"])
|
||||
return data.get("cloud") or {}
|
||||
|
||||
if action == "remote_access":
|
||||
data = await make_graphql_request(QUERIES["remote_access"])
|
||||
return data.get("remoteAccess") or {}
|
||||
|
||||
if action == "origins":
|
||||
data = await make_graphql_request(QUERIES["origins"])
|
||||
origins = data.get("allowedOrigins", [])
|
||||
return {"origins": list(origins) if isinstance(origins, list) else []}
|
||||
|
||||
raise ToolError(f"Unhandled action '{action}' — this is a bug")
|
||||
logger.info("Executing unraid_users action=me")
|
||||
data = await make_graphql_request(QUERIES["me"])
|
||||
return data.get("me") or {}
|
||||
|
||||
except ToolError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error in unraid_users action={action}: {e}", exc_info=True)
|
||||
raise ToolError(f"Failed to execute users/{action}: {e!s}") from e
|
||||
logger.error(f"Error in unraid_users action=me: {e}", exc_info=True)
|
||||
raise ToolError(f"Failed to execute users/me: {e!s}") from e
|
||||
|
||||
logger.info("Users tool registered successfully")
|
||||
|
||||
@@ -53,8 +53,15 @@ _MUTATION_FIELDS: dict[str, str] = {
|
||||
DESTRUCTIVE_ACTIONS = {"force_stop", "reset"}
|
||||
|
||||
VM_ACTIONS = Literal[
|
||||
"list", "details",
|
||||
"start", "stop", "pause", "resume", "force_stop", "reboot", "reset",
|
||||
"list",
|
||||
"details",
|
||||
"start",
|
||||
"stop",
|
||||
"pause",
|
||||
"resume",
|
||||
"force_stop",
|
||||
"reboot",
|
||||
"reset",
|
||||
]
|
||||
|
||||
|
||||
@@ -111,21 +118,15 @@ def register_vm_tool(mcp: FastMCP) -> None:
|
||||
or vm.get("name") == vm_id
|
||||
):
|
||||
return dict(vm)
|
||||
available = [
|
||||
f"{v.get('name')} (UUID: {v.get('uuid')})" for v in vms
|
||||
]
|
||||
raise ToolError(
|
||||
f"VM '{vm_id}' not found. Available: {', '.join(available)}"
|
||||
)
|
||||
available = [f"{v.get('name')} (UUID: {v.get('uuid')})" for v in vms]
|
||||
raise ToolError(f"VM '{vm_id}' not found. Available: {', '.join(available)}")
|
||||
if action == "details":
|
||||
raise ToolError("No VM data returned from server")
|
||||
return {"vms": []}
|
||||
|
||||
# Mutations
|
||||
if action in MUTATIONS:
|
||||
data = await make_graphql_request(
|
||||
MUTATIONS[action], {"id": vm_id}
|
||||
)
|
||||
data = await make_graphql_request(MUTATIONS[action], {"id": vm_id})
|
||||
field = _MUTATION_FIELDS.get(action, action)
|
||||
if data.get("vm") and field in data["vm"]:
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user