feat: consolidate 26 tools into 10 tools with 90 actions

Refactor the entire tool layer to use the consolidated action pattern
(action: Literal[...] with QUERIES/MUTATIONS dicts). This reduces LLM
context from ~12k to ~5k tokens while adding ~60 new API capabilities.

New tools: unraid_info (19 actions), unraid_array (12), unraid_notifications (9),
unraid_users (8), unraid_keys (5). Rewritten: unraid_docker (15), unraid_vm (9),
unraid_storage (6), unraid_rclone (4), unraid_health (3).

Includes 129 tests across 10 test files, code review fixes for 16 issues
(severity ordering, PrefixedID regex, sensitive var redaction, etc.).

Removes tools/system.py (replaced by tools/info.py). Version bumped to 0.2.0.
This commit is contained in:
Jacob Magar
2026-02-08 08:49:47 -05:00
parent 67b775a9bc
commit 523b3edc76
33 changed files with 3538 additions and 1583 deletions

View File

@@ -1 +1,14 @@
"""MCP tools organized by functional domain."""
"""MCP tools organized by functional domain.
10 consolidated tools with ~90 actions total:
unraid_info - System information queries (19 actions)
unraid_array - Array operations and power management (12 actions)
unraid_storage - Storage, disks, and logs (6 actions)
unraid_docker - Docker container management (15 actions)
unraid_vm - Virtual machine management (9 actions)
unraid_notifications - Notification management (9 actions)
unraid_rclone - Cloud storage remotes (4 actions)
unraid_users - User management (8 actions)
unraid_keys - API key management (5 actions)
unraid_health - Health monitoring and diagnostics (3 actions)
"""

161
unraid_mcp/tools/array.py Normal file
View File

