refactor(tools)!: consolidate 15 individual tools into single unified unraid tool

BREAKING CHANGE: Replaces 15 separate MCP tools (unraid_info, unraid_array,
unraid_storage, unraid_docker, unraid_vm, unraid_notifications, unraid_rclone,
unraid_users, unraid_keys, unraid_health, unraid_settings, unraid_customization,
unraid_plugins, unraid_oidc, unraid_live) with a single `unraid` tool using
action (domain) + subaction (operation) routing.

New interface: unraid(action="system", subaction="overview") replaces
unraid_info(action="overview"). All 15 domains and ~108 subactions preserved.

- Add unraid_mcp/tools/unraid.py (1891 lines, all domains consolidated)
- Remove 15 individual tool files
- Update tools/__init__.py to register single unified tool
- Update server.py for new tool registration pattern
- Update subscriptions/manager.py and resources.py for new tool names
- Update all 25 test files + integration/contract/safety/schema/property tests
- Update mcporter smoke-test script for new tool interface
- Bump version 0.6.0 → 1.0.0

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Jacob Magar
2026-03-16 02:29:57 -04:00
parent faf9fb9ad7
commit dab1cd6995
48 changed files with 3591 additions and 4903 deletions

View File

@@ -1,7 +1,7 @@
"""Tests for unraid_health tool."""
"""Tests for health subactions of the consolidated unraid tool."""
from collections.abc import Generator
from unittest.mock import AsyncMock, patch
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from conftest import make_tool_fn
@@ -12,26 +12,26 @@ from unraid_mcp.core.utils import safe_display_url
@pytest.fixture
def _mock_graphql() -> Generator[AsyncMock, None, None]:
with patch("unraid_mcp.tools.health.make_graphql_request", new_callable=AsyncMock) as mock:
with patch("unraid_mcp.tools.unraid.make_graphql_request", new_callable=AsyncMock) as mock:
yield mock
def _make_tool():
return make_tool_fn("unraid_mcp.tools.health", "register_health_tool", "unraid_health")
return make_tool_fn("unraid_mcp.tools.unraid", "register_unraid_tool", "unraid")
class TestHealthValidation:
async def test_invalid_action(self, _mock_graphql: AsyncMock) -> None:
async def test_invalid_subaction(self, _mock_graphql: AsyncMock) -> None:
tool_fn = _make_tool()
with pytest.raises(ToolError, match="Invalid action"):
await tool_fn(action="invalid")
with pytest.raises(ToolError, match="Invalid subaction"):
await tool_fn(action="health", subaction="invalid")
class TestHealthActions:
async def test_test_connection(self, _mock_graphql: AsyncMock) -> None:
_mock_graphql.return_value = {"online": True}
tool_fn = _make_tool()
result = await tool_fn(action="test_connection")
result = await tool_fn(action="health", subaction="test_connection")
assert result["status"] == "connected"
assert result["online"] is True
assert "latency_ms" in result
@@ -46,13 +46,38 @@ class TestHealthActions:
},
"array": {"state": "STARTED"},
"notifications": {"overview": {"unread": {"alert": 0, "warning": 0, "total": 3}}},
"docker": {"containers": [{"id": "c1", "state": "running", "status": "Up 2 days"}]},
"docker": {"containers": [{"id": "c1", "state": "RUNNING", "status": "Up 2 days"}]},
}
tool_fn = _make_tool()
result = await tool_fn(action="check")
result = await tool_fn(action="health", subaction="check")
assert result["status"] == "healthy"
assert "api_latency_ms" in result
async def test_check_docker_counts_uppercase_states(self, _mock_graphql: AsyncMock) -> None:
"""ContainerState enum is UPPERCASE — running/stopped counts must use case-insensitive match."""
_mock_graphql.return_value = {
"info": {
"machineId": "x",
"versions": {"core": {"unraid": "7.0"}},
"os": {"uptime": 1},
},
"array": {"state": "STARTED"},
"notifications": {"overview": {"unread": {"alert": 0, "warning": 0, "total": 0}}},
"docker": {
"containers": [
{"id": "c1", "state": "RUNNING"},
{"id": "c2", "state": "RUNNING"},
{"id": "c3", "state": "EXITED"},
]
},
}
tool_fn = _make_tool()
result = await tool_fn(action="health", subaction="check")
svc = result["docker_services"]
assert svc["total"] == 3
assert svc["running"] == 2
assert svc["stopped"] == 1
async def test_check_warning_on_alerts(self, _mock_graphql: AsyncMock) -> None:
_mock_graphql.return_value = {
"info": {"machineId": "abc", "versions": {"unraid": "7.2"}, "os": {"uptime": 100}},
@@ -61,20 +86,20 @@ class TestHealthActions:
"docker": {"containers": []},
}
tool_fn = _make_tool()
result = await tool_fn(action="check")
result = await tool_fn(action="health", subaction="check")
assert result["status"] == "warning"
assert any("alert" in i for i in result.get("issues", []))
async def test_check_no_data(self, _mock_graphql: AsyncMock) -> None:
_mock_graphql.return_value = {}
tool_fn = _make_tool()
result = await tool_fn(action="check")
result = await tool_fn(action="health", subaction="check")
assert result["status"] == "unhealthy"
async def test_check_api_error(self, _mock_graphql: AsyncMock) -> None:
_mock_graphql.side_effect = Exception("Connection refused")
tool_fn = _make_tool()
result = await tool_fn(action="check")
result = await tool_fn(action="health", subaction="check")
assert result["status"] == "unhealthy"
assert "Connection refused" in result["error"]
@@ -87,61 +112,51 @@ class TestHealthActions:
"docker": {"containers": []},
}
tool_fn = _make_tool()
result = await tool_fn(action="check")
result = await tool_fn(action="health", subaction="check")
# Missing info escalates to "degraded"; alerts only escalate to "warning"
# Severity should stay at "degraded" (not downgrade to "warning")
assert result["status"] == "degraded"
async def test_diagnose_wraps_exception(self, _mock_graphql: AsyncMock) -> None:
"""When _diagnose_subscriptions raises, tool wraps in ToolError."""
async def test_diagnose_success(self, _mock_graphql: AsyncMock) -> None:
"""Diagnose returns subscription status."""
tool_fn = _make_tool()
mock_status = {"cpu": {"connection_state": "connected"}}
mock_manager = MagicMock()
mock_manager.get_subscription_status = AsyncMock(return_value=mock_status)
mock_manager.auto_start_enabled = True
mock_manager.max_reconnect_attempts = 3
mock_manager.subscription_configs = {}
mock_manager.active_subscriptions = {}
mock_manager.resource_data = {}
with (
patch("unraid_mcp.subscriptions.manager.subscription_manager", mock_manager),
patch("unraid_mcp.subscriptions.resources.ensure_subscriptions_started", AsyncMock()),
patch(
"unraid_mcp.tools.health._diagnose_subscriptions",
side_effect=RuntimeError("broken"),
"unraid_mcp.subscriptions.utils._analyze_subscription_status",
return_value=(0, []),
),
):
result = await tool_fn(action="health", subaction="diagnose")
assert "subscriptions" in result
assert "summary" in result
async def test_diagnose_wraps_exception(self, _mock_graphql: AsyncMock) -> None:
"""When subscription manager raises, tool wraps in ToolError."""
tool_fn = _make_tool()
mock_manager = MagicMock()
mock_manager.get_subscription_status = AsyncMock(side_effect=RuntimeError("broken"))
with (
patch("unraid_mcp.subscriptions.manager.subscription_manager", mock_manager),
patch("unraid_mcp.subscriptions.resources.ensure_subscriptions_started", AsyncMock()),
patch(
"unraid_mcp.subscriptions.utils._analyze_subscription_status",
return_value=(0, []),
),
pytest.raises(ToolError, match="Failed to execute health/diagnose"),
):
await tool_fn(action="diagnose")
async def test_diagnose_success(self, _mock_graphql: AsyncMock) -> None:
"""Diagnose returns subscription status when modules are available."""
tool_fn = _make_tool()
mock_status = {
"cpu_sub": {"runtime": {"connection_state": "connected", "last_error": None}},
}
with patch("unraid_mcp.tools.health._diagnose_subscriptions", return_value=mock_status):
result = await tool_fn(action="diagnose")
assert "cpu_sub" in result
async def test_diagnose_import_error_internal(self) -> None:
"""_diagnose_subscriptions raises ToolError when subscription modules are unavailable."""
import sys
from unraid_mcp.tools.health import _diagnose_subscriptions
# Remove cached subscription modules so the import is re-triggered
cached = {k: v for k, v in sys.modules.items() if "unraid_mcp.subscriptions" in k}
for k in cached:
del sys.modules[k]
try:
# Replace the modules with objects that raise ImportError on access
with (
patch.dict(
sys.modules,
{
"unraid_mcp.subscriptions": None,
"unraid_mcp.subscriptions.manager": None,
"unraid_mcp.subscriptions.resources": None,
},
),
pytest.raises(ToolError, match="Subscription modules not available"),
):
await _diagnose_subscriptions()
finally:
# Restore cached modules
sys.modules.update(cached)
await tool_fn(action="health", subaction="diagnose")
# ---------------------------------------------------------------------------
@@ -166,17 +181,20 @@ class TestSafeDisplayUrl:
def test_strips_path(self) -> None:
result = safe_display_url("http://unraid.local/some/deep/path?query=1")
assert result is not None
assert "path" not in result
assert "query" not in result
def test_strips_credentials(self) -> None:
result = safe_display_url("https://user:password@unraid.local/graphql")
assert result is not None
assert "user" not in result
assert "password" not in result
assert result == "https://unraid.local"
def test_strips_query_params(self) -> None:
result = safe_display_url("http://host.local?token=abc&key=xyz")
assert result is not None
assert "token" not in result
assert "abc" not in result
@@ -190,23 +208,25 @@ class TestSafeDisplayUrl:
def test_malformed_ipv6_url_returns_unparseable(self) -> None:
"""Malformed IPv6 brackets in netloc cause urlparse.hostname to raise ValueError."""
# urlparse("https://[invalid") parses without error, but accessing .hostname
# raises ValueError: Invalid IPv6 URL — this triggers the except branch.
result = safe_display_url("https://[invalid")
assert result == "<unparseable>"
@pytest.mark.asyncio
async def test_health_setup_action_calls_elicitation() -> None:
"""setup action triggers elicit_and_configure and returns success message."""
from unittest.mock import AsyncMock, MagicMock
"""setup subaction triggers elicit_and_configure when no credentials exist."""
tool_fn = _make_tool()
with patch(
"unraid_mcp.tools.health.elicit_and_configure", new=AsyncMock(return_value=True)
) as mock_elicit:
result = await tool_fn(action="setup", ctx=MagicMock())
mock_path = MagicMock()
mock_path.exists.return_value = False
with (
patch("unraid_mcp.config.settings.CREDENTIALS_ENV_PATH", mock_path),
patch(
"unraid_mcp.core.setup.elicit_and_configure", new=AsyncMock(return_value=True)
) as mock_elicit,
):
result = await tool_fn(action="health", subaction="setup", ctx=MagicMock())
assert mock_elicit.called
assert "configured" in result.lower() or "success" in result.lower()
@@ -214,13 +234,17 @@ async def test_health_setup_action_calls_elicitation() -> None:
@pytest.mark.asyncio
async def test_health_setup_action_returns_declined_message() -> None:
"""setup action with declined elicitation returns appropriate message."""
from unittest.mock import AsyncMock, MagicMock
"""setup subaction with declined elicitation returns appropriate message."""
tool_fn = _make_tool()
with patch("unraid_mcp.tools.health.elicit_and_configure", new=AsyncMock(return_value=False)):
result = await tool_fn(action="setup", ctx=MagicMock())
mock_path = MagicMock()
mock_path.exists.return_value = False
with (
patch("unraid_mcp.config.settings.CREDENTIALS_ENV_PATH", mock_path),
patch("unraid_mcp.core.setup.elicit_and_configure", new=AsyncMock(return_value=False)),
):
result = await tool_fn(action="health", subaction="setup", ctx=MagicMock())
assert (
"not configured" in result.lower()
@@ -229,18 +253,126 @@ async def test_health_setup_action_returns_declined_message() -> None:
)
@pytest.mark.asyncio
async def test_health_setup_already_configured_and_working_no_reset() -> None:
"""setup returns early when credentials exist, connection works, and user declines reset."""
tool_fn = _make_tool()
mock_path = MagicMock()
mock_path.exists.return_value = True
with (
patch("unraid_mcp.config.settings.CREDENTIALS_ENV_PATH", mock_path),
patch(
"unraid_mcp.tools.unraid.make_graphql_request",
new=AsyncMock(return_value={"online": True}),
),
patch(
"unraid_mcp.core.setup.elicit_reset_confirmation",
new=AsyncMock(return_value=False),
),
patch("unraid_mcp.core.setup.elicit_and_configure") as mock_configure,
):
result = await tool_fn(action="health", subaction="setup", ctx=MagicMock())
mock_configure.assert_not_called()
assert "already configured" in result.lower()
assert "no changes" in result.lower()
@pytest.mark.asyncio
async def test_health_setup_already_configured_user_confirms_reset() -> None:
"""setup proceeds with elicitation when credentials exist but user confirms reset."""
tool_fn = _make_tool()
mock_path = MagicMock()
mock_path.exists.return_value = True
with (
patch("unraid_mcp.config.settings.CREDENTIALS_ENV_PATH", mock_path),
patch(
"unraid_mcp.tools.unraid.make_graphql_request",
new=AsyncMock(return_value={"online": True}),
),
patch(
"unraid_mcp.core.setup.elicit_reset_confirmation",
new=AsyncMock(return_value=True),
),
patch(
"unraid_mcp.core.setup.elicit_and_configure", new=AsyncMock(return_value=True)
) as mock_configure,
):
result = await tool_fn(action="health", subaction="setup", ctx=MagicMock())
mock_configure.assert_called_once()
assert "configured" in result.lower() or "success" in result.lower()
@pytest.mark.asyncio
async def test_health_setup_credentials_exist_but_connection_fails() -> None:
"""setup proceeds with elicitation when credentials exist but connection fails."""
tool_fn = _make_tool()
mock_path = MagicMock()
mock_path.exists.return_value = True
with (
patch("unraid_mcp.config.settings.CREDENTIALS_ENV_PATH", mock_path),
patch(
"unraid_mcp.tools.unraid.make_graphql_request",
new=AsyncMock(side_effect=Exception("connection refused")),
),
patch(
"unraid_mcp.core.setup.elicit_and_configure", new=AsyncMock(return_value=True)
) as mock_configure,
):
result = await tool_fn(action="health", subaction="setup", ctx=MagicMock())
mock_configure.assert_called_once()
assert "configured" in result.lower() or "success" in result.lower()
@pytest.mark.asyncio
async def test_health_setup_ctx_none_already_configured_returns_no_changes() -> None:
"""When ctx=None and credentials are working, setup returns 'already configured' gracefully."""
tool_fn = _make_tool()
mock_path = MagicMock()
mock_path.exists.return_value = True
with (
patch("unraid_mcp.config.settings.CREDENTIALS_ENV_PATH", mock_path),
patch(
"unraid_mcp.tools.unraid.make_graphql_request",
new=AsyncMock(return_value={"online": True}),
),
patch("unraid_mcp.core.setup.elicit_and_configure") as mock_configure,
):
result = await tool_fn(action="health", subaction="setup", ctx=None)
mock_configure.assert_not_called()
assert "already configured" in result.lower()
assert "no changes" in result.lower()
@pytest.mark.asyncio
async def test_health_setup_declined_message_includes_manual_path() -> None:
"""Declined setup message includes the exact credentials file path and variable names."""
from unittest.mock import AsyncMock, MagicMock, patch
from unraid_mcp.config.settings import CREDENTIALS_ENV_PATH
tool_fn = _make_tool()
with patch("unraid_mcp.tools.health.elicit_and_configure", new=AsyncMock(return_value=False)):
result = await tool_fn(action="setup", ctx=MagicMock())
real_path_str = str(CREDENTIALS_ENV_PATH)
mock_path = MagicMock()
mock_path.exists.return_value = False
type(mock_path).__str__ = lambda self: real_path_str # type: ignore[method-assign]
assert str(CREDENTIALS_ENV_PATH) in result
assert "UNRAID_API_URL=" in result # inline variable shown
with (
patch("unraid_mcp.config.settings.CREDENTIALS_ENV_PATH", mock_path),
patch("unraid_mcp.core.setup.elicit_and_configure", new=AsyncMock(return_value=False)),
):
result = await tool_fn(action="health", subaction="setup", ctx=MagicMock())
assert real_path_str in result
assert "UNRAID_API_URL=" in result
assert "UNRAID_API_KEY=" in result