mirror of
https://github.com/jmagar/unraid-mcp.git
synced 2026-03-01 16:04:24 -08:00
feat: harden API safety and expand command docs with full test coverage
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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']}")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user