@@ -0,0 +1,161 @@
"""Array operations and system power management.
Provides the `unraid_array` tool with 12 actions for array lifecycle,
parity operations, disk management, and system power control.
"""
from typing import Any, Literal
from fastmcp import FastMCP
from ..config.logging import logger
from ..core.client import make_graphql_request
from ..core.exceptions import ToolError
QUERIES: dict[str, str] = {
"parity_history": """
query GetParityHistory {
array { parityCheckStatus { progress speed errors } }
}
""",
}
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) }
}
""",
"parity_pause": """
mutation PauseParityCheck {
parityCheck { pause }
}
""",
"parity_resume": """
mutation ResumeParityCheck {
parityCheck { resume }
}
""",
"parity_cancel": """
mutation CancelParityCheck {
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"}
ARRAY_ACTIONS = Literal[
"start", "stop",
"parity_start", "parity_pause", "parity_resume", "parity_cancel", "parity_history",
"mount_disk", "unmount_disk", "clear_stats",
"shutdown", "reboot",
]
def register_array_tool(mcp: FastMCP) -> None:
"""Register the unraid_array tool with the FastMCP instance."""
@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.
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_history - Get parity check status/history
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)
"""
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)}")
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:
variables = {"correct": correct}
data = await make_graphql_request(query, variables)
return {
"success": True,
"action": action,
"data": data,
}
except ToolError:
raise
except Exception as e:
logger.error(f"Error in unraid_array action={action}: {e}", exc_info=True)
raise ToolError(f"Failed to execute array/{action}: {str(e)}") from e
logger.info("Array tool registered successfully")

View File

@@ -1,11 +1,11 @@
"""Docker container management tools.
"""Docker container management.
This module provides tools for Docker container lifecycle and management
including listing containers with caching options, start/stop operations,
and detailed container information retrieval.
Provides the `unraid_docker` tool with 15 actions for container lifecycle,
logs, networks, and update management.
"""
from typing import Any
import re
from typing import Any, Literal
from fastmcp import FastMCP
@@ -13,376 +13,311 @@ from ..config.logging import logger
from ..core.client import make_graphql_request
from ..core.exceptions import ToolError
QUERIES: dict[str, str] = {
"list": """
query ListDockerContainers {
docker { containers(skipCache: false) {
id names image state status autoStart
} }
}
""",
"details": """
query GetContainerDetails {
docker { containers(skipCache: false) {
id names image imageId command created
ports { ip privatePort publicPort type }
sizeRootFs labels state status
hostConfig { networkMode }
networkSettings mounts autoStart
} }
}
""",
"logs": """
query GetContainerLogs($id: PrefixedID!, $tail: Int) {
docker { logs(id: $id, tail: $tail) }
}
""",
"networks": """
query GetDockerNetworks {
dockerNetworks { id name driver scope }
}
""",
"network_details": """
query GetDockerNetwork($id: PrefixedID!) {
dockerNetwork(id: $id) { id name driver scope containers }
}
""",
"port_conflicts": """
query GetPortConflicts {
docker { portConflicts { containerName port conflictsWith } }
}
""",
"check_updates": """
query CheckContainerUpdates {
docker { containerUpdateStatuses { id name updateAvailable currentVersion latestVersion } }
}
""",
}
def find_container_by_identifier(container_identifier: str, containers: list[dict[str, Any]]) -> dict[str, Any] | None:
"""Find a container by ID or name with fuzzy matching.
MUTATIONS: dict[str, str] = {
"start": """
mutation StartContainer($id: PrefixedID!) {
docker { start(id: $id) { id names state status } }
}
""",
"stop": """
mutation StopContainer($id: PrefixedID!) {
docker { stop(id: $id) { id names state status } }
}
""",
"pause": """
mutation PauseContainer($id: PrefixedID!) {
docker { pause(id: $id) { id names state status } }
}
""",
"unpause": """
mutation UnpauseContainer($id: PrefixedID!) {
docker { unpause(id: $id) { id names state status } }
}
""",
"remove": """
mutation RemoveContainer($id: PrefixedID!) {
docker { removeContainer(id: $id) }
}
""",
"update": """
mutation UpdateContainer($id: PrefixedID!) {
docker { updateContainer(id: $id) { id names state status } }
}
""",
"update_all": """
mutation UpdateAllContainers {
docker { updateAllContainers { id names state status } }
}
""",
}
Args:
container_identifier: Container ID or name to find
containers: List of container dictionaries to search
DESTRUCTIVE_ACTIONS = {"remove"}
CONTAINER_ACTIONS = {"start", "stop", "restart", "pause", "unpause", "remove", "update", "details", "logs"}
Returns:
Container dictionary if found, None otherwise
"""
DOCKER_ACTIONS = Literal[
"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")
_DOCKER_ID_PATTERN = re.compile(r"^[a-f0-9]{64}(:[a-z0-9]+)?$", re.IGNORECASE)
def find_container_by_identifier(
identifier: str, containers: list[dict[str, Any]]
) -> dict[str, Any] | None:
"""Find a container by ID or name with fuzzy matching."""
if not containers:
return None
# Exact matches first
for container in containers:
if container.get("id") == container_identifier:
return container
for c in containers:
if c.get("id") == identifier:
return c
if identifier in c.get("names", []):
return c
# Check all names for exact match
names = container.get("names", [])
if container_identifier in names:
return container
# Fuzzy matching - case insensitive partial matches
container_identifier_lower = container_identifier.lower()
for container in containers:
names = container.get("names", [])
for name in names:
if container_identifier_lower in name.lower() or name.lower() in container_identifier_lower:
logger.info(f"Found container via fuzzy match: '{container_identifier}' -> '{name}'")
return container
id_lower = identifier.lower()
for c in containers:
for name in c.get("names", []):
if id_lower in name.lower() or name.lower() in id_lower:
logger.info(f"Fuzzy match: '{identifier}' -> '{name}'")
return c
return None
def get_available_container_names(containers: list[dict[str, Any]]) -> list[str]:
"""Extract all available container names for error reporting.
Args:
containers: List of container dictionaries
Returns:
List of container names
"""
names = []
for container in containers:
container_names = container.get("names", [])
names.extend(container_names)
"""Extract all container names for error messages."""
names: list[str] = []
for c in containers:
names.extend(c.get("names", []))
return names
def register_docker_tools(mcp: FastMCP) -> None:
"""Register all Docker tools with the FastMCP instance.
async def _resolve_container_id(container_id: str) -> str:
"""Resolve a container name/identifier to its actual PrefixedID."""
if _DOCKER_ID_PATTERN.match(container_id):
return container_id
Args:
mcp: FastMCP instance to register tools with
"""
@mcp.tool()
async def list_docker_containers() -> list[dict[str, Any]]:
"""Lists all Docker containers on the Unraid system.
Returns:
List of Docker container information dictionaries
"""
query = """
query ListDockerContainers {
docker {
containers(skipCache: false) {
id
names
image
state
status
autoStart
}
}
logger.info(f"Resolving container identifier '{container_id}'")
list_query = """
query ResolveContainerID {
docker { containers(skipCache: true) { id names } }
}
"""
try:
logger.info("Executing list_docker_containers tool")
response_data = await make_graphql_request(query)
if response_data.get("docker"):
containers = response_data["docker"].get("containers", [])
return list(containers) if isinstance(containers, list) else []
return []
except Exception as e:
logger.error(f"Error in list_docker_containers: {e}", exc_info=True)
raise ToolError(f"Failed to list Docker containers: {str(e)}") from e
"""
data = await make_graphql_request(list_query)
containers = data.get("docker", {}).get("containers", [])
resolved = find_container_by_identifier(container_id, containers)
if resolved:
actual_id = str(resolved.get("id", ""))
logger.info(f"Resolved '{container_id}' -> '{actual_id}'")
return actual_id
available = get_available_container_names(containers)
msg = f"Container '{container_id}' not found."
if available:
msg += f" Available: {', '.join(available[:10])}"
raise ToolError(msg)
def register_docker_tool(mcp: FastMCP) -> None:
"""Register the unraid_docker tool with the FastMCP instance."""
@mcp.tool()
async def manage_docker_container(container_id: str, action: str) -> dict[str, Any]:
"""Starts or stops a specific Docker container. Action must be 'start' or 'stop'.
async def unraid_docker(
action: DOCKER_ACTIONS,
container_id: str | None = None,
network_id: str | None = None,
confirm: bool = False,
tail_lines: int = 100,
) -> dict[str, Any]:
"""Manage Docker containers, networks, and updates.
Args:
container_id: Container ID to manage
action: Action to perform - 'start' or 'stop'
Returns:
Dict containing operation result and container information
Actions:
list - List all containers
details - Detailed info for a container (requires container_id)
start - Start a container (requires container_id)
stop - Stop a container (requires container_id)
restart - Stop then start a container (requires container_id)
pause - Pause a container (requires container_id)
unpause - Unpause a container (requires container_id)
remove - Remove a container (requires container_id, confirm=True)
update - Update a container to latest image (requires container_id)
update_all - Update all containers with available updates
logs - Get container logs (requires container_id, optional tail_lines)
networks - List Docker networks
network_details - Details of a network (requires network_id)
port_conflicts - Check for port conflicts
check_updates - Check which containers have updates available
"""
import asyncio
all_actions = set(QUERIES) | set(MUTATIONS) | {"restart"}
if action not in all_actions:
raise ToolError(f"Invalid action '{action}'. Must be one of: {sorted(all_actions)}")
if action.lower() not in ["start", "stop"]:
logger.warning(f"Invalid action '{action}' for manage_docker_container")
raise ToolError("Invalid action. Must be 'start' or 'stop'.")
if action in DESTRUCTIVE_ACTIONS and not confirm:
raise ToolError(f"Action '{action}' is destructive. Set confirm=True to proceed.")
mutation_name = action.lower()
if action in CONTAINER_ACTIONS and not container_id:
raise ToolError(f"container_id is required for '{action}' action")
# Step 1: Execute the operation mutation
operation_query = f"""
mutation ManageDockerContainer($id: PrefixedID!) {{
docker {{
{mutation_name}(id: $id) {{
id
names
state
status
}}
}}
}}
"""
variables = {"id": container_id}
if action == "network_details" and not network_id:
raise ToolError("network_id is required for 'network_details' action")
try:
logger.info(f"Executing manage_docker_container: action={action}, id={container_id}")
logger.info(f"Executing unraid_docker action={action}")
# Step 1: Resolve container identifier to actual container ID if needed
actual_container_id = container_id
if not container_id.startswith("3cb1026338736ed07b8afec2c484e429710b0f6550dc65d0c5c410ea9d0fa6b2:"):
# This looks like a name, not a full container ID - need to resolve it
logger.info(f"Resolving container identifier '{container_id}' to actual container ID")
list_query = """
query ResolveContainerID {
docker {
containers(skipCache: true) {
id
names
}
}
# --- Read-only queries ---
if action == "list":
data = await make_graphql_request(QUERIES["list"])
containers = data.get("docker", {}).get("containers", [])
return {"containers": list(containers) if isinstance(containers, list) else []}
if action == "details":
data = await make_graphql_request(QUERIES["details"])
containers = data.get("docker", {}).get("containers", [])
container = find_container_by_identifier(container_id or "", containers)
if container:
return container
available = get_available_container_names(containers)
msg = f"Container '{container_id}' not found."
if available:
msg += f" Available: {', '.join(available[:10])}"
raise ToolError(msg)
if action == "logs":
actual_id = await _resolve_container_id(container_id or "")
data = await make_graphql_request(
QUERIES["logs"], {"id": actual_id, "tail": tail_lines}
)
return {"logs": data.get("docker", {}).get("logs")}
if action == "networks":
data = await make_graphql_request(QUERIES["networks"])
networks = data.get("dockerNetworks", [])
return {"networks": list(networks) if isinstance(networks, list) else []}
if action == "network_details":
data = await make_graphql_request(
QUERIES["network_details"], {"id": network_id}
)
return dict(data.get("dockerNetwork", {}))
if action == "port_conflicts":
data = await make_graphql_request(QUERIES["port_conflicts"])
conflicts = data.get("docker", {}).get("portConflicts", [])
return {"port_conflicts": list(conflicts) if isinstance(conflicts, list) else []}
if action == "check_updates":
data = await make_graphql_request(QUERIES["check_updates"])
statuses = data.get("docker", {}).get("containerUpdateStatuses", [])
return {"update_statuses": list(statuses) if isinstance(statuses, list) else []}
# --- Mutations ---
if action == "restart":
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},
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},
operation_context={"operation": "start"},
)
result = start_data.get("docker", {}).get("start", {})
response: dict[str, Any] = {
"success": True, "action": "restart", "container": result,
}
"""
list_response = await make_graphql_request(list_query)
if list_response.get("docker"):
containers = list_response["docker"].get("containers", [])
resolved_container = find_container_by_identifier(container_id, containers)
if resolved_container:
actual_container_id = str(resolved_container.get("id", ""))
logger.info(f"Resolved '{container_id}' to container ID: {actual_container_id}")
else:
available_names = get_available_container_names(containers)
error_msg = f"Container '{container_id}' not found for {action} operation."
if available_names:
error_msg += f" Available containers: {', '.join(available_names[:10])}"
raise ToolError(error_msg)
if stop_was_idempotent:
response["note"] = "Container was already stopped before restart"
return response
# Update variables with the actual container ID
variables = {"id": actual_container_id}
if action == "update_all":
data = await make_graphql_request(MUTATIONS["update_all"])
results = data.get("docker", {}).get("updateAllContainers", [])
return {"success": True, "action": "update_all", "containers": results}
# Execute the operation with idempotent error handling
operation_context = {"operation": action}
operation_response = await make_graphql_request(
operation_query,
variables,
operation_context=operation_context
)
# Single-container mutations
if action in MUTATIONS:
actual_id = await _resolve_container_id(container_id or "")
op_context = {"operation": action} if action in ("start", "stop") else None
data = await make_graphql_request(
MUTATIONS[action], {"id": actual_id},
operation_context=op_context,
)
# Handle idempotent success case
if operation_response.get("idempotent_success"):
logger.info(f"Container {action} operation was idempotent: {operation_response.get('message')}")
# Get current container state since the operation was already complete
try:
list_query = """
query GetContainerStateAfterIdempotent($skipCache: Boolean!) {
docker {
containers(skipCache: $skipCache) {
id
names
image
state
status
autoStart
}
}
# Handle idempotent success
if data.get("idempotent_success"):
return {
"success": True,
"action": action,
"idempotent": True,
"message": f"Container already in desired state for '{action}'",
}
"""
list_response = await make_graphql_request(list_query, {"skipCache": True})
if list_response.get("docker"):
containers = list_response["docker"].get("containers", [])
container = find_container_by_identifier(container_id, containers)
if container:
return {
"operation_result": {"id": container_id, "names": container.get("names", [])},
"container_details": container,
"success": True,
"message": f"Container {action} operation was already complete - current state returned",
"idempotent": True
}
except Exception as lookup_error:
logger.warning(f"Could not retrieve container state after idempotent operation: {lookup_error}")
docker_data = data.get("docker", {})
result = docker_data.get(action, docker_data.get("removeContainer"))
return {
"operation_result": {"id": container_id},
"container_details": None,
"success": True,
"message": f"Container {action} operation was already complete",
"idempotent": True
"action": action,
"container": result,
}
# Handle normal successful operation
if not (operation_response.get("docker") and operation_response["docker"].get(mutation_name)):
raise ToolError(f"Failed to execute {action} operation on container")
operation_result = operation_response["docker"][mutation_name]
logger.info(f"Container {action} operation completed for {container_id}")
# Step 2: Wait briefly for state to propagate, then fetch current container details
await asyncio.sleep(1.0) # Give the container state time to update
# Step 3: Try to get updated container details with retry logic
max_retries = 3
retry_delay = 1.0
for attempt in range(max_retries):
try:
# Query all containers and find the one we just operated on
list_query = """
query GetUpdatedContainerState($skipCache: Boolean!) {
docker {
containers(skipCache: $skipCache) {
id
names
image
state
status
autoStart
}
}
}
"""
# Skip cache to get fresh data
list_response = await make_graphql_request(list_query, {"skipCache": True})
if list_response.get("docker"):
containers = list_response["docker"].get("containers", [])
# Find the container using our helper function
container = find_container_by_identifier(container_id, containers)
if container:
logger.info(f"Found updated container state for {container_id}")
return {
"operation_result": operation_result,
"container_details": container,
"success": True,
"message": f"Container {action} operation completed successfully"
}
# If not found in this attempt, wait and retry
if attempt < max_retries - 1:
logger.warning(f"Container {container_id} not found after {action}, retrying in {retry_delay}s (attempt {attempt + 1}/{max_retries})")
await asyncio.sleep(retry_delay)
retry_delay *= 1.5 # Exponential backoff
except Exception as query_error:
logger.warning(f"Error querying updated container state (attempt {attempt + 1}): {query_error}")
if attempt < max_retries - 1:
await asyncio.sleep(retry_delay)
retry_delay *= 1.5
else:
# On final attempt failure, still return operation success
logger.warning(f"Could not retrieve updated container details after {action}, but operation succeeded")
return {
"operation_result": operation_result,
"container_details": None,
"success": True,
"message": f"Container {action} operation completed, but updated state could not be retrieved",
"warning": "Container state query failed after operation - this may be due to timing or the container not being found in the updated state"
}
# If we get here, all retries failed to find the container
logger.warning(f"Container {container_id} not found in any retry attempt after {action}")
return {
"operation_result": operation_result,
"container_details": None,
"success": True,
"message": f"Container {action} operation completed, but container not found in subsequent queries",
"warning": "Container not found in updated state - this may indicate the operation succeeded but container is no longer listed"
}
return {}
except ToolError:
raise
except Exception as e:
logger.error(f"Error in manage_docker_container ({action}): {e}", exc_info=True)
raise ToolError(f"Failed to {action} Docker container: {str(e)}") from e
logger.error(f"Error in unraid_docker action={action}: {e}", exc_info=True)
raise ToolError(f"Failed to execute docker/{action}: {str(e)}") from e
@mcp.tool()
async def get_docker_container_details(container_identifier: str) -> dict[str, Any]:
"""Retrieves detailed information for a specific Docker container by its ID or name.
Args:
container_identifier: Container ID or name to retrieve details for
Returns:
Dict containing detailed container information
"""
# This tool fetches all containers and then filters by ID or name.
# More detailed query for a single container if found:
detailed_query_fields = """
id
names
image
imageId
command
created
ports { ip privatePort publicPort type }
sizeRootFs
labels # JSONObject
state
status
hostConfig { networkMode }
networkSettings # JSONObject
mounts # JSONObject array
autoStart
"""
# Fetch all containers first
list_query = f"""
query GetAllContainerDetailsForFiltering {{
docker {{
containers(skipCache: false) {{
{detailed_query_fields}
}}
}}
}}
"""
try:
logger.info(f"Executing get_docker_container_details for identifier: {container_identifier}")
response_data = await make_graphql_request(list_query)
containers = []
if response_data.get("docker"):
containers = response_data["docker"].get("containers", [])
# Use our enhanced container lookup
container = find_container_by_identifier(container_identifier, containers)
if container:
logger.info(f"Found container {container_identifier}")
return container
# Container not found - provide helpful error message with available containers
available_names = get_available_container_names(containers)
logger.warning(f"Container with identifier '{container_identifier}' not found.")
logger.info(f"Available containers: {available_names}")
error_msg = f"Container '{container_identifier}' not found."
if available_names:
error_msg += f" Available containers: {', '.join(available_names[:10])}" # Limit to first 10
if len(available_names) > 10:
error_msg += f" (and {len(available_names) - 10} more)"
else:
error_msg += " No containers are currently available."
raise ToolError(error_msg)
except Exception as e:
logger.error(f"Error in get_docker_container_details: {e}", exc_info=True)
raise ToolError(f"Failed to retrieve Docker container details: {str(e)}") from e
logger.info("Docker tools registered successfully")
logger.info("Docker tool registered successfully")

View File

@@ -1,186 +1,264 @@
"""Comprehensive health monitoring tools.
"""Health monitoring and diagnostics.
This module provides tools for comprehensive health checks of the Unraid MCP server
and the underlying Unraid system, including performance metrics, system status,
notifications, Docker services, and API responsiveness.
Provides the `unraid_health` tool with 3 actions for system health checks,
connection testing, and subscription diagnostics.
"""
import datetime
import time
from typing import Any
from typing import Any, Literal
from fastmcp import FastMCP
from ..config.logging import logger
from ..config.settings import UNRAID_API_URL, UNRAID_MCP_HOST, UNRAID_MCP_PORT, UNRAID_MCP_TRANSPORT
from ..config.settings import (
UNRAID_API_URL,
UNRAID_MCP_HOST,
UNRAID_MCP_PORT,
UNRAID_MCP_TRANSPORT,
VERSION,
)
from ..core.client import make_graphql_request
from ..core.exceptions import ToolError
HEALTH_ACTIONS = Literal["check", "test_connection", "diagnose"]
# Severity ordering: only upgrade, never downgrade
_SEVERITY = {"healthy": 0, "warning": 1, "degraded": 2, "unhealthy": 3}
def register_health_tools(mcp: FastMCP) -> None:
"""Register all health tools with the FastMCP instance.
Args:
mcp: FastMCP instance to register tools with
"""
def register_health_tool(mcp: FastMCP) -> None:
"""Register the unraid_health tool with the FastMCP instance."""
@mcp.tool()
async def health_check() -> dict[str, Any]:
"""Returns comprehensive health status of the Unraid MCP server and system for monitoring purposes."""
start_time = time.time()
health_status = "healthy"
issues = []
async def unraid_health(
action: HEALTH_ACTIONS,
) -> dict[str, Any]:
"""Monitor Unraid MCP server and system health.
Actions:
check - Comprehensive health check (API latency, array, notifications, Docker)
test_connection - Quick connectivity test (just checks { online })
diagnose - Subscription system diagnostics
"""
if action not in ("check", "test_connection", "diagnose"):
raise ToolError(
f"Invalid action '{action}'. Must be one of: check, test_connection, diagnose"
)
try:
# Enhanced health check with multiple system components
comprehensive_query = """
query ComprehensiveHealthCheck {
info {
machineId
time
versions { unraid }
os { uptime }
}
array {
state
}
notifications {
overview {
unread { alert warning total }
}
}
docker {
containers(skipCache: true) {
id
state
status
}
}
}
"""
logger.info(f"Executing unraid_health action={action}")
response_data = await make_graphql_request(comprehensive_query)
api_latency = round((time.time() - start_time) * 1000, 2) # ms
# Base health info
health_info = {
"status": health_status,
"timestamp": datetime.datetime.utcnow().isoformat(),
"api_latency_ms": api_latency,
"server": {
"name": "Unraid MCP Server",
"version": "0.1.0",
"transport": UNRAID_MCP_TRANSPORT,
"host": UNRAID_MCP_HOST,
"port": UNRAID_MCP_PORT,
"process_uptime_seconds": time.time() - start_time # Rough estimate
}
}
if not response_data:
health_status = "unhealthy"
issues.append("No response from Unraid API")
health_info["status"] = health_status
health_info["issues"] = issues
return health_info
# System info analysis
info = response_data.get("info", {})
if info:
health_info["unraid_system"] = {
if action == "test_connection":
start = time.time()
data = await make_graphql_request("query { online }")
latency = round((time.time() - start) * 1000, 2)
return {
"status": "connected",
"url": UNRAID_API_URL,
"machine_id": info.get("machineId"),
"time": info.get("time"),
"version": info.get("versions", {}).get("unraid"),
"uptime": info.get("os", {}).get("uptime")
}
else:
health_status = "degraded"
issues.append("Unable to retrieve system info")
# Array health analysis
array_info = response_data.get("array", {})
if array_info:
array_state = array_info.get("state", "unknown")
health_info["array_status"] = {
"state": array_state,
"healthy": array_state in ["STARTED", "STOPPED"]
}
if array_state not in ["STARTED", "STOPPED"]:
health_status = "warning"
issues.append(f"Array in unexpected state: {array_state}")
else:
health_status = "warning"
issues.append("Unable to retrieve array status")
# Notifications analysis
notifications = response_data.get("notifications", {})
if notifications and notifications.get("overview"):
unread = notifications["overview"].get("unread", {})
alert_count = unread.get("alert", 0)
warning_count = unread.get("warning", 0)
total_unread = unread.get("total", 0)
health_info["notifications"] = {
"unread_total": total_unread,
"unread_alerts": alert_count,
"unread_warnings": warning_count,
"has_critical_notifications": alert_count > 0
"online": data.get("online"),
"latency_ms": latency,
}
if alert_count > 0:
health_status = "warning"
issues.append(f"{alert_count} unread alert notification(s)")
if action == "check":
return await _comprehensive_check()
# Docker services analysis
docker_info = response_data.get("docker", {})
if docker_info and docker_info.get("containers"):
containers = docker_info["containers"]
running_containers = [c for c in containers if c.get("state") == "running"]
stopped_containers = [c for c in containers if c.get("state") == "exited"]
if action == "diagnose":
return await _diagnose_subscriptions()
health_info["docker_services"] = {
"total_containers": len(containers),
"running_containers": len(running_containers),
"stopped_containers": len(stopped_containers),
"containers_healthy": len([c for c in containers if c.get("status", "").startswith("Up")])
}
return {}
# API performance assessment
if api_latency > 5000: # > 5 seconds
health_status = "warning"
issues.append(f"High API latency: {api_latency}ms")
elif api_latency > 10000: # > 10 seconds
health_status = "degraded"
issues.append(f"Very high API latency: {api_latency}ms")
except ToolError:
raise
except Exception as e:
logger.error(f"Error in unraid_health action={action}: {e}", exc_info=True)
raise ToolError(f"Failed to execute health/{action}: {str(e)}") from e
# Final status determination
health_info["status"] = health_status
if issues:
health_info["issues"] = issues
logger.info("Health tool registered successfully")
# Add performance metrics
health_info["performance"] = {
"api_response_time_ms": api_latency,
"health_check_duration_ms": round((time.time() - start_time) * 1000, 2)
}
async def _comprehensive_check() -> dict[str, Any]:
"""Run comprehensive health check against the Unraid system."""
start_time = time.time()
health_severity = 0 # Track as int to prevent downgrade
issues: list[str] = []
def _escalate(level: str) -> None:
nonlocal health_severity
health_severity = max(health_severity, _SEVERITY.get(level, 0))
try:
query = """
query ComprehensiveHealthCheck {
info {
machineId time
versions { unraid }
os { uptime }
}
array { state }
notifications {
overview { unread { alert warning total } }
}
docker {
containers(skipCache: true) { id state status }
}
}
"""
data = await make_graphql_request(query)
api_latency = round((time.time() - start_time) * 1000, 2)
health_info: dict[str, Any] = {
"status": "healthy",
"timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"api_latency_ms": api_latency,
"server": {
"name": "Unraid MCP Server",
"version": VERSION,
"transport": UNRAID_MCP_TRANSPORT,
"host": UNRAID_MCP_HOST,
"port": UNRAID_MCP_PORT,
},
}
if not data:
health_info["status"] = "unhealthy"
health_info["issues"] = ["No response from Unraid API"]
return health_info
except Exception as e:
logger.error(f"Health check failed: {e}")
return {
"status": "unhealthy",
"timestamp": datetime.datetime.utcnow().isoformat(),
"error": str(e),
"api_latency_ms": round((time.time() - start_time) * 1000, 2) if 'start_time' in locals() else None,
"server": {
"name": "Unraid MCP Server",
"version": "0.1.0",
"transport": UNRAID_MCP_TRANSPORT,
"host": UNRAID_MCP_HOST,
"port": UNRAID_MCP_PORT
}
# System info
info = data.get("info", {})
if info:
health_info["unraid_system"] = {
"status": "connected",
"url": UNRAID_API_URL,
"machine_id": info.get("machineId"),
"version": info.get("versions", {}).get("unraid"),
"uptime": info.get("os", {}).get("uptime"),
}
else:
_escalate("degraded")
issues.append("Unable to retrieve system info")
# Array
array_info = data.get("array", {})
if array_info:
state = array_info.get("state", "unknown")
health_info["array_status"] = {
"state": state,
"healthy": state in ("STARTED", "STOPPED"),
}
if state not in ("STARTED", "STOPPED"):
_escalate("warning")
issues.append(f"Array in unexpected state: {state}")
else:
_escalate("warning")
issues.append("Unable to retrieve array status")
# Notifications
notifications = data.get("notifications", {})
if notifications and notifications.get("overview"):
unread = notifications["overview"].get("unread", {})
alerts = unread.get("alert", 0)
health_info["notifications"] = {
"unread_total": unread.get("total", 0),
"unread_alerts": alerts,
"unread_warnings": unread.get("warning", 0),
}
if alerts > 0:
_escalate("warning")
issues.append(f"{alerts} unread alert(s)")
# Docker
docker = data.get("docker", {})
if docker and docker.get("containers"):
containers = docker["containers"]
health_info["docker_services"] = {
"total": len(containers),
"running": len([c for c in containers if c.get("state") == "running"]),
"stopped": len([c for c in containers if c.get("state") == "exited"]),
}
logger.info("Health tools registered successfully")
# Latency assessment
if api_latency > 10000:
_escalate("degraded")
issues.append(f"Very high API latency: {api_latency}ms")
elif api_latency > 5000:
_escalate("warning")
issues.append(f"High API latency: {api_latency}ms")
# Resolve final status from severity level
severity_to_status = {v: k for k, v in _SEVERITY.items()}
health_info["status"] = severity_to_status.get(health_severity, "healthy")
if issues:
health_info["issues"] = issues
health_info["performance"] = {
"api_response_time_ms": api_latency,
"check_duration_ms": round((time.time() - start_time) * 1000, 2),
}
return health_info
except Exception as e:
logger.error(f"Health check failed: {e}")
return {
"status": "unhealthy",
"timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"error": str(e),
"server": {
"name": "Unraid MCP Server",
"version": VERSION,
"transport": UNRAID_MCP_TRANSPORT,
"host": UNRAID_MCP_HOST,
"port": UNRAID_MCP_PORT,
},
}
async def _diagnose_subscriptions() -> dict[str, Any]:
"""Import and run subscription diagnostics."""
try:
from ..subscriptions.manager import subscription_manager
from ..subscriptions.resources import ensure_subscriptions_started
await ensure_subscriptions_started()
status = subscription_manager.get_subscription_status()
connection_issues: list[dict[str, Any]] = []
diagnostic_info: dict[str, Any] = {
"timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"environment": {
"auto_start_enabled": subscription_manager.auto_start_enabled,
"max_reconnect_attempts": subscription_manager.max_reconnect_attempts,
"api_url_configured": bool(UNRAID_API_URL),
},
"subscriptions": status,
"summary": {
"total_configured": len(subscription_manager.subscription_configs),
"active_count": len(subscription_manager.active_subscriptions),
"with_data": len(subscription_manager.resource_data),
"in_error_state": 0,
"connection_issues": connection_issues,
},
}
for sub_name, sub_status in status.items():
runtime = sub_status.get("runtime", {})
conn_state = runtime.get("connection_state", "unknown")
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"],
})
return diagnostic_info
except ImportError:
return {
"error": "Subscription modules not available",
"timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(),
}
except Exception as e:
raise ToolError(f"Failed to generate diagnostics: {str(e)}") from e

