feat: harden API safety and expand command docs with full test coverage

This commit is contained in:
Jacob Magar
2026-02-15 22:15:51 -05:00
parent d791c6b6b7
commit abb7915672
60 changed files with 7122 additions and 1247 deletions

View File

@@ -19,6 +19,7 @@ from rich.text import Text
try:
from fastmcp.utilities.logging import get_logger as get_fastmcp_logger
FASTMCP_AVAILABLE = True
except ImportError:
FASTMCP_AVAILABLE = False
@@ -33,7 +34,7 @@ console = Console(stderr=True, force_terminal=True)
class OverwriteFileHandler(logging.FileHandler):
"""Custom file handler that overwrites the log file when it reaches max size."""
def __init__(self, filename, max_bytes=10*1024*1024, mode="a", encoding=None, delay=False):
def __init__(self, filename, max_bytes=10 * 1024 * 1024, mode="a", encoding=None, delay=False):
"""Initialize the handler.
Args:
@@ -74,14 +75,17 @@ class OverwriteFileHandler(logging.FileHandler):
lineno=0,
msg="=== LOG FILE RESET (10MB limit reached) ===",
args=(),
exc_info=None
exc_info=None,
)
super().emit(reset_record)
except OSError as e:
import sys
print(f"WARNING: Log file size check failed: {e}. Continuing without rotation.",
file=sys.stderr)
print(
f"WARNING: Log file size check failed: {e}. Continuing without rotation.",
file=sys.stderr,
)
# Emit the original record
super().emit(record)
@@ -114,17 +118,13 @@ def setup_logger(name: str = "UnraidMCPServer") -> logging.Logger:
show_level=True,
show_path=False,
rich_tracebacks=True,
tracebacks_show_locals=True
tracebacks_show_locals=True,
)
console_handler.setLevel(numeric_log_level)
logger.addHandler(console_handler)
# File Handler with 10MB cap (overwrites instead of rotating)
file_handler = OverwriteFileHandler(
LOG_FILE_PATH,
max_bytes=10*1024*1024,
encoding="utf-8"
)
file_handler = OverwriteFileHandler(LOG_FILE_PATH, max_bytes=10 * 1024 * 1024, encoding="utf-8")
file_handler.setLevel(numeric_log_level)
file_formatter = logging.Formatter(
"%(asctime)s - %(name)s - %(levelname)s - %(module)s - %(funcName)s - %(lineno)d - %(message)s"
@@ -158,17 +158,13 @@ def configure_fastmcp_logger_with_rich() -> logging.Logger | None:
show_path=False,
rich_tracebacks=True,
tracebacks_show_locals=True,
markup=True
markup=True,
)
console_handler.setLevel(numeric_log_level)
fastmcp_logger.addHandler(console_handler)
# File Handler with 10MB cap (overwrites instead of rotating)
file_handler = OverwriteFileHandler(
LOG_FILE_PATH,
max_bytes=10*1024*1024,
encoding="utf-8"
)
file_handler = OverwriteFileHandler(LOG_FILE_PATH, max_bytes=10 * 1024 * 1024, encoding="utf-8")
file_handler.setLevel(numeric_log_level)
file_formatter = logging.Formatter(
"%(asctime)s - %(name)s - %(levelname)s - %(module)s - %(funcName)s - %(lineno)d - %(message)s"
@@ -191,16 +187,14 @@ def configure_fastmcp_logger_with_rich() -> logging.Logger | None:
show_path=False,
rich_tracebacks=True,
tracebacks_show_locals=True,
markup=True
markup=True,
)
root_console_handler.setLevel(numeric_log_level)
root_logger.addHandler(root_console_handler)
# File Handler for root logger with 10MB cap (overwrites instead of rotating)
root_file_handler = OverwriteFileHandler(
LOG_FILE_PATH,
max_bytes=10*1024*1024,
encoding="utf-8"
LOG_FILE_PATH, max_bytes=10 * 1024 * 1024, encoding="utf-8"
)
root_file_handler.setLevel(numeric_log_level)
root_file_handler.setFormatter(file_formatter)
@@ -255,16 +249,18 @@ def get_est_timestamp() -> str:
now = datetime.now(est)
return now.strftime("%y/%m/%d %H:%M:%S")
def log_header(title: str) -> None:
"""Print a beautiful header panel with Nordic blue styling."""
panel = Panel(
Align.center(Text(title, style="bold white")),
style="#5E81AC", # Nordic blue
padding=(0, 2),
border_style="#81A1C1" # Light Nordic blue
border_style="#81A1C1", # Light Nordic blue
)
console.print(panel)
def log_with_level_and_indent(message: str, level: str = "info", indent: int = 0) -> None:
"""Log a message with specific level and indentation."""
timestamp = get_est_timestamp()
@@ -272,15 +268,17 @@ def log_with_level_and_indent(message: str, level: str = "info", indent: int = 0
# Enhanced Nordic color scheme with more blues
level_config = {
"error": {"color": "#BF616A", "icon": "", "style": "bold"}, # Nordic red
"warning": {"color": "#EBCB8B", "icon": "⚠️", "style": ""}, # Nordic yellow
"success": {"color": "#A3BE8C", "icon": "", "style": "bold"}, # Nordic green
"error": {"color": "#BF616A", "icon": "", "style": "bold"}, # Nordic red
"warning": {"color": "#EBCB8B", "icon": "⚠️", "style": ""}, # Nordic yellow
"success": {"color": "#A3BE8C", "icon": "", "style": "bold"}, # Nordic green
"info": {"color": "#5E81AC", "icon": "\u2139\ufe0f", "style": "bold"}, # Nordic blue (bold)
"status": {"color": "#81A1C1", "icon": "🔍", "style": ""}, # Light Nordic blue
"debug": {"color": "#4C566A", "icon": "🐛", "style": ""}, # Nordic dark gray
"status": {"color": "#81A1C1", "icon": "🔍", "style": ""}, # Light Nordic blue
"debug": {"color": "#4C566A", "icon": "🐛", "style": ""}, # Nordic dark gray
}
config = level_config.get(level, {"color": "#81A1C1", "icon": "", "style": ""}) # Default to light Nordic blue
config = level_config.get(
level, {"color": "#81A1C1", "icon": "", "style": ""}
) # Default to light Nordic blue
# Create beautifully formatted text
text = Text()
@@ -308,26 +306,33 @@ def log_with_level_and_indent(message: str, level: str = "info", indent: int = 0
console.print(text)
def log_separator() -> None:
"""Print a beautiful separator line with Nordic blue styling."""
console.print(Rule(style="#81A1C1"))
# Convenience functions for different log levels
def log_error(message: str, indent: int = 0) -> None:
log_with_level_and_indent(message, "error", indent)
def log_warning(message: str, indent: int = 0) -> None:
log_with_level_and_indent(message, "warning", indent)
def log_success(message: str, indent: int = 0) -> None:
log_with_level_and_indent(message, "success", indent)
def log_info(message: str, indent: int = 0) -> None:
log_with_level_and_indent(message, "info", indent)
def log_status(message: str, indent: int = 0) -> None:
log_with_level_and_indent(message, "status", indent)
# Global logger instance - modules can import this directly
if FASTMCP_AVAILABLE:
# Use FastMCP logger with Rich formatting

View File

@@ -22,7 +22,7 @@ dotenv_paths = [
Path("/app/.env.local"), # Container mount point
PROJECT_ROOT / ".env.local", # Project root .env.local
PROJECT_ROOT / ".env", # Project root .env
UNRAID_MCP_DIR / ".env" # Local .env in unraid_mcp/
UNRAID_MCP_DIR / ".env", # Local .env in unraid_mcp/
]
for dotenv_path in dotenv_paths:
@@ -73,10 +73,7 @@ def validate_required_config() -> tuple[bool, list[str]]:
Returns:
bool: True if all required config is present, False otherwise.
"""
required_vars = [
("UNRAID_API_URL", UNRAID_API_URL),
("UNRAID_API_KEY", UNRAID_API_KEY)
]
required_vars = [("UNRAID_API_URL", UNRAID_API_URL), ("UNRAID_API_KEY", UNRAID_API_KEY)]
missing = []
for name, value in required_vars:
@@ -105,5 +102,5 @@ def get_config_summary() -> dict[str, Any]:
"log_level": LOG_LEVEL_STR,
"log_file": str(LOG_FILE_PATH),
"config_valid": is_valid,
"missing_config": missing if not is_valid else None
"missing_config": missing if not is_valid else None,
}

