fix: address 5 critical and major PR review issues

- Remove set -e from validate-marketplace.sh to prevent early exit on
  check failures, allowing the summary to always be displayed (PRRT_kwDOO6Hdxs5uvKrc)
- Fix marketplace.json source path to point to skills/unraid instead of
  ./ for correct plugin directory resolution (PRRT_kwDOO6Hdxs5uvKrg)
- Fix misleading trap registration comment in unraid-api-crawl.md and
  add auth note to Apollo Studio URL (PRRT_kwDOO6Hdxs5uvO2t)
- Extract duplicated cleanup-with-error-handling in main.py into
  _run_shutdown_cleanup() helper (PRRT_kwDOO6Hdxs5uvO3A)
- Add input validation to read-logs.sh to prevent GraphQL injection
  via LOG_NAME and LINES parameters (PRRT_kwDOO6Hdxs5uvKrj)
This commit is contained in:
Jacob Magar
2026-02-15 23:03:01 -05:00
parent a0721e38dd
commit 91244b66ff
11 changed files with 90 additions and 47 deletions

View File

@@ -12,7 +12,7 @@
"plugins": [ "plugins": [
{ {
"name": "unraid", "name": "unraid",
"source": "./", "source": "skills/unraid",
"description": "Query and monitor Unraid servers via GraphQL API - array status, disk health, containers, VMs, system monitoring", "description": "Query and monitor Unraid servers via GraphQL API - array status, disk health, containers, VMs, system monitoring",
"version": "0.2.0", "version": "0.2.0",
"tags": ["unraid", "monitoring", "homelab", "graphql", "docker", "virtualization"], "tags": ["unraid", "monitoring", "homelab", "graphql", "docker", "virtualization"],

View File

@@ -81,7 +81,7 @@ The Unraid API provides a GraphQL interface that allows you to interact with you
#### Enabling the GraphQL Sandbox #### Enabling the GraphQL Sandbox
**Live Documentation:** **Live Documentation:**
- View the complete API schema and documentation at [Apollo GraphQL Studio](https://studio.apollographql.com/graph/Unraid-API/variant/current/home) - View the complete API schema and documentation at [Apollo GraphQL Studio](https://studio.apollographql.com/graph/Unraid-API/variant/current/home) *(requires authentication; may not be publicly accessible)*
**WebGUI method (recommended):** **WebGUI method (recommended):**
1. Navigate to **Settings > Management Access > Developer Options** 1. Navigate to **Settings > Management Access > Developer Options**
@@ -468,7 +468,7 @@ unraid-api apikey --delete --name "workflow key" --json
#!/bin/bash #!/bin/bash
set -e set -e
# Set up cleanup trap early so it fires even if key creation fails # Define cleanup function (deletes the temporary key)
cleanup() { echo "Cleaning up..."; unraid-api apikey --delete --name "temp deployment key" 2>/dev/null || true; } cleanup() { echo "Cleaning up..."; unraid-api apikey --delete --name "temp deployment key" 2>/dev/null || true; }
# 1. Create temporary API key # 1. Create temporary API key
@@ -479,7 +479,7 @@ KEY_DATA=$(unraid-api apikey --create \
--description "Temporary key for deployment $(date)" \ --description "Temporary key for deployment $(date)" \
--json) --json)
# Register trap after key creation succeeds # Register trap only after key creation succeeds (nothing to clean up if creation failed)
trap cleanup EXIT trap cleanup EXIT
# 2. Extract the API key # 2. Extract the API key

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Validate Claude Code marketplace and plugin structure # Validate Claude Code marketplace and plugin structure
set -euo pipefail set -uo pipefail
# Colors for output # Colors for output
RED='\033[0;31m' RED='\033[0;31m'

View File

@@ -8,6 +8,16 @@ QUERY_SCRIPT="$SCRIPT_DIR/../scripts/unraid-query.sh"
LOG_NAME="${1:-syslog}" LOG_NAME="${1:-syslog}"
LINES="${2:-20}" LINES="${2:-20}"
# Validate inputs to prevent GraphQL injection
if ! [[ "$LOG_NAME" =~ ^[a-zA-Z0-9_./-]+$ ]]; then
echo "Error: Invalid log name. Only alphanumeric characters, dots, slashes, hyphens, and underscores are allowed." >&2
exit 1
fi
if ! [[ "$LINES" =~ ^[0-9]+$ ]]; then
echo "Error: Lines must be a positive integer." >&2
exit 1
fi
echo "=== Reading $LOG_NAME (last $LINES lines) ===" echo "=== Reading $LOG_NAME (last $LINES lines) ==="
echo "" echo ""

View File

@@ -19,6 +19,18 @@ async def shutdown_cleanup() -> None:
print(f"Error during cleanup: {e}") print(f"Error during cleanup: {e}")
def _run_shutdown_cleanup() -> None:
"""Run shutdown cleanup, suppressing expected event loop errors."""
try:
asyncio.run(shutdown_cleanup())
except RuntimeError as e:
msg = str(e).lower()
if "event loop is closed" in msg or "no running event loop" in msg:
pass # Expected during shutdown
else:
print(f"WARNING: Unexpected error during cleanup: {e}", file=sys.stderr)
def main() -> None: def main() -> None:
"""Main entry point for the Unraid MCP Server.""" """Main entry point for the Unraid MCP Server."""
try: try:
@@ -27,28 +39,10 @@ def main() -> None:
run_server() run_server()
except KeyboardInterrupt: except KeyboardInterrupt:
print("\nServer stopped by user") print("\nServer stopped by user")
try: _run_shutdown_cleanup()
asyncio.run(shutdown_cleanup())
except RuntimeError as e:
if (
"event loop is closed" in str(e).lower()
or "no running event loop" in str(e).lower()
):
pass # Expected during shutdown
else:
print(f"WARNING: Unexpected error during cleanup: {e}", file=sys.stderr)
except Exception as e: except Exception as e:
print(f"Server failed to start: {e}") print(f"Server failed to start: {e}")
try: _run_shutdown_cleanup()
asyncio.run(shutdown_cleanup())
except RuntimeError as e:
if (
"event loop is closed" in str(e).lower()
or "no running event loop" in str(e).lower()
):
pass # Expected during shutdown
else:
print(f"WARNING: Unexpected error during cleanup: {e}", file=sys.stderr)
raise raise

View File

@@ -48,7 +48,7 @@ def register_all_modules() -> None:
register_subscription_resources(mcp) register_subscription_resources(mcp)
logger.info("Subscription resources registered") logger.info("Subscription resources registered")
# Register all 10 consolidated tools # Register all consolidated tools
registrars = [ registrars = [
register_info_tool, register_info_tool,
register_array_tool, register_array_tool,
@@ -64,7 +64,7 @@ def register_all_modules() -> None:
for registrar in registrars: for registrar in registrars:
registrar(mcp) registrar(mcp)
logger.info("All 10 tools registered successfully - Server ready!") logger.info(f"All {len(registrars)} tools registered successfully - Server ready!")
except Exception as e: except Exception as e:
logger.error(f"Failed to register modules: {e}", exc_info=True) logger.error(f"Failed to register modules: {e}", exc_info=True)

View File

@@ -134,13 +134,34 @@ DOCKER_ACTIONS = Literal[
_DOCKER_ID_PATTERN = re.compile(r"^[a-f0-9]{64}(:[a-z0-9]+)?$", re.IGNORECASE) _DOCKER_ID_PATTERN = re.compile(r"^[a-f0-9]{64}(:[a-z0-9]+)?$", re.IGNORECASE)
def _safe_get(data: dict[str, Any], *keys: str, default: Any = None) -> Any:
"""Safely traverse nested dict keys, handling None intermediates."""
current = data
for key in keys:
if not isinstance(current, dict):
return default
current = current.get(key)
return current if current is not None else default
def find_container_by_identifier( def find_container_by_identifier(
identifier: str, containers: list[dict[str, Any]] identifier: str, containers: list[dict[str, Any]]
) -> dict[str, Any] | None: ) -> dict[str, Any] | None:
"""Find a container by ID or name with fuzzy matching.""" """Find a container by ID or name with fuzzy matching.
Match priority:
1. Exact ID match
2. Exact name match (case-sensitive)
3. Name starts with identifier (case-insensitive)
4. Name contains identifier as substring (case-insensitive)
Note: Short identifiers (e.g. "db") may match unintended containers
via substring. Use more specific names or IDs for precision.
"""
if not containers: if not containers:
return None return None
# Priority 1 & 2: exact matches
for c in containers: for c in containers:
if c.get("id") == identifier: if c.get("id") == identifier:
return c return c
@@ -148,10 +169,19 @@ def find_container_by_identifier(
return c return c
id_lower = identifier.lower() id_lower = identifier.lower()
# Priority 3: prefix match (more precise than substring)
for c in containers:
for name in c.get("names", []):
if name.lower().startswith(id_lower):
logger.info(f"Prefix match: '{identifier}' -> '{name}'")
return c
# Priority 4: substring match (least precise)
for c in containers: for c in containers:
for name in c.get("names", []): for name in c.get("names", []):
if id_lower in name.lower(): if id_lower in name.lower():
logger.info(f"Fuzzy match: '{identifier}' -> '{name}'") logger.info(f"Substring match: '{identifier}' -> '{name}'")
return c return c
return None return None
@@ -177,7 +207,7 @@ async def _resolve_container_id(container_id: str) -> str:
} }
""" """
data = await make_graphql_request(list_query) data = await make_graphql_request(list_query)
containers = data.get("docker", {}).get("containers", []) containers = _safe_get(data, "docker", "containers", default=[])
resolved = find_container_by_identifier(container_id, containers) resolved = find_container_by_identifier(container_id, containers)
if resolved: if resolved:
actual_id = str(resolved.get("id", "")) actual_id = str(resolved.get("id", ""))
@@ -240,12 +270,12 @@ def register_docker_tool(mcp: FastMCP) -> None:
# --- Read-only queries --- # --- Read-only queries ---
if action == "list": if action == "list":
data = await make_graphql_request(QUERIES["list"]) data = await make_graphql_request(QUERIES["list"])
containers = data.get("docker", {}).get("containers", []) containers = _safe_get(data, "docker", "containers", default=[])
return {"containers": list(containers) if isinstance(containers, list) else []} return {"containers": list(containers) if isinstance(containers, list) else []}
if action == "details": if action == "details":
data = await make_graphql_request(QUERIES["details"]) data = await make_graphql_request(QUERIES["details"])
containers = data.get("docker", {}).get("containers", []) containers = _safe_get(data, "docker", "containers", default=[])
container = find_container_by_identifier(container_id or "", containers) container = find_container_by_identifier(container_id or "", containers)
if container: if container:
return container return container
@@ -260,7 +290,7 @@ def register_docker_tool(mcp: FastMCP) -> None:
data = await make_graphql_request( data = await make_graphql_request(
QUERIES["logs"], {"id": actual_id, "tail": tail_lines} QUERIES["logs"], {"id": actual_id, "tail": tail_lines}
) )
return {"logs": data.get("docker", {}).get("logs")} return {"logs": _safe_get(data, "docker", "logs")}
if action == "networks": if action == "networks":
data = await make_graphql_request(QUERIES["networks"]) data = await make_graphql_request(QUERIES["networks"])
@@ -269,16 +299,16 @@ def register_docker_tool(mcp: FastMCP) -> None:
if action == "network_details": if action == "network_details":
data = await make_graphql_request(QUERIES["network_details"], {"id": network_id}) data = await make_graphql_request(QUERIES["network_details"], {"id": network_id})
return dict(data.get("dockerNetwork", {})) return dict(data.get("dockerNetwork") or {})
if action == "port_conflicts": if action == "port_conflicts":
data = await make_graphql_request(QUERIES["port_conflicts"]) data = await make_graphql_request(QUERIES["port_conflicts"])
conflicts = data.get("docker", {}).get("portConflicts", []) conflicts = _safe_get(data, "docker", "portConflicts", default=[])
return {"port_conflicts": list(conflicts) if isinstance(conflicts, list) else []} return {"port_conflicts": list(conflicts) if isinstance(conflicts, list) else []}
if action == "check_updates": if action == "check_updates":
data = await make_graphql_request(QUERIES["check_updates"]) data = await make_graphql_request(QUERIES["check_updates"])
statuses = data.get("docker", {}).get("containerUpdateStatuses", []) statuses = _safe_get(data, "docker", "containerUpdateStatuses", default=[])
return {"update_statuses": list(statuses) if isinstance(statuses, list) else []} return {"update_statuses": list(statuses) if isinstance(statuses, list) else []}
# --- Mutations --- # --- Mutations ---
@@ -300,7 +330,7 @@ def register_docker_tool(mcp: FastMCP) -> None:
if start_data.get("idempotent_success"): if start_data.get("idempotent_success"):
result = {} result = {}
else: else:
result = start_data.get("docker", {}).get("start", {}) result = _safe_get(start_data, "docker", "start", default={})
response: dict[str, Any] = { response: dict[str, Any] = {
"success": True, "success": True,
"action": "restart", "action": "restart",
@@ -312,7 +342,7 @@ def register_docker_tool(mcp: FastMCP) -> None:
if action == "update_all": if action == "update_all":
data = await make_graphql_request(MUTATIONS["update_all"]) data = await make_graphql_request(MUTATIONS["update_all"])
results = data.get("docker", {}).get("updateAllContainers", []) results = _safe_get(data, "docker", "updateAllContainers", default=[])
return {"success": True, "action": "update_all", "containers": results} return {"success": True, "action": "update_all", "containers": results}
# Single-container mutations # Single-container mutations
@@ -336,7 +366,7 @@ def register_docker_tool(mcp: FastMCP) -> None:
"message": f"Container already in desired state for '{action}'", "message": f"Container already in desired state for '{action}'",
} }
docker_data = data.get("docker", {}) docker_data = data.get("docker") or {}
# Map action names to GraphQL response field names where they differ # Map action names to GraphQL response field names where they differ
response_field_map = { response_field_map = {
"update": "updateContainer", "update": "updateContainer",

View File

@@ -204,6 +204,8 @@ async def _comprehensive_check() -> dict[str, Any]:
return health_info return health_info
except Exception as e: except Exception as e:
# Intentionally broad: health checks must always return a result,
# even on unexpected failures, so callers never get an unhandled exception.
logger.error(f"Health check failed: {e}") logger.error(f"Health check failed: {e}")
return { return {
"status": "unhealthy", "status": "unhealthy",
@@ -222,6 +224,9 @@ async def _diagnose_subscriptions() -> dict[str, Any]:
await ensure_subscriptions_started() await ensure_subscriptions_started()
status = subscription_manager.get_subscription_status() status = subscription_manager.get_subscription_status()
# This list is intentionally placed into the summary dict below and then
# appended to in the loop — the mutable alias ensures both references
# reflect the same data without a second pass.
connection_issues: list[dict[str, Any]] = [] connection_issues: list[dict[str, Any]] = []
diagnostic_info: dict[str, Any] = { diagnostic_info: dict[str, Any] = {

View File

@@ -101,9 +101,9 @@ def register_keys_tool(mcp: FastMCP) -> None:
if not name: if not name:
raise ToolError("name is required for 'create' action") raise ToolError("name is required for 'create' action")
input_data: dict[str, Any] = {"name": name} input_data: dict[str, Any] = {"name": name}
if roles: if roles is not None:
input_data["roles"] = roles input_data["roles"] = roles
if permissions: if permissions is not None:
input_data["permissions"] = permissions input_data["permissions"] = permissions
data = await make_graphql_request(MUTATIONS["create"], {"input": input_data}) data = await make_graphql_request(MUTATIONS["create"], {"input": input_data})
return { return {
@@ -117,7 +117,7 @@ def register_keys_tool(mcp: FastMCP) -> None:
input_data: dict[str, Any] = {"id": key_id} input_data: dict[str, Any] = {"id": key_id}
if name: if name:
input_data["name"] = name input_data["name"] = name
if roles: if roles is not None:
input_data["roles"] = roles input_data["roles"] = roles
data = await make_graphql_request(MUTATIONS["update"], {"input": input_data}) data = await make_graphql_request(MUTATIONS["update"], {"input": input_data})
return { return {

View File

@@ -70,7 +70,10 @@ def format_bytes(bytes_value: int | None) -> str:
"""Format byte values into human-readable sizes.""" """Format byte values into human-readable sizes."""
if bytes_value is None: if bytes_value is None:
return "N/A" return "N/A"
value = float(int(bytes_value)) try:
value = float(int(bytes_value))
except (ValueError, TypeError):
return "N/A"
for unit in ["B", "KB", "MB", "GB", "TB", "PB"]: for unit in ["B", "KB", "MB", "GB", "TB", "PB"]:
if value < 1024.0: if value < 1024.0:
return f"{value:.2f} {unit}" return f"{value:.2f} {unit}"
@@ -118,7 +121,7 @@ def register_storage_tool(mcp: FastMCP) -> None:
query = QUERIES[action] query = QUERIES[action]
variables: dict[str, Any] | None = None variables: dict[str, Any] | None = None
custom_timeout = DISK_TIMEOUT if action == "disks" else None custom_timeout = DISK_TIMEOUT if action in ("disks", "disk_details") else None
if action == "disk_details": if action == "disk_details":
variables = {"id": disk_id} variables = {"id": disk_id}

View File

@@ -64,6 +64,8 @@ VM_ACTIONS = Literal[
"reset", "reset",
] ]
ALL_ACTIONS = set(QUERIES) | set(MUTATIONS) | {"details"}
def register_vm_tool(mcp: FastMCP) -> None: def register_vm_tool(mcp: FastMCP) -> None:
"""Register the unraid_vm tool with the FastMCP instance.""" """Register the unraid_vm tool with the FastMCP instance."""
@@ -87,9 +89,8 @@ def register_vm_tool(mcp: FastMCP) -> None:
reboot - Reboot a VM (requires vm_id) reboot - Reboot a VM (requires vm_id)
reset - Reset a VM (requires vm_id, confirm=True) reset - Reset a VM (requires vm_id, confirm=True)
""" """
all_actions = set(QUERIES) | set(MUTATIONS) | {"details"} if action not in ALL_ACTIONS:
if action not in all_actions: raise ToolError(f"Invalid action '{action}'. Must be one of: {sorted(ALL_ACTIONS)}")
raise ToolError(f"Invalid action '{action}'. Must be one of: {sorted(all_actions)}")
if action != "list" and not vm_id: if action != "list" and not vm_id:
raise ToolError(f"vm_id is required for '{action}' action") raise ToolError(f"vm_id is required for '{action}' action")