400
unraid_mcp/tools/info.py Normal file
View File

@@ -0,0 +1,400 @@
"""System information and server status queries.
Provides the `unraid_info` tool with 19 read-only actions for retrieving
system information, array status, network config, and server metadata.
"""
from typing import Any, Literal
from fastmcp import FastMCP
from ..config.logging import logger
from ..core.client import make_graphql_request
from ..core.exceptions import ToolError
# Pre-built queries keyed by action name
QUERIES: dict[str, str] = {
"overview": """
query GetSystemInfo {
info {
os { platform distro release codename kernel arch hostname codepage logofile serial build uptime }
cpu { manufacturer brand vendor family model stepping revision voltage speed speedmin speedmax threads cores processors socket cache flags }
memory {
layout { bank type clockSpeed formFactor manufacturer partNum serialNum }
}
baseboard { manufacturer model version serial assetTag }
system { manufacturer model version serial uuid sku }
versions { kernel openssl systemOpenssl systemOpensslLib node v8 npm yarn pm2 gulp grunt git tsc mysql redis mongodb apache nginx php docker postfix postgresql perl python gcc unraid }
apps { installed started }
machineId
time
}
}
""",
"array": """
query GetArrayStatus {
array {
id
state
capacity {
kilobytes { free used total }
disks { free used total }
}
boot { id idx name device size status rotational temp numReads numWrites numErrors fsSize fsFree fsUsed exportable type warning critical fsType comment format transport color }
parities { id idx name device size status rotational temp numReads numWrites numErrors fsSize fsFree fsUsed exportable type warning critical fsType comment format transport color }
disks { id idx name device size status rotational temp numReads numWrites numErrors fsSize fsFree fsUsed exportable type warning critical fsType comment format transport color }
caches { id idx name device size status rotational temp numReads numWrites numErrors fsSize fsFree fsUsed exportable type warning critical fsType comment format transport color }
}
}
""",
"network": """
query GetNetworkConfig {
network {
id
accessUrls { type name ipv4 ipv6 }
}
}
""",
"registration": """
query GetRegistrationInfo {
registration {
id type
keyFile { location contents }
state expiration updateExpiration
}
}
""",
"connect": """
query GetConnectSettings {
connect { status sandbox flashGuid }
}
""",
"variables": """
query GetSelectiveUnraidVariables {
vars {
id version name timeZone comment security workgroup domain domainShort
hideDotFiles localMaster enableFruit useNtp domainLogin sysModel
sysFlashSlots useSsl port portssl localTld bindMgt useTelnet porttelnet
useSsh portssh startPage startArray shutdownTimeout
shareSmbEnabled shareNfsEnabled shareAfpEnabled shareCacheEnabled
shareAvahiEnabled safeMode startMode configValid configError joinStatus
deviceCount flashGuid flashProduct flashVendor mdState mdVersion
shareCount shareSmbCount shareNfsCount shareAfpCount shareMoverActive
csrfToken
}
}
""",
"metrics": """
query GetMetrics {
metrics { cpu { used } memory { used total } }
}
""",
"services": """
query GetServices {
services { name state }
}
""",
"display": """
query GetDisplay {
info { display { theme } }
}
""",
"config": """
query GetConfig {
config { valid error }
}
""",
"online": """
query GetOnline { online }
""",
"owner": """
query GetOwner {
owner { username avatar url }
}
""",
"settings": """
query GetSettings {
settings { unified { values } }
}
""",
"server": """
query GetServer {
info {
os { hostname uptime }
versions { unraid }
machineId time
}
array { state }
online
}
""",
"servers": """
query GetServers {
servers { id name status description ip port }
}
""",
"flash": """
query GetFlash {
flash { id guid product vendor size }
}
""",
"ups_devices": """
query GetUpsDevices {
upsDevices { id model status runtime charge load }
}
""",
"ups_device": """
query GetUpsDevice($id: PrefixedID!) {
upsDeviceById(id: $id) { id model status runtime charge load voltage frequency temperature }
}
""",
"ups_config": """
query GetUpsConfig {
upsConfiguration { enabled mode cable driver port }
}
""",
}
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",
]
def _process_system_info(raw_info: dict[str, Any]) -> dict[str, Any]:
"""Process raw system info into summary + details."""
summary: dict[str, Any] = {}
if raw_info.get("os"):
os_info = raw_info["os"]
summary["os"] = (
f"{os_info.get('distro', '')} {os_info.get('release', '')} "
f"({os_info.get('platform', '')}, {os_info.get('arch', '')})"
)
summary["hostname"] = os_info.get("hostname")
summary["uptime"] = os_info.get("uptime")
if raw_info.get("cpu"):
cpu = raw_info["cpu"]
summary["cpu"] = (
f"{cpu.get('manufacturer', '')} {cpu.get('brand', '')} "
f"({cpu.get('cores')} cores, {cpu.get('threads')} threads)"
)
if raw_info.get("memory") and raw_info["memory"].get("layout"):
mem_layout = raw_info["memory"]["layout"]
summary["memory_layout_details"] = []
for stick in mem_layout:
summary["memory_layout_details"].append(
f"Bank {stick.get('bank', '?')}: Type {stick.get('type', '?')}, "
f"Speed {stick.get('clockSpeed', '?')}MHz, "
f"Manufacturer: {stick.get('manufacturer', '?')}, "
f"Part: {stick.get('partNum', '?')}"
)
summary["memory_summary"] = (
"Stick layout details retrieved. Overall total/used/free memory stats "
"are unavailable due to API limitations."
)
else:
summary["memory_summary"] = "Memory information not available."
return {"summary": summary, "details": raw_info}
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, "unknown": 0}
for disk in disks:
status = disk.get("status", "").upper()
warning = disk.get("warning")
critical = disk.get("critical")
if status == "DISK_OK":
counts["warning" if (warning or critical) else "healthy"] += 1
elif status in ("DISK_DSBL", "DISK_INVALID"):
counts["failed"] += 1
elif status == "DISK_NP":
counts["missing"] += 1
elif status == "DISK_NEW":
counts["new"] += 1
else:
counts["unknown"] += 1
return counts
def _process_array_status(raw: dict[str, Any]) -> dict[str, Any]:
"""Process raw array data into summary + details."""
def format_kb(k: Any) -> str:
if k is None:
return "N/A"
k = int(k)
if k >= 1024 * 1024 * 1024:
return f"{k / (1024 * 1024 * 1024):.2f} TB"
if k >= 1024 * 1024:
return f"{k / (1024 * 1024):.2f} GB"
if k >= 1024:
return f"{k / 1024:.2f} MB"
return f"{k} KB"
summary: dict[str, Any] = {"state": raw.get("state")}
if raw.get("capacity") and raw["capacity"].get("kilobytes"):
kb = raw["capacity"]["kilobytes"]
summary["capacity_total"] = format_kb(kb.get("total"))
summary["capacity_used"] = format_kb(kb.get("used"))
summary["capacity_free"] = format_kb(kb.get("free"))
summary["num_parity_disks"] = len(raw.get("parities", []))
summary["num_data_disks"] = len(raw.get("disks", []))
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")]:
if raw.get(key):
health_summary[label] = _analyze_disk_health(raw[key])
total_failed = sum(h.get("failed", 0) for h in health_summary.values())
total_missing = sum(h.get("missing", 0) for h in health_summary.values())
total_warning = sum(h.get("warning", 0) for h in health_summary.values())
if total_failed > 0:
overall = "CRITICAL"
elif total_missing > 0:
overall = "DEGRADED"
elif total_warning > 0:
overall = "WARNING"
else:
overall = "HEALTHY"
summary["overall_health"] = overall
summary["health_summary"] = health_summary
return {"summary": summary, "details": raw}
def register_info_tool(mcp: FastMCP) -> None:
"""Register the unraid_info tool with the FastMCP instance."""
@mcp.tool()
async def unraid_info(
action: INFO_ACTIONS,
device_id: str | None = None,
) -> dict[str, Any]:
"""Query Unraid system information.
Actions:
overview - OS, CPU, memory, baseboard, versions
array - Array state, capacity, disk health
network - Access URLs, interfaces
registration - License type, state, expiration
connect - Unraid Connect settings
variables - System variables and configuration
metrics - CPU and memory utilization
services - Running services
display - Theme settings
config - Configuration validity
online - Server online status
owner - Server owner info
settings - All unified settings
server - Quick server summary
servers - Connected servers list
flash - Flash drive info
ups_devices - List UPS devices
ups_device - Single UPS device (requires device_id)
ups_config - UPS configuration
"""
if action not in QUERIES:
raise ToolError(f"Invalid action '{action}'. Must be one of: {list(QUERIES.keys())}")
if action == "ups_device" and not device_id:
raise ToolError("device_id is required for ups_device action")
query = QUERIES[action]
variables: dict[str, Any] | None = None
if action == "ups_device":
variables = {"id": device_id}
try:
logger.info(f"Executing unraid_info action={action}")
data = await make_graphql_request(query, variables)
# Action-specific response processing
if action == "overview":
raw = data.get("info", {})
if not raw:
raise ToolError("No system info returned from Unraid API")
return _process_system_info(raw)
if action == "array":
raw = data.get("array", {})
if not raw:
raise ToolError("No array information returned from Unraid API")
return _process_array_status(raw)
if action == "network":
return dict(data.get("network", {}))
if action == "registration":
return dict(data.get("registration", {}))
if action == "connect":
return dict(data.get("connect", {}))
if action == "variables":
return dict(data.get("vars", {}))
if action == "metrics":
return dict(data.get("metrics", {}))
if action == "services":
services = data.get("services", [])
return {"services": list(services) if isinstance(services, list) else []}
if action == "display":
info = data.get("info", {})
return dict(info.get("display", {}))
if action == "config":
return dict(data.get("config", {}))
if action == "online":
return {"online": data.get("online")}
if action == "owner":
return dict(data.get("owner", {}))
if action == "settings":
settings = data.get("settings", {})
if settings and settings.get("unified"):
return dict(settings["unified"].get("values", {}))
return {}
if action == "server":
return data
if action == "servers":
servers = data.get("servers", [])
return {"servers": list(servers) if isinstance(servers, list) else []}
if action == "flash":
return dict(data.get("flash", {}))
if action == "ups_devices":
devices = data.get("upsDevices", [])
return {"ups_devices": list(devices) if isinstance(devices, list) else []}
if action == "ups_device":
return dict(data.get("upsDeviceById", {}))
if action == "ups_config":
return dict(data.get("upsConfiguration", {}))
return data
except ToolError:
raise
except Exception as e:
logger.error(f"Error in unraid_info action={action}: {e}", exc_info=True)
raise ToolError(f"Failed to execute info/{action}: {str(e)}") from e
logger.info("Info tool registered successfully")

