mirror of
https://github.com/jmagar/unraid-mcp.git
synced 2026-03-23 12:39:24 -07:00
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:
97
tests/test_resources.py
Normal file
97
tests/test_resources.py
Normal 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
|
||||
@@ -14,6 +14,8 @@ from fastmcp import FastMCP
|
||||
|
||||
from ..config.logging import logger
|
||||
from .manager import subscription_manager
|
||||
from .queries import SNAPSHOT_ACTIONS
|
||||
from .snapshot import subscribe_once
|
||||
|
||||
|
||||
# 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")
|
||||
|
||||
Reference in New Issue
Block a user