mirror of
https://github.com/jmagar/unraid-mcp.git
synced 2026-03-01 16:04:24 -08:00
**Critical Fixes (7 issues):**
- Fix GraphQL schema field names in users tool (role→roles, remove email)
- Fix GraphQL mutation signatures (addUserInput, deleteUser input)
- Fix dict(None) TypeError guards in users tool (use `or {}` pattern)
- Fix FastAPI version constraint (0.116.1→0.115.0)
- Fix WebSocket SSL context handling (support CA bundles, bool, and None)
- Fix critical disk threshold treated as warning (split counters)
**High Priority Fixes (11 issues):**
- Fix Docker update/remove action response field mapping
- Fix path traversal vulnerability in log validation (normalize paths)
- Fix deleteApiKeys validation (check response before success)
- Fix rclone create_remote validation (check response)
- Fix keys input_data type annotation (dict[str, Any])
- Fix VM domain/domains fallback restoration
**Changes by file:**
- unraid_mcp/tools/docker.py: Response field mapping
- unraid_mcp/tools/info.py: Split critical/warning counters
- unraid_mcp/tools/storage.py: Path normalization for traversal protection
- unraid_mcp/tools/users.py: GraphQL schema + null handling
- unraid_mcp/tools/keys.py: Validation + type annotations
- unraid_mcp/tools/rclone.py: Response validation
- unraid_mcp/tools/virtualization.py: Domain fallback
- unraid_mcp/subscriptions/manager.py: SSL context creation
- pyproject.toml: FastAPI version fix
- tests/*: New tests for all fixes
**Review threads resolved:**
PRRT_kwDOO6Hdxs5uu70L, PRRT_kwDOO6Hdxs5uu70O, PRRT_kwDOO6Hdxs5uu70V,
PRRT_kwDOO6Hdxs5uu70e, PRRT_kwDOO6Hdxs5uu70i, PRRT_kwDOO6Hdxs5uu7zn,
PRRT_kwDOO6Hdxs5uu7z_, PRRT_kwDOO6Hdxs5uu7sI, PRRT_kwDOO6Hdxs5uu7sJ,
PRRT_kwDOO6Hdxs5uu7sK, PRRT_kwDOO6Hdxs5uu7Tk, PRRT_kwDOO6Hdxs5uu7Tn,
PRRT_kwDOO6Hdxs5uu7Tr, PRRT_kwDOO6Hdxs5uu7Ts, PRRT_kwDOO6Hdxs5uu7Tu,
PRRT_kwDOO6Hdxs5uu7Tv, PRRT_kwDOO6Hdxs5uu7Tw, PRRT_kwDOO6Hdxs5uu7Tx
All tests passing.
Co-authored-by: docker-fixer <agent@pr-fixes>
Co-authored-by: info-fixer <agent@pr-fixes>
Co-authored-by: storage-fixer <agent@pr-fixes>
Co-authored-by: users-fixer <agent@pr-fixes>
Co-authored-by: config-fixer <agent@pr-fixes>
Co-authored-by: websocket-fixer <agent@pr-fixes>
Co-authored-by: keys-rclone-fixer <agent@pr-fixes>
Co-authored-by: vm-fixer <agent@pr-fixes>
171 lines
5.8 KiB
Python
171 lines
5.8 KiB
Python
"""Storage and disk management.
|
|
|
|
Provides the `unraid_storage` tool with 6 actions for shares, physical disks,
|
|
unassigned devices, log files, and log content retrieval.
|
|
"""
|
|
|
|
import posixpath
|
|
from typing import Any, Literal
|
|
|
|
from fastmcp import FastMCP
|
|
|
|
from ..config.logging import logger
|
|
from ..core.client import DISK_TIMEOUT, make_graphql_request
|
|
from ..core.exceptions import ToolError
|
|
|
|
QUERIES: dict[str, str] = {
|
|
"shares": """
|
|
query GetSharesInfo {
|
|
shares {
|
|
id name free used size include exclude cache nameOrig
|
|
comment allocator splitLevel floor cow color luksStatus
|
|
}
|
|
}
|
|
""",
|
|
"disks": """
|
|
query ListPhysicalDisks {
|
|
disks { id device name }
|
|
}
|
|
""",
|
|
"disk_details": """
|
|
query GetDiskDetails($id: PrefixedID!) {
|
|
disk(id: $id) {
|
|
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)
|
|
"""
|
|
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":
|
|
if not log_path:
|
|
raise ToolError("log_path is required for 'logs' action")
|
|
_ALLOWED_LOG_PREFIXES = ("/var/log/", "/boot/logs/", "/mnt/")
|
|
# Normalize path to prevent traversal attacks (e.g. /var/log/../../etc/shadow)
|
|
normalized = posixpath.normpath(log_path)
|
|
if not any(normalized.startswith(p) for p in _ALLOWED_LOG_PREFIXES):
|
|
raise ToolError(
|
|
f"log_path must start with one of: {', '.join(_ALLOWED_LOG_PREFIXES)}. "
|
|
f"Use log_files action to discover valid paths."
|
|
)
|
|
log_path = normalized
|
|
|
|
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 unraid_storage action={action}")
|
|
data = await make_graphql_request(query, variables, custom_timeout=custom_timeout)
|
|
|
|
if action == "shares":
|
|
shares = data.get("shares", [])
|
|
return {"shares": list(shares) if isinstance(shares, list) else []}
|
|
|
|
if action == "disks":
|
|
disks = data.get("disks", [])
|
|
return {"disks": list(disks) if isinstance(disks, list) else []}
|
|
|
|
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}
|
|
|
|
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", {}))
|
|
|
|
raise ToolError(f"Unhandled action '{action}' — this is a bug")
|
|
|
|
except ToolError:
|
|
raise
|
|
except Exception as 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 tool registered successfully")
|