146
unraid_mcp/tools/keys.py Normal file
View File

@@ -0,0 +1,146 @@
"""API key management.
Provides the `unraid_keys` tool with 5 actions for listing, viewing,
creating, updating, and deleting API keys.
"""
from typing import Any, Literal
from fastmcp import FastMCP
from ..config.logging import logger
from ..core.client import make_graphql_request
from ..core.exceptions import ToolError
QUERIES: dict[str, str] = {
"list": """
query ListApiKeys {
apiKeys { id name roles permissions createdAt lastUsed }
}
""",
"get": """
query GetApiKey($id: PrefixedID!) {
apiKey(id: $id) { id name roles permissions createdAt lastUsed }
}
""",
}
MUTATIONS: dict[str, str] = {
"create": """
mutation CreateApiKey($input: CreateApiKeyInput!) {
createApiKey(input: $input) { id name key roles }
}
""",
"update": """
mutation UpdateApiKey($input: UpdateApiKeyInput!) {
updateApiKey(input: $input) { id name roles }
}
""",
"delete": """
mutation DeleteApiKeys($input: DeleteApiKeysInput!) {
deleteApiKeys(input: $input)
}
""",
}
DESTRUCTIVE_ACTIONS = {"delete"}
KEY_ACTIONS = Literal[
"list", "get", "create", "update", "delete",
]
def register_keys_tool(mcp: FastMCP) -> None:
"""Register the unraid_keys tool with the FastMCP instance."""
@mcp.tool()
async def unraid_keys(
action: KEY_ACTIONS,
confirm: bool = False,
key_id: str | None = None,
name: str | None = None,
roles: list[str] | None = None,
permissions: list[str] | None = None,
) -> dict[str, Any]:
"""Manage Unraid API keys.
Actions:
list - List all API keys
get - Get a specific API key (requires key_id)
create - Create a new API key (requires name; optional roles, permissions)
update - Update an API key (requires key_id; optional name, roles)
delete - Delete API keys (requires key_id, confirm=True)
"""
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)}")
if action in DESTRUCTIVE_ACTIONS and not confirm:
raise ToolError(f"Action '{action}' is destructive. Set confirm=True to proceed.")
try:
logger.info(f"Executing unraid_keys action={action}")
if action == "list":
data = await make_graphql_request(QUERIES["list"])
keys = data.get("apiKeys", [])
return {"keys": list(keys) if isinstance(keys, list) else []}
if action == "get":
if not key_id:
raise ToolError("key_id is required for 'get' action")
data = await make_graphql_request(QUERIES["get"], {"id": key_id})
return dict(data.get("apiKey", {}))
if action == "create":
if not name:
raise ToolError("name is required for 'create' action")
input_data: dict[str, Any] = {"name": name}
if roles:
input_data["roles"] = roles
if permissions:
input_data["permissions"] = permissions
data = await make_graphql_request(
MUTATIONS["create"], {"input": input_data}
)
return {
"success": True,
"key": data.get("createApiKey", {}),
}
if action == "update":
if not key_id:
raise ToolError("key_id is required for 'update' action")
input_data = {"id": key_id}
if name:
input_data["name"] = name
if roles:
input_data["roles"] = roles
data = await make_graphql_request(
MUTATIONS["update"], {"input": input_data}
)
return {
"success": True,
"key": data.get("updateApiKey", {}),
}
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]}}
)
return {
"success": True,
"message": f"API key '{key_id}' deleted",
}
return {}
except ToolError:
raise
except Exception as e:
logger.error(f"Error in unraid_keys action={action}: {e}", exc_info=True)
raise ToolError(f"Failed to execute keys/{action}: {str(e)}") from e
logger.info("Keys tool registered successfully")

View File