View File

@@ -34,7 +34,9 @@ def _is_sensitive_key(key: str) -> bool:
def _redact_sensitive(obj: Any) -> Any:
"""Recursively redact sensitive values from nested dicts/lists."""
if isinstance(obj, dict):
return {k: ("***" if _is_sensitive_key(k) else _redact_sensitive(v)) for k, v in obj.items()}
return {
k: ("***" if _is_sensitive_key(k) else _redact_sensitive(v)) for k, v in obj.items()
}
if isinstance(obj, list):
return [_redact_sensitive(item) for item in obj]
return obj
@@ -62,6 +64,7 @@ def get_timeout_for_operation(profile: str) -> httpx.Timeout:
"""
return _TIMEOUT_PROFILES.get(profile, DEFAULT_TIMEOUT)
# Global connection pool (module-level singleton)
_http_client: httpx.AsyncClient | None = None
_client_lock = asyncio.Lock()
@@ -82,16 +85,16 @@ def is_idempotent_error(error_message: str, operation: str) -> bool:
# Docker container operation patterns
if operation == "start":
return (
"already started" in error_lower or
"container already running" in error_lower or
"http code 304" in error_lower
"already started" in error_lower
or "container already running" in error_lower
or "http code 304" in error_lower
)
if operation == "stop":
return (
"already stopped" in error_lower or
"container already stopped" in error_lower or
"container not running" in error_lower or
"http code 304" in error_lower
"already stopped" in error_lower
or "container already stopped" in error_lower
or "container not running" in error_lower
or "http code 304" in error_lower
)
return False
@@ -106,19 +109,14 @@ async def _create_http_client() -> httpx.AsyncClient:
return httpx.AsyncClient(
# Connection pool settings
limits=httpx.Limits(
max_keepalive_connections=20,
max_connections=100,
keepalive_expiry=30.0
max_keepalive_connections=20, max_connections=100, keepalive_expiry=30.0
),
# Default timeout (can be overridden per-request)
timeout=DEFAULT_TIMEOUT,
# SSL verification
verify=UNRAID_VERIFY_SSL,
# Connection pooling headers
headers={
"Connection": "keep-alive",
"User-Agent": f"UnraidMCPServer/{VERSION}"
}
headers={"Connection": "keep-alive", "User-Agent": f"UnraidMCPServer/{VERSION}"},
)
@@ -136,7 +134,9 @@ async def get_http_client() -> httpx.AsyncClient:
async with _client_lock:
if _http_client is None or _http_client.is_closed:
_http_client = await _create_http_client()
logger.info("Created shared HTTP client with connection pooling (20 keepalive, 100 max connections)")
logger.info(
"Created shared HTTP client with connection pooling (20 keepalive, 100 max connections)"
)
client = _http_client
@@ -167,7 +167,7 @@ async def make_graphql_request(
query: str,
variables: dict[str, Any] | None = None,
custom_timeout: httpx.Timeout | None = None,
operation_context: dict[str, str] | None = None
operation_context: dict[str, str] | None = None,
) -> dict[str, Any]:
"""Make GraphQL requests to the Unraid API.
@@ -193,7 +193,7 @@ async def make_graphql_request(
headers = {
"Content-Type": "application/json",
"X-API-Key": UNRAID_API_KEY,
"User-Agent": f"UnraidMCPServer/{VERSION}" # Custom user-agent
"User-Agent": f"UnraidMCPServer/{VERSION}", # Custom user-agent
}
payload: dict[str, Any] = {"query": query}
@@ -212,10 +212,7 @@ async def make_graphql_request(
# Override timeout if custom timeout specified
if custom_timeout is not None:
response = await client.post(
UNRAID_API_URL,
json=payload,
headers=headers,
timeout=custom_timeout
UNRAID_API_URL, json=payload, headers=headers, timeout=custom_timeout
)
else:
response = await client.post(UNRAID_API_URL, json=payload, headers=headers)
@@ -224,19 +221,23 @@ async def make_graphql_request(
response_data = response.json()
if response_data.get("errors"):
error_details = "; ".join([err.get("message", str(err)) for err in response_data["errors"]])
error_details = "; ".join(
[err.get("message", str(err)) for err in response_data["errors"]]
)
# Check if this is an idempotent error that should be treated as success
if operation_context and operation_context.get("operation"):
operation = operation_context["operation"]
if is_idempotent_error(error_details, operation):
logger.warning(f"Idempotent operation '{operation}' - treating as success: {error_details}")
logger.warning(
f"Idempotent operation '{operation}' - treating as success: {error_details}"
)
# Return a success response with the current state information
return {
"idempotent_success": True,
"operation": operation,
"message": error_details,
"original_errors": response_data["errors"]
"original_errors": response_data["errors"],
}
logger.error(f"GraphQL API returned errors: {response_data['errors']}")

View File

@@ -15,26 +15,31 @@ class ToolError(FastMCPToolError):
Inherits from FastMCP's ToolError to ensure proper MCP protocol handling.
"""
pass
class ConfigurationError(ToolError):
"""Raised when there are configuration-related errors."""
pass
class UnraidAPIError(ToolError):
"""Raised when the Unraid API returns an error or is unreachable."""
pass
class SubscriptionError(ToolError):
"""Raised when there are WebSocket subscription-related errors."""
pass
class ValidationError(ToolError):
"""Raised when input validation fails."""
pass
@@ -45,4 +50,5 @@ class IdempotentOperationError(ToolError):
which should typically be converted to a success response rather than
propagated as an error to the user.
"""
pass

View File

@@ -12,6 +12,7 @@ from typing import Any
@dataclass
class SubscriptionData:
"""Container for subscription data with metadata."""
data: dict[str, Any]
last_updated: datetime
subscription_type: str
@@ -20,6 +21,7 @@ class SubscriptionData:
@dataclass
class SystemHealth:
"""Container for system health status information."""
is_healthy: bool
issues: list[str]
warnings: list[str]
@@ -30,6 +32,7 @@ class SystemHealth:
@dataclass
class APIResponse:
"""Container for standardized API response data."""
success: bool
data: dict[str, Any] | None = None
error: str | None = None

View File

@@ -13,6 +13,7 @@ async def shutdown_cleanup() -> None:
"""Cleanup resources on server shutdown."""
try:
from .core.client import close_http_client
await close_http_client()
except Exception as e:
print(f"Error during cleanup: {e}")
@@ -22,13 +23,17 @@ def main() -> None:
"""Main entry point for the Unraid MCP Server."""
try:
from .server import run_server
run_server()
except KeyboardInterrupt:
print("\nServer stopped by user")
try:
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():
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)
@@ -37,7 +42,10 @@ def main() -> None:
try:
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():
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)

View File

@@ -91,28 +91,24 @@ def run_server() -> None:
# Register all modules
register_all_modules()
logger.info(f"Starting Unraid MCP Server on {UNRAID_MCP_HOST}:{UNRAID_MCP_PORT} using {UNRAID_MCP_TRANSPORT} transport...")
logger.info(
f"Starting Unraid MCP Server on {UNRAID_MCP_HOST}:{UNRAID_MCP_PORT} using {UNRAID_MCP_TRANSPORT} transport..."
)
try:
if UNRAID_MCP_TRANSPORT == "streamable-http":
mcp.run(
transport="streamable-http",
host=UNRAID_MCP_HOST,
port=UNRAID_MCP_PORT,
path="/mcp"
transport="streamable-http", host=UNRAID_MCP_HOST, port=UNRAID_MCP_PORT, path="/mcp"
)
elif UNRAID_MCP_TRANSPORT == "sse":
logger.warning("SSE transport is deprecated. Consider switching to 'streamable-http'.")
mcp.run(
transport="sse",
host=UNRAID_MCP_HOST,
port=UNRAID_MCP_PORT,
path="/mcp"
)
mcp.run(transport="sse", host=UNRAID_MCP_HOST, port=UNRAID_MCP_PORT, path="/mcp")
elif UNRAID_MCP_TRANSPORT == "stdio":
mcp.run()
else:
logger.error(f"Unsupported MCP_TRANSPORT: {UNRAID_MCP_TRANSPORT}. Choose 'streamable-http', 'sse', or 'stdio'.")
logger.error(
f"Unsupported MCP_TRANSPORT: {UNRAID_MCP_TRANSPORT}. Choose 'streamable-http', 'sse', or 'stdio'."
)
sys.exit(1)
except Exception as e:
logger.critical(f"Failed to start Unraid MCP server: {e}", exc_info=True)

View File

@@ -47,7 +47,10 @@ def register_diagnostic_tools(mcp: FastMCP) -> None:
# Build WebSocket URL
if not UNRAID_API_URL:
raise ToolError("UNRAID_API_URL is not configured")
ws_url = UNRAID_API_URL.replace("https://", "wss://").replace("http://", "ws://") + "/graphql"
ws_url = (
UNRAID_API_URL.replace("https://", "wss://").replace("http://", "ws://")
+ "/graphql"
)
ssl_context = build_ws_ssl_context(ws_url)
@@ -57,18 +60,17 @@ def register_diagnostic_tools(mcp: FastMCP) -> None:
subprotocols=[Subprotocol("graphql-transport-ws"), Subprotocol("graphql-ws")],
ssl=ssl_context,
ping_interval=30,
ping_timeout=10
ping_timeout=10,
) as websocket:
# Send connection init (using standard X-API-Key format)
await websocket.send(json.dumps({
"type": "connection_init",
"payload": {
"headers": {
"X-API-Key": UNRAID_API_KEY
await websocket.send(
json.dumps(
{
"type": "connection_init",
"payload": {"headers": {"X-API-Key": UNRAID_API_KEY}},
}
}
}))
)
)
# Wait for ack
response = await websocket.recv()
@@ -78,11 +80,11 @@ def register_diagnostic_tools(mcp: FastMCP) -> None:
return {"error": f"Connection failed: {init_response}"}
# Send subscription
await websocket.send(json.dumps({
"id": "test",
"type": "start",
"payload": {"query": subscription_query}
}))
await websocket.send(
json.dumps(
{"id": "test", "type": "start", "payload": {"query": subscription_query}}
)
)
# Wait for response with timeout
try:
@@ -90,26 +92,19 @@ def register_diagnostic_tools(mcp: FastMCP) -> None:
result = json.loads(response)
logger.info(f"[TEST_SUBSCRIPTION] Response: {result}")
return {
"success": True,
"response": result,
"query_tested": subscription_query
}
return {"success": True, "response": result, "query_tested": subscription_query}
except TimeoutError:
return {
"success": True,
"response": "No immediate response (subscriptions may only send data on changes)",
"query_tested": subscription_query,
"note": "Connection successful, subscription may be waiting for events"
"note": "Connection successful, subscription may be waiting for events",
}
except Exception as e:
logger.error(f"[TEST_SUBSCRIPTION] Error: {e}", exc_info=True)
return {
"error": str(e),
"query_tested": subscription_query
}
return {"error": str(e), "query_tested": subscription_query}
@mcp.tool()
async def diagnose_subscriptions() -> dict[str, Any]:
@@ -140,25 +135,29 @@ def register_diagnostic_tools(mcp: FastMCP) -> None:
"max_reconnect_attempts": subscription_manager.max_reconnect_attempts,
"unraid_api_url": UNRAID_API_URL[:50] + "..." if UNRAID_API_URL else None,
"api_key_configured": bool(UNRAID_API_KEY),
"websocket_url": None
"websocket_url": None,
},
"subscriptions": status,
"summary": {
"total_configured": len(subscription_manager.subscription_configs),
"auto_start_count": sum(1 for s in subscription_manager.subscription_configs.values() if s.get("auto_start")),
"auto_start_count": sum(
1
for s in subscription_manager.subscription_configs.values()
if s.get("auto_start")
),
"active_count": len(subscription_manager.active_subscriptions),
"with_data": len(subscription_manager.resource_data),
"in_error_state": 0,
"connection_issues": connection_issues
}
"connection_issues": connection_issues,
},
}
# Calculate WebSocket URL
if UNRAID_API_URL:
if UNRAID_API_URL.startswith("https://"):
ws_url = "wss://" + UNRAID_API_URL[len("https://"):]
ws_url = "wss://" + UNRAID_API_URL[len("https://") :]
elif UNRAID_API_URL.startswith("http://"):
ws_url = "ws://" + UNRAID_API_URL[len("http://"):]
ws_url = "ws://" + UNRAID_API_URL[len("http://") :]
else:
ws_url = UNRAID_API_URL
if not ws_url.endswith("/graphql"):
@@ -174,42 +173,57 @@ def register_diagnostic_tools(mcp: FastMCP) -> None:
diagnostic_info["summary"]["in_error_state"] += 1
if runtime.get("last_error"):
connection_issues.append({
"subscription": sub_name,
"state": connection_state,
"error": runtime["last_error"]
})
connection_issues.append(
{
"subscription": sub_name,
"state": connection_state,
"error": runtime["last_error"],
}
)
# Add troubleshooting recommendations
recommendations: list[str] = []
if not diagnostic_info["environment"]["api_key_configured"]:
recommendations.append("CRITICAL: No API key configured. Set UNRAID_API_KEY environment variable.")
recommendations.append(
"CRITICAL: No API key configured. Set UNRAID_API_KEY environment variable."
)
if diagnostic_info["summary"]["in_error_state"] > 0:
recommendations.append("Some subscriptions are in error state. Check 'connection_issues' for details.")
recommendations.append(
"Some subscriptions are in error state. Check 'connection_issues' for details."
)
if diagnostic_info["summary"]["with_data"] == 0:
recommendations.append("No subscriptions have received data yet. Check WebSocket connectivity and authentication.")
recommendations.append(
"No subscriptions have received data yet. Check WebSocket connectivity and authentication."
)
if diagnostic_info["summary"]["active_count"] < diagnostic_info["summary"]["auto_start_count"]:
recommendations.append("Not all auto-start subscriptions are active. Check server startup logs.")
if (
diagnostic_info["summary"]["active_count"]
< diagnostic_info["summary"]["auto_start_count"]
):
recommendations.append(
"Not all auto-start subscriptions are active. Check server startup logs."
)
diagnostic_info["troubleshooting"] = {
"recommendations": recommendations,
"log_commands": [
"Check server logs for [WEBSOCKET:*], [AUTH:*], [SUBSCRIPTION:*] prefixed messages",
"Look for connection timeout or authentication errors",
"Verify Unraid API URL is accessible and supports GraphQL subscriptions"
"Verify Unraid API URL is accessible and supports GraphQL subscriptions",
],
"next_steps": [
"If authentication fails: Verify API key has correct permissions",
"If connection fails: Check network connectivity to Unraid server",
"If no data received: Enable DEBUG logging to see detailed protocol messages"
]
"If no data received: Enable DEBUG logging to see detailed protocol messages",
],
}
logger.info(f"[DIAGNOSTIC] Completed. Active: {diagnostic_info['summary']['active_count']}, With data: {diagnostic_info['summary']['with_data']}, Errors: {diagnostic_info['summary']['in_error_state']}")
logger.info(
f"[DIAGNOSTIC] Completed. Active: {diagnostic_info['summary']['active_count']}, With data: {diagnostic_info['summary']['with_data']}, Errors: {diagnostic_info['summary']['in_error_state']}"
)
return diagnostic_info
except Exception as e:

