Files
unraid-mcp/unraid_mcp/subscriptions/resources.py
Jacob Magar efaab031ae fix: address all 17 PR review comments
Resolves review threads:
- PRRT_kwDOO6Hdxs50fewG (setup.py): non-eliciting clients now return True
  from elicit_reset_confirmation so they can reconfigure without being blocked
- PRRT_kwDOO6Hdxs50fewM (test-tools.sh): add notification/recalculate smoke test
- PRRT_kwDOO6Hdxs50fewP (test-tools.sh): add system/array smoke test
- PRRT_kwDOO6Hdxs50fewT (resources.py): surface manager error state instead of
  reporting 'connecting' for permanently failed subscriptions
- PRRT_kwDOO6Hdxs50feAj (resources.py): use is not None check for empty cached dicts
- PRRT_kwDOO6Hdxs50fewY (integration tests): remove duplicate snapshot-registration
  tests already covered in test_resources.py
- PRRT_kwDOO6Hdxs50fewe (test_resources.py): replace brittle import-detail test
  with behavior tests for connecting/error states
- PRRT_kwDOO6Hdxs50fewh (test_customization.py): strengthen public_theme assertion
- PRRT_kwDOO6Hdxs50fewk (test_customization.py): strengthen theme assertion
- PRRT_kwDOO6Hdxs50fewo (__init__.py): correct subaction count ~88 -> ~107
- PRRT_kwDOO6Hdxs50fewx (test_oidc.py): assert providers list value directly
- PRRT_kwDOO6Hdxs50fewz (unraid.py): remove unreachable raise after vm handler
- PRRT_kwDOO6Hdxs50few2 (unraid.py): remove unreachable raise after docker handler
- PRRT_kwDOO6Hdxs50fev8 (CLAUDE.md): replace legacy 15-tool table with unified
  unraid action/subaction table
- PRRT_kwDOO6Hdxs50fev_ (test_oidc.py): assert providers + defaultAllowedOrigins
- PRRT_kwDOO6Hdxs50feAz (CLAUDE.md): update tool categories to unified API shape
- PRRT_kwDOO6Hdxs50feBE (CLAUDE.md/setup.py): update unraid_health refs to
  unraid(action=health, subaction=setup)
2026-03-16 02:58:54 -04:00

138 lines
5.1 KiB
Python

"""MCP resources that expose subscription data.
This module defines MCP resources that bridge between the subscription manager
and the MCP protocol, providing fallback queries when subscription data is unavailable.
"""
import asyncio
import json
import os
from typing import Final
import anyio
from fastmcp import FastMCP
from ..config.logging import logger
from .manager import subscription_manager
from .queries import SNAPSHOT_ACTIONS
# Global flag to track subscription startup
_subscriptions_started = False
_startup_lock: Final[asyncio.Lock] = asyncio.Lock()
async def ensure_subscriptions_started() -> None:
"""Ensure subscriptions are started, called from async context."""
global _subscriptions_started
# Fast-path: skip lock if already started
if _subscriptions_started:
return
# Slow-path: acquire lock for initialization (double-checked locking)
async with _startup_lock:
if _subscriptions_started:
return
logger.info("[STARTUP] First async operation detected, starting subscriptions...")
try:
await autostart_subscriptions()
_subscriptions_started = True
logger.info("[STARTUP] Subscriptions started successfully")
except Exception as e:
logger.error(f"[STARTUP] Failed to start subscriptions: {e}", exc_info=True)
async def autostart_subscriptions() -> None:
"""Auto-start all subscriptions marked for auto-start in SubscriptionManager."""
logger.info("[AUTOSTART] Initiating subscription auto-start process...")
try:
# Use the SubscriptionManager auto-start method
await subscription_manager.auto_start_all_subscriptions()
logger.info("[AUTOSTART] Auto-start process completed successfully")
except Exception as e:
logger.error(f"[AUTOSTART] Failed during auto-start process: {e}", exc_info=True)
raise # Propagate so ensure_subscriptions_started doesn't mark as started
# Optional log file subscription
log_path = os.getenv("UNRAID_AUTOSTART_LOG_PATH")
if log_path is None:
# Default to syslog if available
default_path = "/var/log/syslog"
if await anyio.Path(default_path).exists():
log_path = default_path
logger.info(f"[AUTOSTART] Using default log path: {default_path}")
if log_path:
try:
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}
)
logger.info(f"[AUTOSTART] Log file subscription started for: {log_path}")
else:
logger.error("[AUTOSTART] logFileSubscription config not found")
except Exception as e:
logger.error(f"[AUTOSTART] Failed to start log file subscription: {e}", exc_info=True)
else:
logger.info("[AUTOSTART] No log file path configured for auto-start")
def register_subscription_resources(mcp: FastMCP) -> None:
"""Register all subscription resources with the FastMCP instance.
Args:
mcp: FastMCP instance to register resources with
"""
@mcp.resource("unraid://logs/stream")
async def logs_stream_resource() -> str:
"""Real-time log stream data from subscription."""
await ensure_subscriptions_started()
data = await 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.",
}
)
def _make_resource_fn(action: str):
async def _live_resource() -> str:
await ensure_subscriptions_started()
data = await subscription_manager.get_resource_data(action)
if data is not None:
return json.dumps(data, indent=2)
# Surface permanent errors instead of reporting "connecting" indefinitely
last_error = subscription_manager.last_error.get(action)
if last_error:
return json.dumps(
{
"status": "error",
"message": f"Subscription '{action}' failed: {last_error}",
}
)
return json.dumps(
{
"status": "connecting",
"message": f"Subscription '{action}' is starting. Retry in a moment.",
}
)
_live_resource.__name__ = f"{action}_resource"
_live_resource.__doc__ = (
f"Real-time {action.replace('_', ' ')} data via WebSocket subscription."
)
return _live_resource
for _action in SNAPSHOT_ACTIONS:
mcp.resource(f"unraid://live/{_action}")(_make_resource_fn(_action))
logger.info("Subscription resources registered successfully")