@@ -0,0 +1,205 @@
"""Notification management.
Provides the `unraid_notifications` tool with 9 actions for viewing,
creating, archiving, and deleting system notifications.
"""
from typing import Any, Literal
from fastmcp import FastMCP
from ..config.logging import logger
from ..core.client import make_graphql_request
from ..core.exceptions import ToolError
QUERIES: dict[str, str] = {
"overview": """
query GetNotificationsOverview {
notifications {
overview {
unread { info warning alert total }
archive { info warning alert total }
}
}
}
""",
"list": """
query ListNotifications($filter: NotificationFilter!) {
notifications {
list(filter: $filter) {
id title subject description importance link type timestamp formattedTimestamp
}
}
}
""",
"warnings": """
query GetWarningsAndAlerts {
notifications {
warningsAndAlerts { id title subject description importance type timestamp }
}
}
""",
}
MUTATIONS: dict[str, str] = {
"create": """
mutation CreateNotification($input: CreateNotificationInput!) {
notifications { createNotification(input: $input) { id title importance } }
}
""",
"archive": """
mutation ArchiveNotification($id: PrefixedID!) {
notifications { archiveNotification(id: $id) }
}
""",
"unread": """
mutation UnreadNotification($id: PrefixedID!) {
notifications { unreadNotification(id: $id) }
}
""",
"delete": """
mutation DeleteNotification($id: PrefixedID!, $type: NotificationType!) {
notifications { deleteNotification(id: $id, type: $type) }
}
""",
"delete_archived": """
mutation DeleteArchivedNotifications {
notifications { deleteArchivedNotifications }
}
""",
"archive_all": """
mutation ArchiveAllNotifications($importance: NotificationImportance) {
notifications { archiveAll(importance: $importance) }
}
""",
}
DESTRUCTIVE_ACTIONS = {"delete", "delete_archived"}
NOTIFICATION_ACTIONS = Literal[
"overview", "list", "warnings",
"create", "archive", "unread", "delete", "delete_archived", "archive_all",
]
def register_notifications_tool(mcp: FastMCP) -> None:
"""Register the unraid_notifications tool with the FastMCP instance."""
@mcp.tool()
async def unraid_notifications(
action: NOTIFICATION_ACTIONS,
confirm: bool = False,
notification_id: str | None = None,
notification_type: str | None = None,
importance: str | None = None,
offset: int = 0,
limit: int = 20,
list_type: str = "UNREAD",
title: str | None = None,
subject: str | None = None,
description: str | None = None,
) -> dict[str, Any]:
"""Manage Unraid system notifications.
Actions:
overview - Notification counts by severity (unread/archive)
list - List notifications with filtering (list_type=UNREAD/ARCHIVE, importance=INFO/WARNING/ALERT)
warnings - Get deduplicated unread warnings and alerts
create - Create notification (requires title, subject, description, importance)
archive - Archive a notification (requires notification_id)
unread - Mark notification as unread (requires notification_id)
delete - Delete a notification (requires notification_id, notification_type, confirm=True)
delete_archived - Delete all archived notifications (requires confirm=True)
archive_all - Archive all notifications (optional importance filter)
"""
all_actions = {**QUERIES, **MUTATIONS}
if action not in all_actions:
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.")
try:
logger.info(f"Executing unraid_notifications action={action}")
if action == "overview":
data = await make_graphql_request(QUERIES["overview"])
notifications = data.get("notifications", {})
return dict(notifications.get("overview", {}))
if action == "list":
filter_vars: dict[str, Any] = {
"type": list_type.upper(),
"offset": offset,
"limit": limit,
}
if importance:
filter_vars["importance"] = importance.upper()
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 []}
if action == "warnings":
data = await make_graphql_request(QUERIES["warnings"])
notifications = data.get("notifications", {})
result = notifications.get("warningsAndAlerts", [])
return {"warnings": list(result) if isinstance(result, list) else []}
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"
)
input_data = {
"title": title,
"subject": subject,
"description": description,
"importance": importance.upper() if importance else "INFO",
}
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}
)
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"
)
data = await make_graphql_request(
MUTATIONS["delete"],
{"id": notification_id, "type": notification_type.upper()},
)
return {"success": True, "action": "delete", "data": data}
if action == "delete_archived":
data = await make_graphql_request(MUTATIONS["delete_archived"])
return {"success": True, "action": "delete_archived", "data": data}
if action == "archive_all":
variables: dict[str, Any] | None = None
if importance:
variables = {"importance": importance.upper()}
data = await make_graphql_request(MUTATIONS["archive_all"], variables)
return {"success": True, "action": "archive_all", "data": data}
return {}
except ToolError:
raise
except Exception as e:
logger.error(f"Error in unraid_notifications action={action}: {e}", exc_info=True)
raise ToolError(f"Failed to execute notifications/{action}: {str(e)}") from e
logger.info("Notifications tool registered successfully")

View File

@@ -1,11 +1,10 @@
"""RClone cloud storage remote management tools.
"""RClone cloud storage remote management.
This module provides tools for managing RClone remotes including listing existing
remotes, getting configuration forms, creating new remotes, and deleting remotes
for various cloud storage providers (S3, Google Drive, Dropbox, FTP, etc.).
Provides the `unraid_rclone` tool with 4 actions for managing
cloud storage remotes (S3, Google Drive, Dropbox, FTP, etc.).
"""
from typing import Any
from typing import Any, Literal
from fastmcp import FastMCP
@@ -13,166 +12,121 @@ from ..config.logging import logger
from ..core.client import make_graphql_request
from ..core.exceptions import ToolError
QUERIES: dict[str, str] = {
"list_remotes": """
query ListRCloneRemotes {
rclone { remotes { name type parameters config } }
}
""",
"config_form": """
query GetRCloneConfigForm($formOptions: RCloneConfigFormInput) {
rclone { configForm(formOptions: $formOptions) { id dataSchema uiSchema } }
}
""",
}
def register_rclone_tools(mcp: FastMCP) -> None:
"""Register all RClone tools with the FastMCP instance.
MUTATIONS: dict[str, str] = {
"create_remote": """
mutation CreateRCloneRemote($input: CreateRCloneRemoteInput!) {
rclone { createRCloneRemote(input: $input) { name type parameters } }
}
""",
"delete_remote": """
mutation DeleteRCloneRemote($input: DeleteRCloneRemoteInput!) {
rclone { deleteRCloneRemote(input: $input) }
}
""",
}
Args:
mcp: FastMCP instance to register tools with
"""
DESTRUCTIVE_ACTIONS = {"delete_remote"}
RCLONE_ACTIONS = Literal[
"list_remotes", "config_form", "create_remote", "delete_remote",
]
def register_rclone_tool(mcp: FastMCP) -> None:
"""Register the unraid_rclone tool with the FastMCP instance."""
@mcp.tool()
async def list_rclone_remotes() -> list[dict[str, Any]]:
"""Retrieves all configured RClone remotes with their configuration details."""
async def unraid_rclone(
action: RCLONE_ACTIONS,
confirm: bool = False,
name: str | None = None,
provider_type: str | None = None,
config_data: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Manage RClone cloud storage remotes.
Actions:
list_remotes - List all configured remotes
config_form - Get config form schema (optional provider_type for specific provider)
create_remote - Create a new remote (requires name, provider_type, config_data)
delete_remote - Delete a remote (requires name, confirm=True)
"""
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)}")
if action in DESTRUCTIVE_ACTIONS and not confirm:
raise ToolError(f"Action '{action}' is destructive. Set confirm=True to proceed.")
try:
query = """
query ListRCloneRemotes {
rclone {
remotes {
name
type
parameters
config
}
}
}
"""
logger.info(f"Executing unraid_rclone action={action}")
response_data = await make_graphql_request(query)
if action == "list_remotes":
data = await make_graphql_request(QUERIES["list_remotes"])
remotes = data.get("rclone", {}).get("remotes", [])
return {"remotes": list(remotes) if isinstance(remotes, list) else []}
if "rclone" in response_data and "remotes" in response_data["rclone"]:
remotes = response_data["rclone"]["remotes"]
logger.info(f"Retrieved {len(remotes)} RClone remotes")
return list(remotes) if isinstance(remotes, list) else []
if action == "config_form":
variables: dict[str, Any] = {}
if provider_type:
variables["formOptions"] = {"providerType": provider_type}
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")
return dict(form)
return []
except Exception as e:
logger.error(f"Failed to list RClone remotes: {str(e)}")
raise ToolError(f"Failed to list RClone remotes: {str(e)}") from e
@mcp.tool()
async def get_rclone_config_form(provider_type: str | None = None) -> dict[str, Any]:
"""
Get RClone configuration form schema for setting up new remotes.
Args:
provider_type: Optional provider type to get specific form (e.g., 's3', 'drive', 'dropbox')
"""
try:
query = """
query GetRCloneConfigForm($formOptions: RCloneConfigFormInput) {
rclone {
configForm(formOptions: $formOptions) {
id
dataSchema
uiSchema
}
}
}
"""
variables = {}
if provider_type:
variables["formOptions"] = {"providerType": provider_type}
response_data = await make_graphql_request(query, variables)
if "rclone" in response_data and "configForm" in response_data["rclone"]:
form_data = response_data["rclone"]["configForm"]
logger.info(f"Retrieved RClone config form for {provider_type or 'general'}")
return dict(form_data) if isinstance(form_data, dict) else {}
raise ToolError("No RClone config form data received")
except Exception as e:
logger.error(f"Failed to get RClone config form: {str(e)}")
raise ToolError(f"Failed to get RClone config form: {str(e)}") from e
@mcp.tool()
async def create_rclone_remote(name: str, provider_type: str, config_data: dict[str, Any]) -> dict[str, Any]:
"""
Create a new RClone remote with the specified configuration.
Args:
name: Name for the new remote
provider_type: Type of provider (e.g., 's3', 'drive', 'dropbox', 'ftp')
config_data: Configuration parameters specific to the provider type
"""
try:
mutation = """
mutation CreateRCloneRemote($input: CreateRCloneRemoteInput!) {
rclone {
createRCloneRemote(input: $input) {
name
type
parameters
}
}
}
"""
variables = {
"input": {
"name": name,
"type": provider_type,
"config": config_data
}
}
response_data = await make_graphql_request(mutation, variables)
if "rclone" in response_data and "createRCloneRemote" in response_data["rclone"]:
remote_info = response_data["rclone"]["createRCloneRemote"]
logger.info(f"Successfully created RClone remote: {name}")
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"
)
data = await make_graphql_request(
MUTATIONS["create_remote"],
{"input": {"name": name, "type": provider_type, "config": config_data}},
)
remote = data.get("rclone", {}).get("createRCloneRemote", {})
return {
"success": True,
"message": f"RClone remote '{name}' created successfully",
"remote": remote_info
"message": f"Remote '{name}' created successfully",
"remote": remote,
}
raise ToolError("Failed to create RClone remote")
except Exception as e:
logger.error(f"Failed to create RClone remote {name}: {str(e)}")
raise ToolError(f"Failed to create RClone remote {name}: {str(e)}") from e
@mcp.tool()
async def delete_rclone_remote(name: str) -> dict[str, Any]:
"""
Delete an existing RClone remote by name.
Args:
name: Name of the remote to delete
"""
try:
mutation = """
mutation DeleteRCloneRemote($input: DeleteRCloneRemoteInput!) {
rclone {
deleteRCloneRemote(input: $input)
}
}
"""
variables = {
"input": {
"name": name
}
}
response_data = await make_graphql_request(mutation, variables)
if "rclone" in response_data and response_data["rclone"]["deleteRCloneRemote"]:
logger.info(f"Successfully deleted RClone remote: {name}")
if action == "delete_remote":
if not name:
raise ToolError("name is required for 'delete_remote' action")
data = await make_graphql_request(
MUTATIONS["delete_remote"], {"input": {"name": name}}
)
success = data.get("rclone", {}).get("deleteRCloneRemote", False)
if not success:
raise ToolError(f"Failed to delete remote '{name}'")
return {
"success": True,
"message": f"RClone remote '{name}' deleted successfully"
"message": f"Remote '{name}' deleted successfully",
}
raise ToolError(f"Failed to delete RClone remote '{name}'")
return {}
except ToolError:
raise
except Exception as e:
logger.error(f"Failed to delete RClone remote {name}: {str(e)}")
raise ToolError(f"Failed to delete RClone remote {name}: {str(e)}") from e
logger.error(f"Error in unraid_rclone action={action}: {e}", exc_info=True)
raise ToolError(f"Failed to execute rclone/{action}: {str(e)}") from e
logger.info("RClone tools registered successfully")
logger.info("RClone tool registered successfully")

View File

