feat(resources): add unraid://live/{action} MCP resources for 9 snapshot subscriptions

Registers cpu, memory, cpu_telemetry, array_state, parity_progress,
ups_status, notifications_overview, owner, and server_status as MCP
resources under unraid://live/{action}. Each opens a transient WebSocket
via subscribe_once() and returns JSON; exceptions degrade gracefully to
an error JSON dict rather than raising. Skips log_tail and
notification_feed (require params, not suitable as resources).
This commit is contained in:
Jacob Magar
2026-03-15 21:51:20 -04:00
parent 7c99fe1527
commit f5978d67ec
2 changed files with 117 additions and 0 deletions

97
tests/test_resources.py Normal file
View File

@@ -0,0 +1,97 @@
"""Tests for MCP subscription resources."""
import json
from unittest.mock import AsyncMock, patch
import pytest
from fastmcp import FastMCP
from unraid_mcp.subscriptions.resources import register_subscription_resources
def _make_resources():
"""Register resources on a test FastMCP instance and return it."""
test_mcp = FastMCP("test")
register_subscription_resources(test_mcp)
return test_mcp
@pytest.fixture
def _mock_subscribe_once():
with patch(
"unraid_mcp.subscriptions.resources.subscribe_once",
new_callable=AsyncMock,
) as mock:
yield mock
@pytest.fixture
def _mock_ensure_started():
with patch(
"unraid_mcp.subscriptions.resources.ensure_subscriptions_started",
new_callable=AsyncMock,
) as mock:
yield mock
class TestLiveResources:
@pytest.mark.parametrize(
"action",
[
"cpu",
"memory",
"cpu_telemetry",
"array_state",
"parity_progress",
"ups_status",
"notifications_overview",
"owner",
"server_status",
],
)
async def test_resource_returns_json(
self,
action: str,
_mock_subscribe_once: AsyncMock,
_mock_ensure_started: AsyncMock,
) -> None:
_mock_subscribe_once.return_value = {"data": "ok"}
mcp = _make_resources()
local_provider = mcp.providers[0]
resource_key = f"resource:unraid://live/{action}@"
resource = local_provider._components[resource_key]
result = await resource.fn()
parsed = json.loads(result)
assert parsed == {"data": "ok"}
async def test_resource_returns_error_dict_on_failure(
self,
_mock_subscribe_once: AsyncMock,
_mock_ensure_started: AsyncMock,
) -> None:
from fastmcp.exceptions import ToolError
_mock_subscribe_once.side_effect = ToolError("WebSocket timeout")
mcp = _make_resources()
local_provider = mcp.providers[0]
resource = local_provider._components["resource:unraid://live/cpu@"]
result = await resource.fn()
parsed = json.loads(result)
assert "error" in parsed
assert "WebSocket timeout" in parsed["error"]
class TestLogsStreamResource:
async def test_logs_stream_no_data(self, _mock_ensure_started: AsyncMock) -> None:
with patch("unraid_mcp.subscriptions.resources.subscription_manager") as mock_mgr:
mock_mgr.get_resource_data = AsyncMock(return_value=None)
mcp = _make_resources()
local_provider = mcp.providers[0]
resource = local_provider._components["resource:unraid://logs/stream@"]
result = await resource.fn()
parsed = json.loads(result)
assert "status" in parsed

View File

@@ -14,6 +14,8 @@ from fastmcp import FastMCP
from ..config.logging import logger from ..config.logging import logger
from .manager import subscription_manager from .manager import subscription_manager
from .queries import SNAPSHOT_ACTIONS
from .snapshot import subscribe_once
# Global flag to track subscription startup # Global flag to track subscription startup
@@ -102,4 +104,22 @@ def register_subscription_resources(mcp: FastMCP) -> None:
} }
) )
def _make_resource_fn(action: str, query: str):
async def _live_resource() -> str:
await ensure_subscriptions_started()
try:
data = await subscribe_once(query)
return json.dumps(data, indent=2)
except Exception as exc:
return json.dumps({"error": str(exc), "action": action})
_live_resource.__name__ = f"{action}_resource"
_live_resource.__doc__ = (
f"Real-time {action.replace('_', ' ')} data via WebSocket subscription."
)
return _live_resource
for _action, _query in SNAPSHOT_ACTIONS.items():
mcp.resource(f"unraid://live/{_action}")(_make_resource_fn(_action, _query))
logger.info("Subscription resources registered successfully") logger.info("Subscription resources registered successfully")