View File

@@ -30,7 +30,9 @@ class SubscriptionManager:
self.subscription_lock = asyncio.Lock()
# Configuration
self.auto_start_enabled = os.getenv("UNRAID_AUTO_START_SUBSCRIPTIONS", "true").lower() == "true"
self.auto_start_enabled = (
os.getenv("UNRAID_AUTO_START_SUBSCRIPTIONS", "true").lower() == "true"
)
self.reconnect_attempts: dict[str, int] = {}
self.max_reconnect_attempts = int(os.getenv("UNRAID_MAX_RECONNECT_ATTEMPTS", "10"))
self.connection_states: dict[str, str] = {} # Track connection state per subscription
@@ -50,12 +52,16 @@ class SubscriptionManager:
""",
"resource": "unraid://logs/stream",
"description": "Real-time log file streaming",
"auto_start": False # Started manually with path parameter
"auto_start": False, # Started manually with path parameter
}
}
logger.info(f"[SUBSCRIPTION_MANAGER] Initialized with auto_start={self.auto_start_enabled}, max_reconnects={self.max_reconnect_attempts}")
logger.debug(f"[SUBSCRIPTION_MANAGER] Available subscriptions: {list(self.subscription_configs.keys())}")
logger.info(
f"[SUBSCRIPTION_MANAGER] Initialized with auto_start={self.auto_start_enabled}, max_reconnects={self.max_reconnect_attempts}"
)
logger.debug(
f"[SUBSCRIPTION_MANAGER] Available subscriptions: {list(self.subscription_configs.keys())}"
)
async def auto_start_all_subscriptions(self) -> None:
"""Auto-start all subscriptions marked for auto-start."""
@@ -69,21 +75,31 @@ class SubscriptionManager:
for subscription_name, config in self.subscription_configs.items():
if config.get("auto_start", False):
try:
logger.info(f"[SUBSCRIPTION_MANAGER] Auto-starting subscription: {subscription_name}")
logger.info(
f"[SUBSCRIPTION_MANAGER] Auto-starting subscription: {subscription_name}"
)
await self.start_subscription(subscription_name, str(config["query"]))
auto_start_count += 1
except Exception as e:
logger.error(f"[SUBSCRIPTION_MANAGER] Failed to auto-start {subscription_name}: {e}")
logger.error(
f"[SUBSCRIPTION_MANAGER] Failed to auto-start {subscription_name}: {e}"
)
self.last_error[subscription_name] = str(e)
logger.info(f"[SUBSCRIPTION_MANAGER] Auto-start completed. Started {auto_start_count} subscriptions")
logger.info(
f"[SUBSCRIPTION_MANAGER] Auto-start completed. Started {auto_start_count} subscriptions"
)
async def start_subscription(self, subscription_name: str, query: str, variables: dict[str, Any] | None = None) -> None:
async def start_subscription(
self, subscription_name: str, query: str, variables: dict[str, Any] | None = None
) -> None:
"""Start a GraphQL subscription and maintain it as a resource."""
logger.info(f"[SUBSCRIPTION:{subscription_name}] Starting subscription...")
if subscription_name in self.active_subscriptions:
logger.warning(f"[SUBSCRIPTION:{subscription_name}] Subscription already active, skipping")
logger.warning(
f"[SUBSCRIPTION:{subscription_name}] Subscription already active, skipping"
)
return
# Reset connection tracking
@@ -92,12 +108,18 @@ class SubscriptionManager:
async with self.subscription_lock:
try:
task = asyncio.create_task(self._subscription_loop(subscription_name, query, variables or {}))
task = asyncio.create_task(
self._subscription_loop(subscription_name, query, variables or {})
)
self.active_subscriptions[subscription_name] = task
logger.info(f"[SUBSCRIPTION:{subscription_name}] Subscription task created and started")
logger.info(
f"[SUBSCRIPTION:{subscription_name}] Subscription task created and started"
)
self.connection_states[subscription_name] = "active"
except Exception as e:
logger.error(f"[SUBSCRIPTION:{subscription_name}] Failed to start subscription task: {e}")
logger.error(
f"[SUBSCRIPTION:{subscription_name}] Failed to start subscription task: {e}"
)
self.connection_states[subscription_name] = "failed"
self.last_error[subscription_name] = str(e)
raise
@@ -120,7 +142,9 @@ class SubscriptionManager:
else:
logger.warning(f"[SUBSCRIPTION:{subscription_name}] No active subscription to stop")
async def _subscription_loop(self, subscription_name: str, query: str, variables: dict[str, Any] | None) -> None:
async def _subscription_loop(
self, subscription_name: str, query: str, variables: dict[str, Any] | None
) -> None:
"""Main loop for maintaining a GraphQL subscription with comprehensive logging."""
retry_delay: int | float = 5
max_retry_delay = 300 # 5 minutes max
@@ -129,10 +153,14 @@ class SubscriptionManager:
attempt = self.reconnect_attempts.get(subscription_name, 0) + 1
self.reconnect_attempts[subscription_name] = attempt
logger.info(f"[WEBSOCKET:{subscription_name}] Connection attempt #{attempt} (max: {self.max_reconnect_attempts})")
logger.info(
f"[WEBSOCKET:{subscription_name}] Connection attempt #{attempt} (max: {self.max_reconnect_attempts})"
)
if attempt > self.max_reconnect_attempts:
logger.error(f"[WEBSOCKET:{subscription_name}] Max reconnection attempts ({self.max_reconnect_attempts}) exceeded, stopping")
logger.error(
f"[WEBSOCKET:{subscription_name}] Max reconnection attempts ({self.max_reconnect_attempts}) exceeded, stopping"
)
self.connection_states[subscription_name] = "max_retries_exceeded"
break
@@ -142,9 +170,9 @@ class SubscriptionManager:
raise ValueError("UNRAID_API_URL is not configured")
if UNRAID_API_URL.startswith("https://"):
ws_url = "wss://" + UNRAID_API_URL[len("https://"):]
ws_url = "wss://" + UNRAID_API_URL[len("https://") :]
elif UNRAID_API_URL.startswith("http://"):
ws_url = "ws://" + UNRAID_API_URL[len("http://"):]
ws_url = "ws://" + UNRAID_API_URL[len("http://") :]
else:
ws_url = UNRAID_API_URL
@@ -152,13 +180,17 @@ class SubscriptionManager:
ws_url = ws_url.rstrip("/") + "/graphql"
logger.debug(f"[WEBSOCKET:{subscription_name}] Connecting to: {ws_url}")
logger.debug(f"[WEBSOCKET:{subscription_name}] API Key present: {'Yes' if UNRAID_API_KEY else 'No'}")
logger.debug(
f"[WEBSOCKET:{subscription_name}] API Key present: {'Yes' if UNRAID_API_KEY else 'No'}"
)
ssl_context = build_ws_ssl_context(ws_url)
# Connection with timeout
connect_timeout = 10
logger.debug(f"[WEBSOCKET:{subscription_name}] Connection timeout: {connect_timeout}s")
logger.debug(
f"[WEBSOCKET:{subscription_name}] Connection timeout: {connect_timeout}s"
)
async with websockets.connect(
ws_url,
@@ -166,11 +198,12 @@ class SubscriptionManager:
ping_interval=20,
ping_timeout=10,
close_timeout=10,
ssl=ssl_context
ssl=ssl_context,
) as websocket:
selected_proto = websocket.subprotocol or "none"
logger.info(f"[WEBSOCKET:{subscription_name}] Connected! Protocol: {selected_proto}")
logger.info(
f"[WEBSOCKET:{subscription_name}] Connected! Protocol: {selected_proto}"
)
self.connection_states[subscription_name] = "connected"
# Reset retry count on successful connection
@@ -178,21 +211,21 @@ class SubscriptionManager:
retry_delay = 5 # Reset delay
# Initialize GraphQL-WS protocol
logger.debug(f"[PROTOCOL:{subscription_name}] Initializing GraphQL-WS protocol...")
logger.debug(
f"[PROTOCOL:{subscription_name}] Initializing GraphQL-WS protocol..."
)
init_type = "connection_init"
init_payload: dict[str, Any] = {"type": init_type}
if UNRAID_API_KEY:
logger.debug(f"[AUTH:{subscription_name}] Adding authentication payload")
# Use standard X-API-Key header format (matching HTTP client)
auth_payload = {
"headers": {
"X-API-Key": UNRAID_API_KEY
}
}
auth_payload = {"headers": {"X-API-Key": UNRAID_API_KEY}}
init_payload["payload"] = auth_payload
else:
logger.warning(f"[AUTH:{subscription_name}] No API key available for authentication")
logger.warning(
f"[AUTH:{subscription_name}] No API key available for authentication"
)
logger.debug(f"[PROTOCOL:{subscription_name}] Sending connection_init message")
await websocket.send(json.dumps(init_payload))
@@ -203,45 +236,66 @@ class SubscriptionManager:
try:
init_data = json.loads(init_raw)
logger.debug(f"[PROTOCOL:{subscription_name}] Received init response: {init_data.get('type')}")
logger.debug(
f"[PROTOCOL:{subscription_name}] Received init response: {init_data.get('type')}"
)
except json.JSONDecodeError as e:
init_preview = init_raw[:200] if isinstance(init_raw, str) else init_raw[:200].decode("utf-8", errors="replace")
logger.error(f"[PROTOCOL:{subscription_name}] Failed to decode init response: {init_preview}...")
init_preview = (
init_raw[:200]
if isinstance(init_raw, str)
else init_raw[:200].decode("utf-8", errors="replace")
)
logger.error(
f"[PROTOCOL:{subscription_name}] Failed to decode init response: {init_preview}..."
)
self.last_error[subscription_name] = f"Invalid JSON in init response: {e}"
break
# Handle connection acknowledgment
if init_data.get("type") == "connection_ack":
logger.info(f"[PROTOCOL:{subscription_name}] Connection acknowledged successfully")
logger.info(
f"[PROTOCOL:{subscription_name}] Connection acknowledged successfully"
)
self.connection_states[subscription_name] = "authenticated"
elif init_data.get("type") == "connection_error":
error_payload = init_data.get("payload", {})
logger.error(f"[AUTH:{subscription_name}] Authentication failed: {error_payload}")
self.last_error[subscription_name] = f"Authentication error: {error_payload}"
logger.error(
f"[AUTH:{subscription_name}] Authentication failed: {error_payload}"
)
self.last_error[subscription_name] = (
f"Authentication error: {error_payload}"
)
self.connection_states[subscription_name] = "auth_failed"
break
else:
logger.warning(f"[PROTOCOL:{subscription_name}] Unexpected init response: {init_data}")
logger.warning(
f"[PROTOCOL:{subscription_name}] Unexpected init response: {init_data}"
)
# Continue anyway - some servers send other messages first
# Start the subscription
logger.debug(f"[SUBSCRIPTION:{subscription_name}] Starting GraphQL subscription...")
start_type = "subscribe" if selected_proto == "graphql-transport-ws" else "start"
logger.debug(
f"[SUBSCRIPTION:{subscription_name}] Starting GraphQL subscription..."
)
start_type = (
"subscribe" if selected_proto == "graphql-transport-ws" else "start"
)
subscription_message = {
"id": subscription_name,
"type": start_type,
"payload": {
"query": query,
"variables": variables
}
"payload": {"query": query, "variables": variables},
}
logger.debug(f"[SUBSCRIPTION:{subscription_name}] Subscription message type: {start_type}")
logger.debug(
f"[SUBSCRIPTION:{subscription_name}] Subscription message type: {start_type}"
)
logger.debug(f"[SUBSCRIPTION:{subscription_name}] Query: {query[:100]}...")
logger.debug(f"[SUBSCRIPTION:{subscription_name}] Variables: {variables}")
await websocket.send(json.dumps(subscription_message))
logger.info(f"[SUBSCRIPTION:{subscription_name}] Subscription started successfully")
logger.info(
f"[SUBSCRIPTION:{subscription_name}] Subscription started successfully"
)
self.connection_states[subscription_name] = "subscribed"
# Listen for subscription data
@@ -253,57 +307,100 @@ class SubscriptionManager:
message_count += 1
message_type = data.get("type", "unknown")
logger.debug(f"[DATA:{subscription_name}] Message #{message_count}: {message_type}")
logger.debug(
f"[DATA:{subscription_name}] Message #{message_count}: {message_type}"
)
# Handle different message types
expected_data_type = "next" if selected_proto == "graphql-transport-ws" else "data"
expected_data_type = (
"next" if selected_proto == "graphql-transport-ws" else "data"
)
if data.get("type") == expected_data_type and data.get("id") == subscription_name:
if (
data.get("type") == expected_data_type
and data.get("id") == subscription_name
):
payload = data.get("payload", {})
if payload.get("data"):
logger.info(f"[DATA:{subscription_name}] Received subscription data update")
logger.info(
f"[DATA:{subscription_name}] Received subscription data update"
)
self.resource_data[subscription_name] = SubscriptionData(
data=payload["data"],
last_updated=datetime.now(),
subscription_type=subscription_name
subscription_type=subscription_name,
)
logger.debug(
f"[RESOURCE:{subscription_name}] Resource data updated successfully"
)
logger.debug(f"[RESOURCE:{subscription_name}] Resource data updated successfully")
elif payload.get("errors"):
logger.error(f"[DATA:{subscription_name}] GraphQL errors in response: {payload['errors']}")
self.last_error[subscription_name] = f"GraphQL errors: {payload['errors']}"
logger.error(
f"[DATA:{subscription_name}] GraphQL errors in response: {payload['errors']}"
)
self.last_error[subscription_name] = (
f"GraphQL errors: {payload['errors']}"
)
else:
logger.warning(f"[DATA:{subscription_name}] Empty or invalid data payload: {payload}")
logger.warning(
f"[DATA:{subscription_name}] Empty or invalid data payload: {payload}"
)
elif data.get("type") == "ping":
logger.debug(f"[PROTOCOL:{subscription_name}] Received ping, sending pong")
logger.debug(
f"[PROTOCOL:{subscription_name}] Received ping, sending pong"
)
await websocket.send(json.dumps({"type": "pong"}))
elif data.get("type") == "error":
error_payload = data.get("payload", {})
logger.error(f"[SUBSCRIPTION:{subscription_name}] Subscription error: {error_payload}")
self.last_error[subscription_name] = f"Subscription error: {error_payload}"
logger.error(
f"[SUBSCRIPTION:{subscription_name}] Subscription error: {error_payload}"
)
self.last_error[subscription_name] = (
f"Subscription error: {error_payload}"
)
self.connection_states[subscription_name] = "error"
elif data.get("type") == "complete":
logger.info(f"[SUBSCRIPTION:{subscription_name}] Subscription completed by server")
logger.info(
f"[SUBSCRIPTION:{subscription_name}] Subscription completed by server"
)
self.connection_states[subscription_name] = "completed"
break
elif data.get("type") in ["ka", "ping", "pong"]:
logger.debug(f"[PROTOCOL:{subscription_name}] Keepalive message: {message_type}")
logger.debug(
f"[PROTOCOL:{subscription_name}] Keepalive message: {message_type}"
)
else:
logger.debug(f"[PROTOCOL:{subscription_name}] Unhandled message type: {message_type}")
logger.debug(
f"[PROTOCOL:{subscription_name}] Unhandled message type: {message_type}"
)
except json.JSONDecodeError as e:
msg_preview = message[:200] if isinstance(message, str) else message[:200].decode("utf-8", errors="replace")
logger.error(f"[PROTOCOL:{subscription_name}] Failed to decode message: {msg_preview}...")
msg_preview = (
message[:200]
if isinstance(message, str)
else message[:200].decode("utf-8", errors="replace")
)
logger.error(
f"[PROTOCOL:{subscription_name}] Failed to decode message: {msg_preview}..."
)
logger.error(f"[PROTOCOL:{subscription_name}] JSON decode error: {e}")
except Exception as e:
logger.error(f"[DATA:{subscription_name}] Error processing message: {e}")
msg_preview = message[:200] if isinstance(message, str) else message[:200].decode("utf-8", errors="replace")
logger.debug(f"[DATA:{subscription_name}] Raw message: {msg_preview}...")
logger.error(
f"[DATA:{subscription_name}] Error processing message: {e}"
)
msg_preview = (
message[:200]
if isinstance(message, str)
else message[:200].decode("utf-8", errors="replace")
)
logger.debug(
f"[DATA:{subscription_name}] Raw message: {msg_preview}..."
)
except TimeoutError:
error_msg = "Connection or authentication timeout"
@@ -332,7 +429,9 @@ class SubscriptionManager:
# Calculate backoff delay
retry_delay = min(retry_delay * 1.5, max_retry_delay)
logger.info(f"[WEBSOCKET:{subscription_name}] Reconnecting in {retry_delay:.1f} seconds...")
logger.info(
f"[WEBSOCKET:{subscription_name}] Reconnecting in {retry_delay:.1f} seconds..."
)
self.connection_states[subscription_name] = "reconnecting"
await asyncio.sleep(retry_delay)
@@ -363,14 +462,14 @@ class SubscriptionManager:
"config": {
"resource": config["resource"],
"description": config["description"],
"auto_start": config.get("auto_start", False)
"auto_start": config.get("auto_start", False),
},
"runtime": {
"active": sub_name in self.active_subscriptions,
"connection_state": self.connection_states.get(sub_name, "not_started"),
"reconnect_attempts": self.reconnect_attempts.get(sub_name, 0),
"last_error": self.last_error.get(sub_name, None)
}
"last_error": self.last_error.get(sub_name, None),
},
}
# Add data info if available
@@ -380,7 +479,7 @@ class SubscriptionManager:
sub_status["data"] = {
"available": True,
"last_updated": data_info.last_updated.isoformat(),
"age_seconds": age_seconds
"age_seconds": age_seconds,
}
else:
sub_status["data"] = {"available": False}

View File

@@ -59,7 +59,9 @@ async def autostart_subscriptions() -> None:
logger.info(f"[AUTOSTART] Starting log file subscription for: {log_path}")
config = subscription_manager.subscription_configs.get("logFileSubscription")
if config:
await subscription_manager.start_subscription("logFileSubscription", str(config["query"]), {"path": log_path})
await subscription_manager.start_subscription(
"logFileSubscription", str(config["query"]), {"path": log_path}
)
logger.info(f"[AUTOSTART] Log file subscription started for: {log_path}")
else:
logger.error("[AUTOSTART] logFileSubscription config not found")
@@ -83,9 +85,11 @@ def register_subscription_resources(mcp: FastMCP) -> None:
data = subscription_manager.get_resource_data("logFileSubscription")
if data:
return json.dumps(data, indent=2)
return json.dumps({
"status": "No subscription data yet",
"message": "Subscriptions auto-start on server boot. If this persists, check server logs for WebSocket/auth issues."
})
return json.dumps(
{
"status": "No subscription data yet",
"message": "Subscriptions auto-start on server boot. If this persists, check server logs for WebSocket/auth issues.",
}
)
logger.info("Subscription resources registered successfully")

View File

@@ -1,7 +1,6 @@
"""Array operations and system power management.
"""Array parity check operations.
Provides the `unraid_array` tool with 12 actions for array lifecycle,
parity operations, disk management, and system power control.
Provides the `unraid_array` tool with 5 actions for parity check management.
"""
from typing import Any, Literal
@@ -22,16 +21,6 @@ QUERIES: dict[str, str] = {
}
MUTATIONS: dict[str, str] = {
"start": """
mutation StartArray {
setState(input: { desiredState: STARTED }) { state }
}
""",
"stop": """
mutation StopArray {
setState(input: { desiredState: STOPPED }) { state }
}
""",
"parity_start": """
mutation StartParityCheck($correct: Boolean) {
parityCheck { start(correct: $correct) }
@@ -52,42 +41,16 @@ MUTATIONS: dict[str, str] = {
parityCheck { cancel }
}
""",
"mount_disk": """
mutation MountDisk($id: PrefixedID!) {
mountArrayDisk(id: $id)
}
""",
"unmount_disk": """
mutation UnmountDisk($id: PrefixedID!) {
unmountArrayDisk(id: $id)
}
""",
"clear_stats": """
mutation ClearStats($id: PrefixedID!) {
clearArrayDiskStatistics(id: $id)
}
""",
"shutdown": """
mutation Shutdown {
shutdown
}
""",
"reboot": """
mutation Reboot {
reboot
}
""",
}
DESTRUCTIVE_ACTIONS = {"start", "stop", "shutdown", "reboot"}
DISK_ACTIONS = {"mount_disk", "unmount_disk", "clear_stats"}
ALL_ACTIONS = set(QUERIES) | set(MUTATIONS)
ARRAY_ACTIONS = Literal[
"start", "stop",
"parity_start", "parity_pause", "parity_resume", "parity_cancel", "parity_status",
"mount_disk", "unmount_disk", "clear_stats",
"shutdown", "reboot",
"parity_start",
"parity_pause",
"parity_resume",
"parity_cancel",
"parity_status",
]
@@ -97,52 +60,31 @@ def register_array_tool(mcp: FastMCP) -> None:
@mcp.tool()
async def unraid_array(
action: ARRAY_ACTIONS,
confirm: bool = False,
disk_id: str | None = None,
correct: bool | None = None,
) -> dict[str, Any]:
"""Manage the Unraid array and system power.
"""Manage Unraid array parity checks.
Actions:
start - Start the array (destructive, requires confirm=True)
stop - Stop the array (destructive, requires confirm=True)
parity_start - Start parity check (optional correct=True to fix errors)
parity_pause - Pause running parity check
parity_resume - Resume paused parity check
parity_cancel - Cancel running parity check
parity_status - Get current parity check status
mount_disk - Mount an array disk (requires disk_id)
unmount_disk - Unmount an array disk (requires disk_id)
clear_stats - Clear disk statistics (requires disk_id)
shutdown - Shut down the server (destructive, requires confirm=True)
reboot - Reboot the server (destructive, requires confirm=True)
"""
if action not in ALL_ACTIONS:
raise ToolError(f"Invalid action '{action}'. Must be one of: {sorted(ALL_ACTIONS)}")
if action in DESTRUCTIVE_ACTIONS and not confirm:
raise ToolError(
f"Action '{action}' is destructive. Set confirm=True to proceed."
)
if action in DISK_ACTIONS and not disk_id:
raise ToolError(f"disk_id is required for '{action}' action")
try:
logger.info(f"Executing unraid_array action={action}")
# Read-only query
if action in QUERIES:
data = await make_graphql_request(QUERIES[action])
return {"success": True, "action": action, "data": data}
# Mutations
query = MUTATIONS[action]
variables: dict[str, Any] | None = None
if action in DISK_ACTIONS:
variables = {"id": disk_id}
elif action == "parity_start" and correct is not None:
if action == "parity_start" and correct is not None:
variables = {"correct": correct}
data = await make_graphql_request(query, variables)

View File

@@ -99,13 +99,35 @@ MUTATIONS: dict[str, str] = {
}
DESTRUCTIVE_ACTIONS = {"remove"}
_ACTIONS_REQUIRING_CONTAINER_ID = {"start", "stop", "restart", "pause", "unpause", "remove", "update", "details", "logs"}
_ACTIONS_REQUIRING_CONTAINER_ID = {
"start",
"stop",
"restart",
"pause",
"unpause",
"remove",
"update",
"details",
"logs",
}
ALL_ACTIONS = set(QUERIES) | set(MUTATIONS) | {"restart"}
DOCKER_ACTIONS = Literal[
"list", "details", "start", "stop", "restart", "pause", "unpause",
"remove", "update", "update_all", "logs",
"networks", "network_details", "port_conflicts", "check_updates",
"list",
"details",
"start",
"stop",
"restart",
"pause",
"unpause",
"remove",
"update",
"update_all",
"logs",
"networks",
"network_details",
"port_conflicts",
"check_updates",
]
# Docker container IDs: 64 hex chars + optional suffix (e.g., ":local")
@@ -246,9 +268,7 @@ def register_docker_tool(mcp: FastMCP) -> None:
return {"networks": list(networks) if isinstance(networks, list) else []}
if action == "network_details":
data = await make_graphql_request(
QUERIES["network_details"], {"id": network_id}
)
data = await make_graphql_request(QUERIES["network_details"], {"id": network_id})
return dict(data.get("dockerNetwork", {}))
if action == "port_conflicts":
@@ -266,13 +286,15 @@ def register_docker_tool(mcp: FastMCP) -> None:
actual_id = await _resolve_container_id(container_id or "")
# Stop (idempotent: treat "already stopped" as success)
stop_data = await make_graphql_request(
MUTATIONS["stop"], {"id": actual_id},
MUTATIONS["stop"],
{"id": actual_id},
operation_context={"operation": "stop"},
)
stop_was_idempotent = stop_data.get("idempotent_success", False)
# Start (idempotent: treat "already running" as success)
start_data = await make_graphql_request(
MUTATIONS["start"], {"id": actual_id},
MUTATIONS["start"],
{"id": actual_id},
operation_context={"operation": "start"},
)
if start_data.get("idempotent_success"):
@@ -280,7 +302,9 @@ def register_docker_tool(mcp: FastMCP) -> None:
else:
result = start_data.get("docker", {}).get("start", {})
response: dict[str, Any] = {
"success": True, "action": "restart", "container": result,
"success": True,
"action": "restart",
"container": result,
}
if stop_was_idempotent:
response["note"] = "Container was already stopped before restart"
@@ -294,9 +318,12 @@ def register_docker_tool(mcp: FastMCP) -> None:
# Single-container mutations
if action in MUTATIONS:
actual_id = await _resolve_container_id(container_id or "")
op_context: dict[str, str] | None = {"operation": action} if action in ("start", "stop") else None
op_context: dict[str, str] | None = (
{"operation": action} if action in ("start", "stop") else None
)
data = await make_graphql_request(
MUTATIONS[action], {"id": actual_id},
MUTATIONS[action],
{"id": actual_id},
operation_context=op_context,
)

View File

@@ -247,11 +247,13 @@ async def _diagnose_subscriptions() -> dict[str, Any]:
if conn_state in ("error", "auth_failed", "timeout", "max_retries_exceeded"):
diagnostic_info["summary"]["in_error_state"] += 1
if runtime.get("last_error"):
connection_issues.append({
"subscription": sub_name,
"state": conn_state,
"error": runtime["last_error"],
})
connection_issues.append(
{
"subscription": sub_name,
"state": conn_state,
"error": runtime["last_error"],
}
)
return diagnostic_info

View File

@@ -157,10 +157,25 @@ QUERIES: dict[str, str] = {
}
INFO_ACTIONS = Literal[
"overview", "array", "network", "registration", "connect", "variables",
"metrics", "services", "display", "config", "online", "owner",
"settings", "server", "servers", "flash",
"ups_devices", "ups_device", "ups_config",
"overview",
"array",
"network",
"registration",
"connect",
"variables",
"metrics",
"services",
"display",
"config",
"online",
"owner",
"settings",
"server",
"servers",
"flash",
"ups_devices",
"ups_device",
"ups_config",
]
assert set(QUERIES.keys()) == set(INFO_ACTIONS.__args__), (
@@ -209,7 +224,15 @@ def _process_system_info(raw_info: dict[str, Any]) -> dict[str, Any]:
def _analyze_disk_health(disks: list[dict[str, Any]]) -> dict[str, int]:
"""Analyze health status of disk arrays."""
counts = {"healthy": 0, "failed": 0, "missing": 0, "new": 0, "warning": 0, "critical": 0, "unknown": 0}
counts = {
"healthy": 0,
"failed": 0,
"missing": 0,
"new": 0,
"warning": 0,
"critical": 0,
"unknown": 0,
}
for disk in disks:
status = disk.get("status", "").upper()
warning = disk.get("warning")
@@ -263,7 +286,11 @@ def _process_array_status(raw: dict[str, Any]) -> dict[str, Any]:
summary["num_cache_pools"] = len(raw.get("caches", []))
health_summary: dict[str, Any] = {}
for key, label in [("parities", "parity_health"), ("disks", "data_health"), ("caches", "cache_health")]:
for key, label in [
("parities", "parity_health"),
("disks", "data_health"),
("caches", "cache_health"),
]:
if raw.get(key):
health_summary[label] = _analyze_disk_health(raw[key])
@@ -377,10 +404,14 @@ def register_info_tool(mcp: FastMCP) -> None:
if action == "settings":
settings = data.get("settings") or {}
if not settings:
raise ToolError("No settings data returned from Unraid API. Check API permissions.")
raise ToolError(
"No settings data returned from Unraid API. Check API permissions."
)
if not settings.get("unified"):
logger.warning(f"Settings returned unexpected structure: {settings.keys()}")
raise ToolError(f"Unexpected settings structure. Expected 'unified' key, got: {list(settings.keys())}")
raise ToolError(
f"Unexpected settings structure. Expected 'unified' key, got: {list(settings.keys())}"
)
values = settings["unified"].get("values") or {}
return dict(values) if isinstance(values, dict) else {"raw": values}

View File

@@ -47,7 +47,11 @@ MUTATIONS: dict[str, str] = {
DESTRUCTIVE_ACTIONS = {"delete"}
KEY_ACTIONS = Literal[
"list", "get", "create", "update", "delete",
"list",
"get",
"create",
"update",
"delete",
]
@@ -101,9 +105,7 @@ def register_keys_tool(mcp: FastMCP) -> None:
input_data["roles"] = roles
if permissions:
input_data["permissions"] = permissions
data = await make_graphql_request(
MUTATIONS["create"], {"input": input_data}
)
data = await make_graphql_request(MUTATIONS["create"], {"input": input_data})
return {
"success": True,
"key": data.get("createApiKey", {}),
@@ -117,9 +119,7 @@ def register_keys_tool(mcp: FastMCP) -> None:
input_data["name"] = name
if roles:
input_data["roles"] = roles
data = await make_graphql_request(
MUTATIONS["update"], {"input": input_data}
)
data = await make_graphql_request(MUTATIONS["update"], {"input": input_data})
return {
"success": True,
"key": data.get("updateApiKey", {}),
@@ -128,12 +128,12 @@ def register_keys_tool(mcp: FastMCP) -> None:
if action == "delete":
if not key_id:
raise ToolError("key_id is required for 'delete' action")
data = await make_graphql_request(
MUTATIONS["delete"], {"input": {"ids": [key_id]}}
)
data = await make_graphql_request(MUTATIONS["delete"], {"input": {"ids": [key_id]}})
result = data.get("deleteApiKeys")
if not result:
raise ToolError(f"Failed to delete API key '{key_id}': no confirmation from server")
raise ToolError(
f"Failed to delete API key '{key_id}': no confirmation from server"
)
return {
"success": True,
"message": f"API key '{key_id}' deleted",

View File

@@ -78,8 +78,15 @@ MUTATIONS: dict[str, str] = {
DESTRUCTIVE_ACTIONS = {"delete", "delete_archived"}
NOTIFICATION_ACTIONS = Literal[
"overview", "list", "warnings",
"create", "archive", "unread", "delete", "delete_archived", "archive_all",
"overview",
"list",
"warnings",
"create",
"archive",
"unread",
"delete",
"delete_archived",
"archive_all",
]
@@ -115,7 +122,9 @@ def register_notifications_tool(mcp: FastMCP) -> None:
"""
all_actions = {**QUERIES, **MUTATIONS}
if action not in all_actions:
raise ToolError(f"Invalid action '{action}'. Must be one of: {list(all_actions.keys())}")
raise ToolError(
f"Invalid action '{action}'. Must be one of: {list(all_actions.keys())}"
)
if action in DESTRUCTIVE_ACTIONS and not confirm:
raise ToolError(f"Action '{action}' is destructive. Set confirm=True to proceed.")
@@ -136,9 +145,7 @@ def register_notifications_tool(mcp: FastMCP) -> None:
}
if importance:
filter_vars["importance"] = importance.upper()
data = await make_graphql_request(
QUERIES["list"], {"filter": filter_vars}
)
data = await make_graphql_request(QUERIES["list"], {"filter": filter_vars})
notifications = data.get("notifications", {})
result = notifications.get("list", [])
return {"notifications": list(result) if isinstance(result, list) else []}
@@ -151,33 +158,25 @@ def register_notifications_tool(mcp: FastMCP) -> None:
if action == "create":
if title is None or subject is None or description is None or importance is None:
raise ToolError(
"create requires title, subject, description, and importance"
)
raise ToolError("create requires title, subject, description, and importance")
input_data = {
"title": title,
"subject": subject,
"description": description,
"importance": importance.upper(),
}
data = await make_graphql_request(
MUTATIONS["create"], {"input": input_data}
)
data = await make_graphql_request(MUTATIONS["create"], {"input": input_data})
return {"success": True, "data": data}
if action in ("archive", "unread"):
if not notification_id:
raise ToolError(f"notification_id is required for '{action}' action")
data = await make_graphql_request(
MUTATIONS[action], {"id": notification_id}
)
data = await make_graphql_request(MUTATIONS[action], {"id": notification_id})
return {"success": True, "action": action, "data": data}
if action == "delete":
if not notification_id or not notification_type:
raise ToolError(
"delete requires notification_id and notification_type"
)
raise ToolError("delete requires notification_id and notification_type")
data = await make_graphql_request(
MUTATIONS["delete"],
{"id": notification_id, "type": notification_type.upper()},

View File

@@ -43,7 +43,10 @@ DESTRUCTIVE_ACTIONS = {"delete_remote"}
ALL_ACTIONS = set(QUERIES) | set(MUTATIONS)
RCLONE_ACTIONS = Literal[
"list_remotes", "config_form", "create_remote", "delete_remote",
"list_remotes",
"config_form",
"create_remote",
"delete_remote",
]
@@ -84,9 +87,7 @@ def register_rclone_tool(mcp: FastMCP) -> None:
variables: dict[str, Any] = {}
if provider_type:
variables["formOptions"] = {"providerType": provider_type}
data = await make_graphql_request(
QUERIES["config_form"], variables or None
)
data = await make_graphql_request(QUERIES["config_form"], variables or None)
form = data.get("rclone", {}).get("configForm", {})
if not form:
raise ToolError("No RClone config form data received")
@@ -94,16 +95,16 @@ def register_rclone_tool(mcp: FastMCP) -> None:
if action == "create_remote":
if name is None or provider_type is None or config_data is None:
raise ToolError(
"create_remote requires name, provider_type, and config_data"
)
raise ToolError("create_remote requires name, provider_type, and config_data")
data = await make_graphql_request(
MUTATIONS["create_remote"],
{"input": {"name": name, "type": provider_type, "config": config_data}},
)
remote = data.get("rclone", {}).get("createRCloneRemote")
if not remote:
raise ToolError(f"Failed to create remote '{name}': no confirmation from server")
raise ToolError(
f"Failed to create remote '{name}': no confirmation from server"
)
return {
"success": True,
"message": f"Remote '{name}' created successfully",

View File

@@ -57,7 +57,12 @@ QUERIES: dict[str, str] = {
}
STORAGE_ACTIONS = Literal[
"shares", "disks", "disk_details", "unassigned", "log_files", "logs",
"shares",
"disks",
"disk_details",
"unassigned",
"log_files",
"logs",
]

View File

@@ -1,7 +1,7 @@
"""User management.
"""User account query.
Provides the `unraid_users` tool with 8 actions for managing users,
cloud access, remote access settings, and allowed origins.
Provides the `unraid_users` tool with 1 action for querying the current authenticated user.
Note: Unraid GraphQL API does not support user management operations (list, add, delete).
"""
from typing import Any, Literal
@@ -19,146 +19,37 @@ QUERIES: dict[str, str] = {
me { id name description roles }
}
""",
"list": """
query ListUsers {
users { id name description roles }
}
""",
"get": """
query GetUser($id: ID!) {
user(id: $id) { id name description roles }
}
""",
"cloud": """
query GetCloud {
cloud { status error }
}
""",
"remote_access": """
query GetRemoteAccess {
remoteAccess { enabled url }
}
""",
"origins": """
query GetAllowedOrigins {
allowedOrigins
}
""",
}
MUTATIONS: dict[str, str] = {
"add": """
mutation AddUser($input: addUserInput!) {
addUser(input: $input) { id name description roles }
}
""",
"delete": """
mutation DeleteUser($input: deleteUserInput!) {
deleteUser(input: $input) { id name }
}
""",
}
ALL_ACTIONS = set(QUERIES)
DESTRUCTIVE_ACTIONS = {"delete"}
USER_ACTIONS = Literal[
"me", "list", "get", "add", "delete", "cloud", "remote_access", "origins",
]
USER_ACTIONS = Literal["me"]
def register_users_tool(mcp: FastMCP) -> None:
"""Register the unraid_users tool with the FastMCP instance."""
@mcp.tool()
async def unraid_users(
action: USER_ACTIONS,
confirm: bool = False,
user_id: str | None = None,
name: str | None = None,
password: str | None = None,
role: str | None = None,
) -> dict[str, Any]:
"""Manage Unraid users and access settings.
async def unraid_users(action: USER_ACTIONS = "me") -> dict[str, Any]:
"""Query current authenticated user.
Actions:
me - Get current authenticated user info
list - List all users
get - Get a specific user (requires user_id)
add - Add a new user (requires name, password; optional role)
delete - Delete a user (requires user_id, confirm=True)
cloud - Get Unraid Connect cloud status
remote_access - Get remote access settings
origins - Get allowed origins
"""
all_actions = set(QUERIES) | set(MUTATIONS)
if action not in all_actions:
raise ToolError(f"Invalid action '{action}'. Must be one of: {sorted(all_actions)}")
me - Get current authenticated user info (id, name, description, roles)
if action in DESTRUCTIVE_ACTIONS and not confirm:
raise ToolError(f"Action '{action}' is destructive. Set confirm=True to proceed.")
Note: Unraid API does not support user management operations (list, add, delete).
"""
if action not in ALL_ACTIONS:
raise ToolError(f"Invalid action '{action}'. Must be: me")
try:
logger.info(f"Executing unraid_users action={action}")
if action == "me":
data = await make_graphql_request(QUERIES["me"])
return data.get("me") or {}
if action == "list":
data = await make_graphql_request(QUERIES["list"])
users = data.get("users", [])
return {"users": list(users) if isinstance(users, list) else []}
if action == "get":
if not user_id:
raise ToolError("user_id is required for 'get' action")
data = await make_graphql_request(QUERIES["get"], {"id": user_id})
return data.get("user") or {}
if action == "add":
if not name or not password:
raise ToolError("add requires name and password")
input_data: dict[str, Any] = {"name": name, "password": password}
if role:
input_data["role"] = role.upper()
data = await make_graphql_request(
MUTATIONS["add"], {"input": input_data}
)
return {
"success": True,
"user": data.get("addUser", {}),
}
if action == "delete":
if not user_id:
raise ToolError("user_id is required for 'delete' action")
data = await make_graphql_request(
MUTATIONS["delete"], {"input": {"id": user_id}}
)
return {
"success": True,
"message": f"User '{user_id}' deleted",
}
if action == "cloud":
data = await make_graphql_request(QUERIES["cloud"])
return data.get("cloud") or {}
if action == "remote_access":
data = await make_graphql_request(QUERIES["remote_access"])
return data.get("remoteAccess") or {}
if action == "origins":
data = await make_graphql_request(QUERIES["origins"])
origins = data.get("allowedOrigins", [])
return {"origins": list(origins) if isinstance(origins, list) else []}
raise ToolError(f"Unhandled action '{action}' — this is a bug")
logger.info("Executing unraid_users action=me")
data = await make_graphql_request(QUERIES["me"])
return data.get("me") or {}
except ToolError:
raise
except Exception as e:
logger.error(f"Error in unraid_users action={action}: {e}", exc_info=True)
raise ToolError(f"Failed to execute users/{action}: {e!s}") from e
logger.error(f"Error in unraid_users action=me: {e}", exc_info=True)
raise ToolError(f"Failed to execute users/me: {e!s}") from e
logger.info("Users tool registered successfully")

View File

@@ -53,8 +53,15 @@ _MUTATION_FIELDS: dict[str, str] = {
DESTRUCTIVE_ACTIONS = {"force_stop", "reset"}
VM_ACTIONS = Literal[
"list", "details",
"start", "stop", "pause", "resume", "force_stop", "reboot", "reset",
"list",
"details",
"start",
"stop",
"pause",
"resume",
"force_stop",
"reboot",
"reset",
]
@@ -111,21 +118,15 @@ def register_vm_tool(mcp: FastMCP) -> None:
or vm.get("name") == vm_id
):
return dict(vm)
available = [
f"{v.get('name')} (UUID: {v.get('uuid')})" for v in vms
]
raise ToolError(
f"VM '{vm_id}' not found. Available: {', '.join(available)}"
)
available = [f"{v.get('name')} (UUID: {v.get('uuid')})" for v in vms]
raise ToolError(f"VM '{vm_id}' not found. Available: {', '.join(available)}")
if action == "details":
raise ToolError("No VM data returned from server")
return {"vms": []}
# Mutations
if action in MUTATIONS:
data = await make_graphql_request(
MUTATIONS[action], {"id": vm_id}
)
data = await make_graphql_request(MUTATIONS[action], {"id": vm_id})
field = _MUTATION_FIELDS.get(action, action)
if data.get("vm") and field in data["vm"]:
return {