@@ -1,277 +1,159 @@
"""Storage, disk, and notification management tools.
"""Storage and disk management.
This module provides tools for managing user shares, notifications,
log files, physical disks with SMART data, and system storage operations
with custom timeout configurations for disk-intensive operations.
Provides the `unraid_storage` tool with 6 actions for shares, physical disks,
unassigned devices, log files, and log content retrieval.
"""
from typing import Any
from typing import Any, Literal
import httpx
from fastmcp import FastMCP
from ..config.logging import logger
from ..core.client import make_graphql_request
from ..core.client import DISK_TIMEOUT, make_graphql_request
from ..core.exceptions import ToolError
def register_storage_tools(mcp: FastMCP) -> None:
"""Register all storage tools with the FastMCP instance.
Args:
mcp: FastMCP instance to register tools with
"""
@mcp.tool()
async def get_shares_info() -> list[dict[str, Any]]:
"""Retrieves information about user shares."""
query = """
QUERIES: dict[str, str] = {
"shares": """
query GetSharesInfo {
shares {
id
name
free
used
size
include
exclude
cache
nameOrig
comment
allocator
splitLevel
floor
cow
color
luksStatus
id name free used size include exclude cache nameOrig
comment allocator splitLevel floor cow color luksStatus
}
}
"""
try:
logger.info("Executing get_shares_info tool")
response_data = await make_graphql_request(query)
shares = response_data.get("shares", [])
return list(shares) if isinstance(shares, list) else []
except Exception as e:
logger.error(f"Error in get_shares_info: {e}", exc_info=True)
raise ToolError(f"Failed to retrieve shares information: {str(e)}") from e
@mcp.tool()
async def get_notifications_overview() -> dict[str, Any]:
"""Retrieves an overview of system notifications (unread and archive counts by severity)."""
query = """
query GetNotificationsOverview {
notifications {
overview {
unread { info warning alert total }
archive { info warning alert total }
}
}
""",
"disks": """
query ListPhysicalDisks {
disks { id device name }
}
"""
try:
logger.info("Executing get_notifications_overview tool")
response_data = await make_graphql_request(query)
if response_data.get("notifications"):
overview = response_data["notifications"].get("overview", {})
return dict(overview) if isinstance(overview, dict) else {}
return {}
except Exception as e:
logger.error(f"Error in get_notifications_overview: {e}", exc_info=True)
raise ToolError(f"Failed to retrieve notifications overview: {str(e)}") from e
@mcp.tool()
async def list_notifications(
type: str,
offset: int,
limit: int,
importance: str | None = None
) -> list[dict[str, Any]]:
"""Lists notifications with filtering. Type: UNREAD/ARCHIVE. Importance: INFO/WARNING/ALERT."""
query = """
query ListNotifications($filter: NotificationFilter!) {
notifications {
list(filter: $filter) {
id
title
subject
description
importance
link
type
timestamp
formattedTimestamp
}
}
}
"""
variables = {
"filter": {
"type": type.upper(),
"offset": offset,
"limit": limit,
"importance": importance.upper() if importance else None
}
}
# Remove null importance from variables if not provided, as GraphQL might be strict
if not importance:
del variables["filter"]["importance"]
try:
logger.info(f"Executing list_notifications: type={type}, offset={offset}, limit={limit}, importance={importance}")
response_data = await make_graphql_request(query, variables)
if response_data.get("notifications"):
notifications_list = response_data["notifications"].get("list", [])
return list(notifications_list) if isinstance(notifications_list, list) else []
return []
except Exception as e:
logger.error(f"Error in list_notifications: {e}", exc_info=True)
raise ToolError(f"Failed to list notifications: {str(e)}") from e
@mcp.tool()
async def list_available_log_files() -> list[dict[str, Any]]:
"""Lists all available log files that can be queried."""
query = """
query ListLogFiles {
logFiles {
name
path
size
modifiedAt
}
}
"""
try:
logger.info("Executing list_available_log_files tool")
response_data = await make_graphql_request(query)
log_files = response_data.get("logFiles", [])
return list(log_files) if isinstance(log_files, list) else []
except Exception as e:
logger.error(f"Error in list_available_log_files: {e}", exc_info=True)
raise ToolError(f"Failed to list available log files: {str(e)}") from e
@mcp.tool()
async def get_logs(log_file_path: str, tail_lines: int = 100) -> dict[str, Any]:
"""Retrieves content from a specific log file, defaulting to the last 100 lines."""
# The Unraid GraphQL API Query.logFile takes 'lines' and 'startLine'.
# To implement 'tail_lines', we would ideally need to know the total lines first,
# then calculate startLine. However, Query.logFile itself returns totalLines.
# A simple approach for 'tail' is to request a large number of lines if totalLines is not known beforehand,
# and let the API handle it, or make two calls (one to get totalLines, then another).
# For now, let's assume 'lines' parameter in Query.logFile effectively means tail if startLine is not given.
# If not, this tool might need to be smarter or the API might not directly support 'tail' easily.
# The SDL for LogFileContent implies it returns startLine, so it seems aware of ranges.
# Let's try fetching with just 'lines' to see if it acts as a tail,
# or if we need Query.logFiles first to get totalLines for calculation.
# For robust tailing, one might need to fetch totalLines first, then calculate start_line for the tail.
# Simplified: query for the last 'tail_lines'. If the API doesn't support tailing this way, we may need adjustment.
# The current plan is to pass 'lines=tail_lines' directly.
query = """
query GetLogContent($path: String!, $lines: Int) {
logFile(path: $path, lines: $lines) {
path
content
totalLines
startLine
}
}
"""
variables = {"path": log_file_path, "lines": tail_lines}
try:
logger.info(f"Executing get_logs for {log_file_path}, tail_lines={tail_lines}")
response_data = await make_graphql_request(query, variables)
log_file = response_data.get("logFile", {})
return dict(log_file) if isinstance(log_file, dict) else {}
except Exception as e:
logger.error(f"Error in get_logs for {log_file_path}: {e}", exc_info=True)
raise ToolError(f"Failed to retrieve logs from {log_file_path}: {str(e)}") from e
@mcp.tool()
async def list_physical_disks() -> list[dict[str, Any]]:
"""Lists all physical disks recognized by the Unraid system."""
# Querying an extremely minimal set of fields for diagnostics
query = """
query ListPhysicalDisksMinimal {
disks {
id
device
name
}
}
"""
try:
logger.info("Executing list_physical_disks tool with minimal query and increased timeout")
# Increased read timeout for this potentially slow query
long_timeout = httpx.Timeout(10.0, read=90.0, connect=5.0)
response_data = await make_graphql_request(query, custom_timeout=long_timeout)
disks = response_data.get("disks", [])
return list(disks) if isinstance(disks, list) else []
except Exception as e:
logger.error(f"Error in list_physical_disks: {e}", exc_info=True)
raise ToolError(f"Failed to list physical disks: {str(e)}") from e
@mcp.tool()
async def get_disk_details(disk_id: str) -> dict[str, Any]:
"""Retrieves detailed SMART information and partition data for a specific physical disk."""
# Enhanced query with more comprehensive disk information
query = """
""",
"disk_details": """
query GetDiskDetails($id: PrefixedID!) {
disk(id: $id) {
id
device
name
serialNum
size
temperature
id device name serialNum size temperature
}
}
""",
"unassigned": """
query GetUnassignedDevices {
unassignedDevices { id device name size type }
}
""",
"log_files": """
query ListLogFiles {
logFiles { name path size modifiedAt }
}
""",
"logs": """
query GetLogContent($path: String!, $lines: Int) {
logFile(path: $path, lines: $lines) {
path content totalLines startLine
}
}
""",
}
STORAGE_ACTIONS = Literal[
"shares", "disks", "disk_details", "unassigned", "log_files", "logs",
]
def format_bytes(bytes_value: int | None) -> str:
"""Format byte values into human-readable sizes."""
if bytes_value is None:
return "N/A"
value = float(int(bytes_value))
for unit in ["B", "KB", "MB", "GB", "TB", "PB"]:
if value < 1024.0:
return f"{value:.2f} {unit}"
value /= 1024.0
return f"{value:.2f} EB"
def register_storage_tool(mcp: FastMCP) -> None:
"""Register the unraid_storage tool with the FastMCP instance."""
@mcp.tool()
async def unraid_storage(
action: STORAGE_ACTIONS,
disk_id: str | None = None,
log_path: str | None = None,
tail_lines: int = 100,
) -> dict[str, Any]:
"""Manage Unraid storage, disks, and logs.
Actions:
shares - List all user shares with capacity info
disks - List all physical disks
disk_details - Detailed SMART info for a disk (requires disk_id)
unassigned - List unassigned devices
log_files - List available log files
logs - Retrieve log content (requires log_path, optional tail_lines)
"""
variables = {"id": disk_id}
if action not in QUERIES:
raise ToolError(f"Invalid action '{action}'. Must be one of: {list(QUERIES.keys())}")
if action == "disk_details" and not disk_id:
raise ToolError("disk_id is required for 'disk_details' action")
if action == "logs" and not log_path:
raise ToolError("log_path is required for 'logs' action")
query = QUERIES[action]
variables: dict[str, Any] | None = None
custom_timeout = DISK_TIMEOUT if action == "disks" else None
if action == "disk_details":
variables = {"id": disk_id}
elif action == "logs":
variables = {"path": log_path, "lines": tail_lines}
try:
logger.info(f"Executing get_disk_details for disk: {disk_id}")
response_data = await make_graphql_request(query, variables)
raw_disk = response_data.get("disk", {})
logger.info(f"Executing unraid_storage action={action}")
data = await make_graphql_request(query, variables, custom_timeout=custom_timeout)
if not raw_disk:
raise ToolError(f"Disk '{disk_id}' not found")
if action == "shares":
shares = data.get("shares", [])
return {"shares": list(shares) if isinstance(shares, list) else []}
# Process disk information for human-readable output
def format_bytes(bytes_value: int | None) -> str:
if bytes_value is None:
return "N/A"
value = float(int(bytes_value))
for unit in ['B', 'KB', 'MB', 'GB', 'TB', 'PB']:
if value < 1024.0:
return f"{value:.2f} {unit}"
value /= 1024.0
return f"{value:.2f} EB"
if action == "disks":
disks = data.get("disks", [])
return {"disks": list(disks) if isinstance(disks, list) else []}
summary = {
'disk_id': raw_disk.get('id'),
'device': raw_disk.get('device'),
'name': raw_disk.get('name'),
'serial_number': raw_disk.get('serialNum'),
'size_formatted': format_bytes(raw_disk.get('size')),
'temperature': f"{raw_disk.get('temperature')}°C" if raw_disk.get('temperature') else 'N/A',
'interface_type': raw_disk.get('interfaceType'),
'smart_status': raw_disk.get('smartStatus'),
'is_spinning': raw_disk.get('isSpinning'),
'power_on_hours': raw_disk.get('powerOnHours'),
'reallocated_sectors': raw_disk.get('reallocatedSectorCount'),
'partition_count': len(raw_disk.get('partitions', [])),
'total_partition_size': format_bytes(sum(p.get('size', 0) for p in raw_disk.get('partitions', []) if p.get('size')))
}
if action == "disk_details":
raw = data.get("disk", {})
if not raw:
raise ToolError(f"Disk '{disk_id}' not found")
summary = {
"disk_id": raw.get("id"),
"device": raw.get("device"),
"name": raw.get("name"),
"serial_number": raw.get("serialNum"),
"size_formatted": format_bytes(raw.get("size")),
"temperature": (
f"{raw.get('temperature')}C"
if raw.get("temperature")
else "N/A"
),
}
return {"summary": summary, "details": raw}
return {
'summary': summary,
'partitions': raw_disk.get('partitions', []),
'details': raw_disk
}
if action == "unassigned":
devices = data.get("unassignedDevices", [])
return {"devices": list(devices) if isinstance(devices, list) else []}
if action == "log_files":
files = data.get("logFiles", [])
return {"log_files": list(files) if isinstance(files, list) else []}
if action == "logs":
return dict(data.get("logFile", {}))
return data
except ToolError:
raise
except Exception as e:
logger.error(f"Error in get_disk_details for {disk_id}: {e}", exc_info=True)
raise ToolError(f"Failed to retrieve disk details for {disk_id}: {str(e)}") from e
logger.error(f"Error in unraid_storage action={action}: {e}", exc_info=True)
raise ToolError(f"Failed to execute storage/{action}: {str(e)}") from e
logger.info("Storage tools registered successfully")
logger.info("Storage tool registered successfully")

View File

