forked from HomeLab/unraid-mcp
Comprehensive fixes across Python code, shell scripts, and documentation addressing all remaining MEDIUM and LOW priority review comments. Python Code Fixes (27 fixes): - tools/info.py: Simplified dispatch with lookup tables, defensive guards, CPU fallback formatting, !s conversion flags, module-level sync assertion - tools/docker.py: Case-insensitive container ID regex, keyword-only confirm, module-level ALL_ACTIONS constant - tools/virtualization.py: Normalized single-VM dict responses, unified list/details queries - core/client.py: Fixed HTTP client singleton race condition, compound key substring matching for sensitive data redaction - subscriptions/: Extracted SSL context creation to shared helper in utils.py, replaced deprecated ssl._create_unverified_context API - tools/array.py: Renamed parity_history to parity_status, hoisted ALL_ACTIONS - tools/storage.py: Fixed dict(None) risks, temperature 0 falsiness bug - tools/notifications.py, keys.py, rclone.py: Fixed dict(None) TypeError risks - tests/: Fixed generator type annotations, added coverage for compound keys Shell Script Fixes (13 fixes): - dashboard.sh: Dynamic server discovery, conditional debug output, null-safe jq, notification count guard order, removed unused variables - unraid-query.sh: Proper JSON escaping via jq, --ignore-errors and --insecure CLI flags, TLS verification now on by default - validate-marketplace.sh: Removed unused YELLOW variable, defensive jq, simplified repository URL output Documentation Fixes (24+ fixes): - Version consistency: Updated all references to v0.2.0 across pyproject.toml, plugin.json, marketplace.json, MARKETPLACE.md, __init__.py, README files - Tool count updates: Changed all "26 tools" references to "10 tools, 90 actions" - Markdown lint: Fixed MD022, MD031, MD047 issues across multiple files - Research docs: Fixed auth headers, removed web artifacts, corrected stale info - Skills docs: Fixed query examples, endpoint counts, env var references All 227 tests pass, ruff and ty checks clean.
173 lines
5.9 KiB
Python
173 lines
5.9 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.
|
|
"""
|
|
|
|
from pathlib import Path
|
|
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
|
|
|
|
|
|
_ALLOWED_LOG_PREFIXES = ("/var/log/", "/boot/logs/", "/mnt/")
|
|
|
|
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")
|
|
# Resolve path to prevent traversal attacks (e.g. /var/log/../../etc/shadow)
|
|
normalized = str(Path(log_path).resolve())
|
|
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['temperature']}\u00b0C"
|
|
if raw.get("temperature") is not None
|
|
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") or {})
|
|
|
|
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}: {e!s}") from e
|
|
|
|
logger.info("Storage tool registered successfully")
|