forked from HomeLab/unraid-mcp
Addresses all critical, high, medium, and low issues from full codebase review. 494 tests pass, ruff clean, ty type-check clean. Security: - Add tool_error_handler context manager (exceptions.py) — standardised error handling, eliminates 11 bare except-reraise patterns - Remove unused exception subclasses (ConfigurationError, UnraidAPIError, SubscriptionError, ValidationError, IdempotentOperationError) - Harden GraphQL subscription query validator with allow-list and forbidden-keyword regex (diagnostics.py) - Add input validation for rclone create_remote config_data: injection, path-traversal, and key-count limits (rclone.py) - Validate notifications importance enum before GraphQL request (notifications.py) - Sanitise HTTP/network/JSON error messages — no raw exception strings leaked to clients (client.py) - Strip path/creds from displayed API URL via _safe_display_url (health.py) - Enable Ruff S (bandit) rule category in pyproject.toml - Harden container mutations to strict-only matching — no fuzzy/substring for destructive operations (docker.py) Performance: - Token-bucket rate limiter (90 tokens, 9 req/s) with 429 retry backoff (client.py) - Lazy asyncio.Lock init via _get_client_lock() — fixes event-loop module-load crash (client.py) - Double-checked locking in get_http_client() for fast-path (client.py) - Short hex container ID fast-path skips list fetch (docker.py) - Cap resource_data log content to 1 MB / 5,000 lines (manager.py) - Reset reconnect counter after 30 s stable connection (manager.py) - Move tail_lines validation to module level; enforce 10,000 line cap (storage.py, docker.py) - force_terminal=True removed from logging RichHandler (logging.py) Architecture: - Register diagnostic tools in server startup (server.py) - Move ALL_ACTIONS computation to module level in all tools - Consolidate format_kb / format_bytes into shared core/utils.py - Add _safe_get() helper in core/utils.py for nested dict traversal - Extract _analyze_subscription_status() from health.py diagnose handler - Validate required config at startup — fail fast with CRITICAL log (server.py) Code quality: - Remove ~90 lines of dead Rich formatting helpers from logging.py - Remove dead self.websocket attribute from SubscriptionManager - Remove dead setup_uvicorn_logging() wrapper - Move _VALID_IMPORTANCE to module level (N806 fix) - Add slots=True to all three dataclasses (SubscriptionData, SystemHealth, APIResponse) - Fix None rendering as literal "None" string in info.py summaries - Change fuzzy-match log messages from INFO to DEBUG (docker.py) - UTC-aware datetimes throughout (manager.py, diagnostics.py) Infrastructure: - Upgrade base image python:3.11-slim → python:3.12-slim (Dockerfile) - Add non-root appuser (UID/GID 1000) with HEALTHCHECK (Dockerfile) - Add read_only, cap_drop: ALL, tmpfs /tmp to docker-compose.yml - Single-source version via importlib.metadata (pyproject.toml → __init__.py) - Add open_timeout to all websockets.connect() calls Tests: - Update error message matchers to match sanitised messages (test_client.py) - Fix patch targets for UNRAID_API_URL → utils module (test_subscriptions.py) - Fix importance="info" → importance="normal" (test_notifications.py, http_layer) - Fix naive datetime fixtures → UTC-aware (test_subscriptions.py) Co-authored-by: Claude <claude@anthropic.com>
210 lines
8.1 KiB
Python
210 lines
8.1 KiB
Python
"""Notification management.
|
|
|
|
Provides the `unraid_notifications` tool with 9 actions for viewing,
|
|
creating, archiving, and deleting system notifications.
|
|
"""
|
|
|
|
from typing import Any, Literal
|
|
|
|
from fastmcp import FastMCP
|
|
|
|
from ..config.logging import logger
|
|
from ..core.client import make_graphql_request
|
|
from ..core.exceptions import ToolError, tool_error_handler
|
|
|
|
|
|
QUERIES: dict[str, str] = {
|
|
"overview": """
|
|
query GetNotificationsOverview {
|
|
notifications {
|
|
overview {
|
|
unread { info warning alert total }
|
|
archive { info warning alert total }
|
|
}
|
|
}
|
|
}
|
|
""",
|
|
"list": """
|
|
query ListNotifications($filter: NotificationFilter!) {
|
|
notifications {
|
|
list(filter: $filter) {
|
|
id title subject description importance link type timestamp formattedTimestamp
|
|
}
|
|
}
|
|
}
|
|
""",
|
|
"warnings": """
|
|
query GetWarningsAndAlerts {
|
|
notifications {
|
|
warningsAndAlerts { id title subject description importance type timestamp }
|
|
}
|
|
}
|
|
""",
|
|
}
|
|
|
|
MUTATIONS: dict[str, str] = {
|
|
"create": """
|
|
mutation CreateNotification($input: CreateNotificationInput!) {
|
|
notifications { createNotification(input: $input) { id title importance } }
|
|
}
|
|
""",
|
|
"archive": """
|
|
mutation ArchiveNotification($id: PrefixedID!) {
|
|
notifications { archiveNotification(id: $id) }
|
|
}
|
|
""",
|
|
"unread": """
|
|
mutation UnreadNotification($id: PrefixedID!) {
|
|
notifications { unreadNotification(id: $id) }
|
|
}
|
|
""",
|
|
"delete": """
|
|
mutation DeleteNotification($id: PrefixedID!, $type: NotificationType!) {
|
|
notifications { deleteNotification(id: $id, type: $type) }
|
|
}
|
|
""",
|
|
"delete_archived": """
|
|
mutation DeleteArchivedNotifications {
|
|
notifications { deleteArchivedNotifications }
|
|
}
|
|
""",
|
|
"archive_all": """
|
|
mutation ArchiveAllNotifications($importance: NotificationImportance) {
|
|
notifications { archiveAll(importance: $importance) }
|
|
}
|
|
""",
|
|
}
|
|
|
|
DESTRUCTIVE_ACTIONS = {"delete", "delete_archived"}
|
|
ALL_ACTIONS = set(QUERIES) | set(MUTATIONS)
|
|
_VALID_IMPORTANCE = {"ALERT", "WARNING", "NORMAL"}
|
|
|
|
NOTIFICATION_ACTIONS = Literal[
|
|
"overview",
|
|
"list",
|
|
"warnings",
|
|
"create",
|
|
"archive",
|
|
"unread",
|
|
"delete",
|
|
"delete_archived",
|
|
"archive_all",
|
|
]
|
|
|
|
|
|
def register_notifications_tool(mcp: FastMCP) -> None:
|
|
"""Register the unraid_notifications tool with the FastMCP instance."""
|
|
|
|
@mcp.tool()
|
|
async def unraid_notifications(
|
|
action: NOTIFICATION_ACTIONS,
|
|
confirm: bool = False,
|
|
notification_id: str | None = None,
|
|
notification_type: str | None = None,
|
|
importance: str | None = None,
|
|
offset: int = 0,
|
|
limit: int = 20,
|
|
list_type: str = "UNREAD",
|
|
title: str | None = None,
|
|
subject: str | None = None,
|
|
description: str | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Manage Unraid system notifications.
|
|
|
|
Actions:
|
|
overview - Notification counts by severity (unread/archive)
|
|
list - List notifications with filtering (list_type=UNREAD/ARCHIVE, importance=INFO/WARNING/ALERT)
|
|
warnings - Get deduplicated unread warnings and alerts
|
|
create - Create notification (requires title, subject, description, importance)
|
|
archive - Archive a notification (requires notification_id)
|
|
unread - Mark notification as unread (requires notification_id)
|
|
delete - Delete a notification (requires notification_id, notification_type, confirm=True)
|
|
delete_archived - Delete all archived notifications (requires confirm=True)
|
|
archive_all - Archive all notifications (optional importance filter)
|
|
"""
|
|
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.")
|
|
|
|
with tool_error_handler("notifications", action, logger):
|
|
logger.info(f"Executing unraid_notifications action={action}")
|
|
|
|
if action == "overview":
|
|
data = await make_graphql_request(QUERIES["overview"])
|
|
notifications = data.get("notifications") or {}
|
|
return dict(notifications.get("overview") or {})
|
|
|
|
if action == "list":
|
|
filter_vars: dict[str, Any] = {
|
|
"type": list_type.upper(),
|
|
"offset": offset,
|
|
"limit": limit,
|
|
}
|
|
if importance:
|
|
filter_vars["importance"] = importance.upper()
|
|
data = await make_graphql_request(QUERIES["list"], {"filter": filter_vars})
|
|
notifications = data.get("notifications", {})
|
|
return {"notifications": notifications.get("list", [])}
|
|
|
|
if action == "warnings":
|
|
data = await make_graphql_request(QUERIES["warnings"])
|
|
notifications = data.get("notifications", {})
|
|
return {"warnings": notifications.get("warningsAndAlerts", [])}
|
|
|
|
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")
|
|
if importance.upper() not in _VALID_IMPORTANCE:
|
|
raise ToolError(
|
|
f"importance must be one of: {', '.join(sorted(_VALID_IMPORTANCE))}. "
|
|
f"Got: '{importance}'"
|
|
)
|
|
if len(title) > 200:
|
|
raise ToolError(f"title must be at most 200 characters (got {len(title)})")
|
|
if len(subject) > 500:
|
|
raise ToolError(f"subject must be at most 500 characters (got {len(subject)})")
|
|
if len(description) > 2000:
|
|
raise ToolError(
|
|
f"description must be at most 2000 characters (got {len(description)})"
|
|
)
|
|
input_data = {
|
|
"title": title,
|
|
"subject": subject,
|
|
"description": description,
|
|
"importance": importance.upper(),
|
|
}
|
|
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})
|
|
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")
|
|
data = await make_graphql_request(
|
|
MUTATIONS["delete"],
|
|
{"id": notification_id, "type": notification_type.upper()},
|
|
)
|
|
return {"success": True, "action": "delete", "data": data}
|
|
|
|
if action == "delete_archived":
|
|
data = await make_graphql_request(MUTATIONS["delete_archived"])
|
|
return {"success": True, "action": "delete_archived", "data": data}
|
|
|
|
if action == "archive_all":
|
|
variables: dict[str, Any] | None = None
|
|
if importance:
|
|
variables = {"importance": importance.upper()}
|
|
data = await make_graphql_request(MUTATIONS["archive_all"], variables)
|
|
return {"success": True, "action": "archive_all", "data": data}
|
|
|
|
raise ToolError(f"Unhandled action '{action}' — this is a bug")
|
|
|
|
logger.info("Notifications tool registered successfully")
|