@@ -1,392 +0,0 @@
"""System information and array status tools.
This module provides tools for retrieving core Unraid system information,
array status with health analysis, network configuration, registration info,
and system variables.
"""
from typing import Any
from fastmcp import FastMCP
from ..config.logging import logger
from ..core.client import make_graphql_request
from ..core.exceptions import ToolError
# Standalone functions for use by subscription resources
async def _get_system_info() -> dict[str, Any]:
"""Standalone function to get system info - used by subscriptions and tools."""
query = """
query GetSystemInfo {
info {
os { platform distro release codename kernel arch hostname codepage logofile serial build uptime }
cpu { manufacturer brand vendor family model stepping revision voltage speed speedmin speedmax threads cores processors socket cache flags }
memory {
# Avoid fetching problematic fields that cause type errors
layout { bank type clockSpeed formFactor manufacturer partNum serialNum }
}
baseboard { manufacturer model version serial assetTag }
system { manufacturer model version serial uuid sku }
versions { kernel openssl systemOpenssl systemOpensslLib node v8 npm yarn pm2 gulp grunt git tsc mysql redis mongodb apache nginx php docker postfix postgresql perl python gcc unraid }
apps { installed started }
# Remove devices section as it has non-nullable fields that might be null
machineId
time
}
}
"""
try:
logger.info("Executing get_system_info")
response_data = await make_graphql_request(query)
raw_info = response_data.get("info", {})
if not raw_info:
raise ToolError("No system info returned from Unraid API")
# Process for human-readable output
summary: dict[str, Any] = {}
if raw_info.get('os'):
os_info = raw_info['os']
summary['os'] = f"{os_info.get('distro', '')} {os_info.get('release', '')} ({os_info.get('platform', '')}, {os_info.get('arch', '')})"
summary['hostname'] = os_info.get('hostname')
summary['uptime'] = os_info.get('uptime')
if raw_info.get('cpu'):
cpu_info = raw_info['cpu']
summary['cpu'] = f"{cpu_info.get('manufacturer', '')} {cpu_info.get('brand', '')} ({cpu_info.get('cores')} cores, {cpu_info.get('threads')} threads)"
if raw_info.get('memory') and raw_info['memory'].get('layout'):
mem_layout = raw_info['memory']['layout']
summary['memory_layout_details'] = [] # Renamed for clarity
# The API is not returning 'size' for individual sticks in the layout, even if queried.
# So, we cannot calculate total from layout currently.
for stick in mem_layout:
# stick_size = stick.get('size') # This is None in the actual API response
summary['memory_layout_details'].append(
f"Bank {stick.get('bank', '?')}: Type {stick.get('type', '?')}, Speed {stick.get('clockSpeed', '?')}MHz, Manufacturer: {stick.get('manufacturer','?')}, Part: {stick.get('partNum', '?')}"
)
summary['memory_summary'] = "Stick layout details retrieved. Overall total/used/free memory stats are unavailable due to API limitations (Int overflow or data not provided by API)."
else:
summary['memory_summary'] = "Memory information (layout or stats) not available or failed to retrieve."
# Include a key for the full details if needed by an LLM for deeper dives
return {"summary": summary, "details": raw_info}
except Exception as e:
logger.error(f"Error in get_system_info: {e}", exc_info=True)
raise ToolError(f"Failed to retrieve system information: {str(e)}") from e
async def _get_array_status() -> dict[str, Any]:
"""Standalone function to get array status - used by subscriptions and tools."""
query = """
query GetArrayStatus {
array {
id
state
capacity {
kilobytes { free used total }
disks { free used total }
}
boot { id idx name device size status rotational temp numReads numWrites numErrors fsSize fsFree fsUsed exportable type warning critical fsType comment format transport color }
parities { id idx name device size status rotational temp numReads numWrites numErrors fsSize fsFree fsUsed exportable type warning critical fsType comment format transport color }
disks { id idx name device size status rotational temp numReads numWrites numErrors fsSize fsFree fsUsed exportable type warning critical fsType comment format transport color }
caches { id idx name device size status rotational temp numReads numWrites numErrors fsSize fsFree fsUsed exportable type warning critical fsType comment format transport color }
}
}
"""
try:
logger.info("Executing get_array_status")
response_data = await make_graphql_request(query)
raw_array_info = response_data.get("array", {})
if not raw_array_info:
raise ToolError("No array information returned from Unraid API")
summary: dict[str, Any] = {}
summary['state'] = raw_array_info.get('state')
if raw_array_info.get('capacity') and raw_array_info['capacity'].get('kilobytes'):
kb_cap = raw_array_info['capacity']['kilobytes']
# Helper to format KB into TB/GB/MB
def format_kb(k: Any) -> str:
if k is None:
return "N/A"
k = int(k) # Values are strings in SDL for PrefixedID containing types like capacity
if k >= 1024*1024*1024:
return f"{k / (1024*1024*1024):.2f} TB"
if k >= 1024*1024:
return f"{k / (1024*1024):.2f} GB"
if k >= 1024:
return f"{k / 1024:.2f} MB"
return f"{k} KB"
summary['capacity_total'] = format_kb(kb_cap.get('total'))
summary['capacity_used'] = format_kb(kb_cap.get('used'))
summary['capacity_free'] = format_kb(kb_cap.get('free'))
summary['num_parity_disks'] = len(raw_array_info.get('parities', []))
summary['num_data_disks'] = len(raw_array_info.get('disks', []))
summary['num_cache_pools'] = len(raw_array_info.get('caches', [])) # Note: caches are pools, not individual cache disks
# Enhanced: Add disk health summary
def analyze_disk_health(disks: list[dict[str, Any]], disk_type: str) -> dict[str, int]:
"""Analyze health status of disk arrays"""
if not disks:
return {}
health_counts = {
'healthy': 0,
'failed': 0,
'missing': 0,
'new': 0,
'warning': 0,
'unknown': 0
}
for disk in disks:
status = disk.get('status', '').upper()
warning = disk.get('warning')
critical = disk.get('critical')
if status == 'DISK_OK':
if warning or critical:
health_counts['warning'] += 1
else:
health_counts['healthy'] += 1
elif status in ['DISK_DSBL', 'DISK_INVALID']:
health_counts['failed'] += 1
elif status == 'DISK_NP':
health_counts['missing'] += 1
elif status == 'DISK_NEW':
health_counts['new'] += 1
else:
health_counts['unknown'] += 1
return health_counts
# Analyze health for each disk type
health_summary = {}
if raw_array_info.get('parities'):
health_summary['parity_health'] = analyze_disk_health(raw_array_info['parities'], 'parity')
if raw_array_info.get('disks'):
health_summary['data_health'] = analyze_disk_health(raw_array_info['disks'], 'data')
if raw_array_info.get('caches'):
health_summary['cache_health'] = analyze_disk_health(raw_array_info['caches'], 'cache')
# Overall array health assessment
total_failed = sum(h.get('failed', 0) for h in health_summary.values())
total_missing = sum(h.get('missing', 0) for h in health_summary.values())
total_warning = sum(h.get('warning', 0) for h in health_summary.values())
if total_failed > 0:
overall_health = "CRITICAL"
elif total_missing > 0:
overall_health = "DEGRADED"
elif total_warning > 0:
overall_health = "WARNING"
else:
overall_health = "HEALTHY"
summary['overall_health'] = overall_health
summary['health_summary'] = health_summary
return {"summary": summary, "details": raw_array_info}
except Exception as e:
logger.error(f"Error in get_array_status: {e}", exc_info=True)
raise ToolError(f"Failed to retrieve array status: {str(e)}") from e
def register_system_tools(mcp: FastMCP) -> None:
"""Register all system tools with the FastMCP instance.
Args:
mcp: FastMCP instance to register tools with
"""
@mcp.tool()
async def get_system_info() -> dict[str, Any]:
"""Retrieves comprehensive information about the Unraid system, OS, CPU, memory, and baseboard."""
return await _get_system_info()
@mcp.tool()
async def get_array_status() -> dict[str, Any]:
"""Retrieves the current status of the Unraid storage array, including its state, capacity, and details of all disks."""
return await _get_array_status()
@mcp.tool()
async def get_network_config() -> dict[str, Any]:
"""Retrieves network configuration details, including access URLs."""
query = """
query GetNetworkConfig {
network {
id
accessUrls { type name ipv4 ipv6 }
}
}
"""
try:
logger.info("Executing get_network_config tool")
response_data = await make_graphql_request(query)
network = response_data.get("network", {})
return dict(network) if isinstance(network, dict) else {}
except Exception as e:
logger.error(f"Error in get_network_config: {e}", exc_info=True)
raise ToolError(f"Failed to retrieve network configuration: {str(e)}") from e
@mcp.tool()
async def get_registration_info() -> dict[str, Any]:
"""Retrieves Unraid registration details."""
query = """
query GetRegistrationInfo {
registration {
id
type
keyFile { location contents }
state
expiration
updateExpiration
}
}
"""
try:
logger.info("Executing get_registration_info tool")
response_data = await make_graphql_request(query)
registration = response_data.get("registration", {})
return dict(registration) if isinstance(registration, dict) else {}
except Exception as e:
logger.error(f"Error in get_registration_info: {e}", exc_info=True)
raise ToolError(f"Failed to retrieve registration information: {str(e)}") from e
@mcp.tool()
async def get_connect_settings() -> dict[str, Any]:
"""Retrieves settings related to Unraid Connect."""
# Based on actual schema: settings.unified.values contains the JSON settings
query = """
query GetConnectSettingsForm {
settings {
unified {
values
}
}
}
"""
try:
logger.info("Executing get_connect_settings tool")
response_data = await make_graphql_request(query)
# Navigate down to the unified settings values
if response_data.get("settings") and response_data["settings"].get("unified"):
values = response_data["settings"]["unified"].get("values", {})
# Filter for Connect-related settings if values is a dict
if isinstance(values, dict):
# Look for connect-related keys in the unified settings
connect_settings = {}
for key, value in values.items():
if 'connect' in key.lower() or key in ['accessType', 'forwardType', 'port']:
connect_settings[key] = value
return connect_settings if connect_settings else values
return dict(values) if isinstance(values, dict) else {}
return {}
except Exception as e:
logger.error(f"Error in get_connect_settings: {e}", exc_info=True)
raise ToolError(f"Failed to retrieve Unraid Connect settings: {str(e)}") from e
@mcp.tool()
async def get_unraid_variables() -> dict[str, Any]:
"""Retrieves a selection of Unraid system variables and settings.
Note: Many variables are omitted due to API type issues (Int overflow/NaN).
"""
# Querying a smaller, curated set of fields to avoid Int overflow and NaN issues
# pending Unraid API schema fixes for the full Vars type.
query = """
query GetSelectiveUnraidVariables {
vars {
id
version
name
timeZone
comment
security
workgroup
domain
domainShort
hideDotFiles
localMaster
enableFruit
useNtp
# ntpServer1, ntpServer2, ... are strings, should be okay but numerous
domainLogin # Boolean
sysModel # String
# sysArraySlots, sysCacheSlots are Int, were problematic (NaN)
sysFlashSlots # Int, might be okay if small and always set
useSsl # Boolean
port # Int, usually small
portssl # Int, usually small
localTld # String
bindMgt # Boolean
useTelnet # Boolean
porttelnet # Int, usually small
useSsh # Boolean
portssh # Int, usually small
startPage # String
startArray # Boolean
# spindownDelay, queueDepth are Int, potentially okay if always set
# defaultFormat, defaultFsType are String
shutdownTimeout # Int, potentially okay
# luksKeyfile is String
# pollAttributes, pollAttributesDefault, pollAttributesStatus are Int/String, were problematic (NaN or type)
# nrRequests, nrRequestsDefault, nrRequestsStatus were problematic
# mdNumStripes, mdNumStripesDefault, mdNumStripesStatus were problematic
# mdSyncWindow, mdSyncWindowDefault, mdSyncWindowStatus were problematic
# mdSyncThresh, mdSyncThreshDefault, mdSyncThreshStatus were problematic
# mdWriteMethod, mdWriteMethodDefault, mdWriteMethodStatus were problematic
# shareDisk, shareUser, shareUserInclude, shareUserExclude are String arrays/String
shareSmbEnabled # Boolean
shareNfsEnabled # Boolean
shareAfpEnabled # Boolean
# shareInitialOwner, shareInitialGroup are String
shareCacheEnabled # Boolean
# shareCacheFloor is String (numeric string?)
# shareMoverSchedule, shareMoverLogging are String
# fuseRemember, fuseRememberDefault, fuseRememberStatus are String/Boolean, were problematic
# fuseDirectio, fuseDirectioDefault, fuseDirectioStatus are String/Boolean, were problematic
shareAvahiEnabled # Boolean
# shareAvahiSmbName, shareAvahiSmbModel, shareAvahiAfpName, shareAvahiAfpModel are String
safeMode # Boolean
startMode # String
configValid # Boolean
configError # String
joinStatus # String
deviceCount # Int, might be okay
flashGuid # String
flashProduct # String
flashVendor # String
# regCheck, regFile, regGuid, regTy, regState, regTo, regTm, regTm2, regGen are varied, mostly String/Int
# sbName, sbVersion, sbUpdated, sbEvents, sbState, sbClean, sbSynced, sbSyncErrs, sbSynced2, sbSyncExit are varied
# mdColor, mdNumDisks, mdNumDisabled, mdNumInvalid, mdNumMissing, mdNumNew, mdNumErased are Int, potentially okay if counts
# mdResync, mdResyncCorr, mdResyncPos, mdResyncDb, mdResyncDt, mdResyncAction are varied (Int/Boolean/String)
# mdResyncSize was an overflow
mdState # String (enum)
mdVersion # String
# cacheNumDevices, cacheSbNumDisks were problematic (NaN)
# fsState, fsProgress, fsCopyPrcnt, fsNumMounted, fsNumUnmountable, fsUnmountableMask are varied
shareCount # Int, might be okay
shareSmbCount # Int, might be okay
shareNfsCount # Int, might be okay
shareAfpCount # Int, might be okay
shareMoverActive # Boolean
csrfToken # String
}
}
"""
try:
logger.info("Executing get_unraid_variables tool with a selective query")
response_data = await make_graphql_request(query)
vars_data = response_data.get("vars", {})
return dict(vars_data) if isinstance(vars_data, dict) else {}
except Exception as e:
logger.error(f"Error in get_unraid_variables: {e}", exc_info=True)
raise ToolError(f"Failed to retrieve Unraid variables: {str(e)}") from e
logger.info("System tools registered successfully")

163
unraid_mcp/tools/users.py Normal file
View File

@@ -0,0 +1,163 @@
"""User management.
Provides the `unraid_users` tool with 8 actions for managing users,
cloud access, remote access settings, and allowed origins.
"""
from typing import Any, Literal
from fastmcp import FastMCP
from ..config.logging import logger
from ..core.client import make_graphql_request
from ..core.exceptions import ToolError
QUERIES: dict[str, str] = {
"me": """
query GetMe {
me { id name role email }
}
""",
"list": """
query ListUsers {
users { id name role email }
}
""",
"get": """
query GetUser($id: PrefixedID!) {
user(id: $id) { id name role email }
}
""",
"cloud": """
query GetCloud {
cloud { status apiKey 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 role }
}
""",
"delete": """
mutation DeleteUser($id: PrefixedID!) {
deleteUser(id: $id)
}
""",
}
DESTRUCTIVE_ACTIONS = {"delete"}
USER_ACTIONS = Literal[
"me", "list", "get", "add", "delete", "cloud", "remote_access", "origins",
]
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.
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)}")
if action in DESTRUCTIVE_ACTIONS and not confirm:
raise ToolError(f"Action '{action}' is destructive. Set confirm=True to proceed.")
try:
logger.info(f"Executing unraid_users action={action}")
if action == "me":
data = await make_graphql_request(QUERIES["me"])
return dict(data.get("me", {}))
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 dict(data.get("user", {}))
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"], {"id": user_id}
)
return {
"success": True,
"message": f"User '{user_id}' deleted",
}
if action == "cloud":
data = await make_graphql_request(QUERIES["cloud"])
return dict(data.get("cloud", {}))
if action == "remote_access":
data = await make_graphql_request(QUERIES["remote_access"])
return dict(data.get("remoteAccess", {}))
if action == "origins":
data = await make_graphql_request(QUERIES["origins"])
origins = data.get("allowedOrigins", [])
return {"origins": list(origins) if isinstance(origins, list) else []}
return {}
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}: {str(e)}") from e
logger.info("Users tool registered successfully")

View File

@@ -1,11 +1,10 @@
"""Virtual machine management tools.
"""Virtual machine management.
This module provides tools for VM lifecycle management and monitoring
including listing VMs, VM operations (start/stop/pause/reboot/etc),
and detailed VM information retrieval.
Provides the `unraid_vm` tool with 9 actions for VM lifecycle management
including start, stop, pause, resume, force stop, reboot, and reset.
"""
from typing import Any
from typing import Any, Literal
from fastmcp import FastMCP
@@ -13,150 +12,148 @@ from ..config.logging import logger
from ..core.client import make_graphql_request
from ..core.exceptions import ToolError
def register_vm_tools(mcp: FastMCP) -> None:
"""Register all VM tools with the FastMCP instance.
Args:
mcp: FastMCP instance to register tools with
"""
@mcp.tool()
async def list_vms() -> list[dict[str, Any]]:
"""Lists all Virtual Machines (VMs) on the Unraid system and their current state.
Returns:
List of VM information dictionaries with UUID, name, and state
"""
query = """
QUERIES: dict[str, str] = {
"list": """
query ListVMs {
vms {
id
domains {
id
name
state
uuid
}
}
vms { id domains { id name state uuid } }
}
"""
try:
logger.info("Executing list_vms tool")
response_data = await make_graphql_request(query)
logger.info(f"VM query response: {response_data}")
if response_data.get("vms") and response_data["vms"].get("domains"):
vms = response_data["vms"]["domains"]
logger.info(f"Found {len(vms)} VMs")
return list(vms) if isinstance(vms, list) else []
else:
logger.info("No VMs found in domains field")
return []
except Exception as e:
logger.error(f"Error in list_vms: {e}", exc_info=True)
error_msg = str(e)
if "VMs are not available" in error_msg:
raise ToolError("VMs are not available on this Unraid server. This could mean: 1) VM support is not enabled, 2) VM service is not running, or 3) no VMs are configured. Check Unraid VM settings.") from e
else:
raise ToolError(f"Failed to list virtual machines: {error_msg}") from e
@mcp.tool()
async def manage_vm(vm_uuid: str, action: str) -> dict[str, Any]:
"""Manages a VM: start, stop, pause, resume, force_stop, reboot, reset. Uses VM UUID.
Args:
vm_uuid: UUID of the VM to manage
action: Action to perform - one of: start, stop, pause, resume, forceStop, reboot, reset
Returns:
Dict containing operation success status and details
"""
valid_actions = ["start", "stop", "pause", "resume", "forceStop", "reboot", "reset"] # Added reset operation
if action not in valid_actions:
logger.warning(f"Invalid action '{action}' for manage_vm")
raise ToolError(f"Invalid action. Must be one of {valid_actions}.")
mutation_name = action
query = f"""
mutation ManageVM($id: PrefixedID!) {{
vm {{
{mutation_name}(id: $id)
}}
}}
"""
variables = {"id": vm_uuid}
try:
logger.info(f"Executing manage_vm tool: action={action}, uuid={vm_uuid}")
response_data = await make_graphql_request(query, variables)
if response_data.get("vm") and mutation_name in response_data["vm"]:
# Mutations for VM return Boolean for success
success = response_data["vm"][mutation_name]
return {"success": success, "action": action, "vm_uuid": vm_uuid}
raise ToolError(f"Failed to {action} VM or unexpected response structure.")
except Exception as e:
logger.error(f"Error in manage_vm ({action}): {e}", exc_info=True)
raise ToolError(f"Failed to {action} virtual machine: {str(e)}") from e
@mcp.tool()
async def get_vm_details(vm_identifier: str) -> dict[str, Any]:
"""Retrieves detailed information for a specific VM by its UUID or name.
Args:
vm_identifier: VM UUID or name to retrieve details for
Returns:
Dict containing detailed VM information
"""
# Make direct GraphQL call instead of calling list_vms() tool
query = """
""",
"details": """
query GetVmDetails {
vms {
domains {
id
name
state
uuid
}
domain {
id
name
state
uuid
}
}
vms { domains { id name state uuid } }
}
""",
}
MUTATIONS: dict[str, str] = {
"start": """
mutation StartVM($id: PrefixedID!) { vm { start(id: $id) } }
""",
"stop": """
mutation StopVM($id: PrefixedID!) { vm { stop(id: $id) } }
""",
"pause": """
mutation PauseVM($id: PrefixedID!) { vm { pause(id: $id) } }
""",
"resume": """
mutation ResumeVM($id: PrefixedID!) { vm { resume(id: $id) } }
""",
"force_stop": """
mutation ForceStopVM($id: PrefixedID!) { vm { forceStop(id: $id) } }
""",
"reboot": """
mutation RebootVM($id: PrefixedID!) { vm { reboot(id: $id) } }
""",
"reset": """
mutation ResetVM($id: PrefixedID!) { vm { reset(id: $id) } }
""",
}
# Map action names to their GraphQL field names
_MUTATION_FIELDS: dict[str, str] = {
"start": "start",
"stop": "stop",
"pause": "pause",
"resume": "resume",
"force_stop": "forceStop",
"reboot": "reboot",
"reset": "reset",
}
DESTRUCTIVE_ACTIONS = {"force_stop", "reset"}
VM_ACTIONS = Literal[
"list", "details",
"start", "stop", "pause", "resume", "force_stop", "reboot", "reset",
]
def register_vm_tool(mcp: FastMCP) -> None:
"""Register the unraid_vm tool with the FastMCP instance."""
@mcp.tool()
async def unraid_vm(
action: VM_ACTIONS,
vm_id: str | None = None,
confirm: bool = False,
) -> dict[str, Any]:
"""Manage Unraid virtual machines.
Actions:
list - List all VMs with state
details - Detailed info for a VM (requires vm_id: UUID, PrefixedID, or name)
start - Start a VM (requires vm_id)
stop - Gracefully stop a VM (requires vm_id)
pause - Pause a VM (requires vm_id)
resume - Resume a paused VM (requires vm_id)
force_stop - Force stop a VM (requires vm_id, confirm=True)
reboot - Reboot a VM (requires vm_id)
reset - Reset a VM (requires vm_id, confirm=True)
"""
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)}")
if action in DESTRUCTIVE_ACTIONS and not confirm:
raise ToolError(f"Action '{action}' is destructive. Set confirm=True to proceed.")
if action != "list" and not vm_id:
raise ToolError(f"vm_id is required for '{action}' action")
try:
logger.info(f"Executing get_vm_details for identifier: {vm_identifier}")
response_data = await make_graphql_request(query)
logger.info(f"Executing unraid_vm action={action}")
if response_data.get("vms"):
vms_data = response_data["vms"]
# Try to get VMs from either domains or domain field
vms = vms_data.get("domains") or vms_data.get("domain") or []
if action == "list":
data = await make_graphql_request(QUERIES["list"])
if data.get("vms") and data["vms"].get("domains"):
vms = data["vms"]["domains"]
return {"vms": list(vms) if isinstance(vms, list) else []}
return {"vms": []}
if vms:
for vm_data in vms:
if (vm_data.get("uuid") == vm_identifier or
vm_data.get("id") == vm_identifier or
vm_data.get("name") == vm_identifier):
logger.info(f"Found VM {vm_identifier}")
return dict(vm_data) if isinstance(vm_data, dict) else {}
if action == "details":
data = await make_graphql_request(QUERIES["details"])
if data.get("vms"):
vms = data["vms"].get("domains") or []
for vm in vms:
if (
vm.get("uuid") == vm_id
or vm.get("id") == vm_id
or vm.get("name") == vm_id
):
return dict(vm) if isinstance(vm, dict) else {}
available = [
f"{v.get('name')} (UUID: {v.get('uuid')})" for v in vms
]
raise ToolError(
f"VM '{vm_id}' not found. Available: {', '.join(available)}"
)
raise ToolError("No VM data returned from server")
logger.warning(f"VM with identifier '{vm_identifier}' not found.")
available_vms = [f"{vm.get('name')} (UUID: {vm.get('uuid')}, ID: {vm.get('id')})" for vm in vms]
raise ToolError(f"VM '{vm_identifier}' not found. Available VMs: {', '.join(available_vms)}")
else:
raise ToolError("No VMs available or VMs not accessible")
else:
raise ToolError("No VMs data returned from server")
# Mutations
if action in MUTATIONS:
data = await make_graphql_request(
MUTATIONS[action], {"id": vm_id}
)
field = _MUTATION_FIELDS[action]
if data.get("vm") and field in data["vm"]:
return {
"success": data["vm"][field],
"action": action,
"vm_id": vm_id,
}
raise ToolError(f"Failed to {action} VM or unexpected response")
return {}
except ToolError:
raise
except Exception as e:
logger.error(f"Error in get_vm_details: {e}", exc_info=True)
error_msg = str(e)
if "VMs are not available" in error_msg:
raise ToolError("VMs are not available on this Unraid server. This could mean: 1) VM support is not enabled, 2) VM service is not running, or 3) no VMs are configured. Check Unraid VM settings.") from e
else:
raise ToolError(f"Failed to retrieve VM details: {error_msg}") from e
logger.error(f"Error in unraid_vm action={action}: {e}", exc_info=True)
msg = str(e)
if "VMs are not available" in msg:
raise ToolError(
"VMs not available on this server. Check VM support is enabled."
) from e
raise ToolError(f"Failed to execute vm/{action}: {msg}") from e
logger.info("VM tools registered successfully")
logger.info("VM tool registered successfully")