mirror of
https://github.com/jmagar/unraid-mcp.git
synced 2026-03-23 12:39:24 -07:00
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:
@@ -1,7 +1,6 @@
|
||||
"""Tests for unraid_storage tool."""
|
||||
"""Tests for disk subactions of the consolidated unraid tool."""
|
||||
|
||||
from collections.abc import Generator
|
||||
from typing import get_args
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -9,13 +8,6 @@ from conftest import make_tool_fn
|
||||
|
||||
from unraid_mcp.core.exceptions import ToolError
|
||||
from unraid_mcp.core.utils import format_bytes, format_kb, safe_get
|
||||
from unraid_mcp.tools.storage import STORAGE_ACTIONS
|
||||
|
||||
|
||||
def test_unassigned_action_removed() -> None:
|
||||
assert "unassigned" not in get_args(STORAGE_ACTIONS), (
|
||||
"unassigned action references unassignedDevices which is not in live API"
|
||||
)
|
||||
|
||||
|
||||
# --- Unit tests for helpers ---
|
||||
@@ -46,59 +38,63 @@ class TestFormatBytes:
|
||||
|
||||
@pytest.fixture
|
||||
def _mock_graphql() -> Generator[AsyncMock, None, None]:
|
||||
with patch("unraid_mcp.tools.storage.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.storage", "register_storage_tool", "unraid_storage")
|
||||
return make_tool_fn("unraid_mcp.tools.unraid", "register_unraid_tool", "unraid")
|
||||
|
||||
|
||||
class TestStorageValidation:
|
||||
async def test_disk_details_requires_disk_id(self, _mock_graphql: AsyncMock) -> None:
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="disk_id"):
|
||||
await tool_fn(action="disk_details")
|
||||
await tool_fn(action="disk", subaction="disk_details")
|
||||
|
||||
async def test_logs_requires_log_path(self, _mock_graphql: AsyncMock) -> None:
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="log_path"):
|
||||
await tool_fn(action="logs")
|
||||
await tool_fn(action="disk", subaction="logs")
|
||||
|
||||
async def test_logs_rejects_invalid_path(self, _mock_graphql: AsyncMock) -> None:
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="log_path must start with"):
|
||||
await tool_fn(action="logs", log_path="/etc/shadow")
|
||||
await tool_fn(action="disk", subaction="logs", log_path="/etc/shadow")
|
||||
|
||||
async def test_logs_rejects_path_traversal(self, _mock_graphql: AsyncMock) -> None:
|
||||
tool_fn = _make_tool()
|
||||
# Traversal that escapes /var/log/ to reach /etc/shadow
|
||||
with pytest.raises(ToolError, match="log_path must start with"):
|
||||
await tool_fn(action="logs", log_path="/var/log/../../etc/shadow")
|
||||
await tool_fn(action="disk", subaction="logs", log_path="/var/log/../../etc/shadow")
|
||||
# Traversal that escapes /mnt/ to reach /etc/passwd
|
||||
with pytest.raises(ToolError, match="log_path must start with"):
|
||||
await tool_fn(action="logs", log_path="/mnt/../etc/passwd")
|
||||
await tool_fn(action="disk", subaction="logs", log_path="/mnt/../etc/passwd")
|
||||
|
||||
async def test_logs_allows_valid_paths(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.return_value = {"logFile": {"path": "/var/log/syslog", "content": "ok"}}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="logs", log_path="/var/log/syslog")
|
||||
result = await tool_fn(action="disk", subaction="logs", log_path="/var/log/syslog")
|
||||
assert result["content"] == "ok"
|
||||
|
||||
async def test_logs_tail_lines_too_large(self, _mock_graphql: AsyncMock) -> None:
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="tail_lines must be between"):
|
||||
await tool_fn(action="logs", log_path="/var/log/syslog", tail_lines=10_001)
|
||||
await tool_fn(
|
||||
action="disk", subaction="logs", log_path="/var/log/syslog", tail_lines=10_001
|
||||
)
|
||||
|
||||
async def test_logs_tail_lines_zero_rejected(self, _mock_graphql: AsyncMock) -> None:
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="tail_lines must be between"):
|
||||
await tool_fn(action="logs", log_path="/var/log/syslog", tail_lines=0)
|
||||
await tool_fn(action="disk", subaction="logs", log_path="/var/log/syslog", tail_lines=0)
|
||||
|
||||
async def test_logs_tail_lines_at_max_accepted(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.return_value = {"logFile": {"path": "/var/log/syslog", "content": "ok"}}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="logs", log_path="/var/log/syslog", tail_lines=10_000)
|
||||
result = await tool_fn(
|
||||
action="disk", subaction="logs", log_path="/var/log/syslog", tail_lines=10_000
|
||||
)
|
||||
assert result["content"] == "ok"
|
||||
|
||||
async def test_non_logs_action_ignores_tail_lines_validation(
|
||||
@@ -106,7 +102,7 @@ class TestStorageValidation:
|
||||
) -> None:
|
||||
_mock_graphql.return_value = {"shares": []}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="shares", tail_lines=0)
|
||||
result = await tool_fn(action="disk", subaction="shares", tail_lines=0)
|
||||
assert result["shares"] == []
|
||||
|
||||
|
||||
@@ -173,13 +169,13 @@ class TestStorageActions:
|
||||
"shares": [{"id": "s:1", "name": "media"}, {"id": "s:2", "name": "backups"}]
|
||||
}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="shares")
|
||||
result = await tool_fn(action="disk", subaction="shares")
|
||||
assert len(result["shares"]) == 2
|
||||
|
||||
async def test_disks(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.return_value = {"disks": [{"id": "d:1", "device": "sda"}]}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="disks")
|
||||
result = await tool_fn(action="disk", subaction="disks")
|
||||
assert len(result["disks"]) == 1
|
||||
|
||||
async def test_disk_details(self, _mock_graphql: AsyncMock) -> None:
|
||||
@@ -194,7 +190,7 @@ class TestStorageActions:
|
||||
}
|
||||
}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="disk_details", disk_id="d:1")
|
||||
result = await tool_fn(action="disk", subaction="disk_details", disk_id="d:1")
|
||||
assert result["summary"]["temperature"] == "35\u00b0C"
|
||||
assert "1.00 GB" in result["summary"]["size_formatted"]
|
||||
|
||||
@@ -211,7 +207,7 @@ class TestStorageActions:
|
||||
}
|
||||
}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="disk_details", disk_id="d:1")
|
||||
result = await tool_fn(action="disk", subaction="disk_details", disk_id="d:1")
|
||||
assert result["summary"]["temperature"] == "0\u00b0C"
|
||||
|
||||
async def test_disk_details_temperature_null(self, _mock_graphql: AsyncMock) -> None:
|
||||
@@ -227,26 +223,26 @@ class TestStorageActions:
|
||||
}
|
||||
}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="disk_details", disk_id="d:1")
|
||||
result = await tool_fn(action="disk", subaction="disk_details", disk_id="d:1")
|
||||
assert result["summary"]["temperature"] == "N/A"
|
||||
|
||||
async def test_logs_null_log_file(self, _mock_graphql: AsyncMock) -> None:
|
||||
"""logFile being null should return an empty dict."""
|
||||
_mock_graphql.return_value = {"logFile": None}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="logs", log_path="/var/log/syslog")
|
||||
result = await tool_fn(action="disk", subaction="logs", log_path="/var/log/syslog")
|
||||
assert result == {}
|
||||
|
||||
async def test_disk_details_not_found(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.return_value = {"disk": None}
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="not found"):
|
||||
await tool_fn(action="disk_details", disk_id="d:missing")
|
||||
await tool_fn(action="disk", subaction="disk_details", disk_id="d:missing")
|
||||
|
||||
async def test_log_files(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.return_value = {"logFiles": [{"name": "syslog", "path": "/var/log/syslog"}]}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="log_files")
|
||||
result = await tool_fn(action="disk", subaction="log_files")
|
||||
assert len(result["log_files"]) == 1
|
||||
|
||||
async def test_logs(self, _mock_graphql: AsyncMock) -> None:
|
||||
@@ -254,7 +250,7 @@ class TestStorageActions:
|
||||
"logFile": {"path": "/var/log/syslog", "content": "log line", "totalLines": 1}
|
||||
}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="logs", log_path="/var/log/syslog")
|
||||
result = await tool_fn(action="disk", subaction="logs", log_path="/var/log/syslog")
|
||||
assert result["content"] == "log line"
|
||||
|
||||
|
||||
@@ -268,7 +264,7 @@ class TestStorageNetworkErrors:
|
||||
)
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="Invalid JSON"):
|
||||
await tool_fn(action="logs", log_path="/var/log/syslog")
|
||||
await tool_fn(action="disk", subaction="logs", log_path="/var/log/syslog")
|
||||
|
||||
async def test_shares_connection_refused(self, _mock_graphql: AsyncMock) -> None:
|
||||
"""Connection refused when listing shares should propagate as ToolError."""
|
||||
@@ -277,14 +273,14 @@ class TestStorageNetworkErrors:
|
||||
)
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="Connection refused"):
|
||||
await tool_fn(action="shares")
|
||||
await tool_fn(action="disk", subaction="shares")
|
||||
|
||||
async def test_disks_http_500(self, _mock_graphql: AsyncMock) -> None:
|
||||
"""HTTP 500 when listing disks should propagate as ToolError."""
|
||||
_mock_graphql.side_effect = ToolError("HTTP error 500: Internal Server Error")
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="HTTP error 500"):
|
||||
await tool_fn(action="disks")
|
||||
await tool_fn(action="disk", subaction="disks")
|
||||
|
||||
|
||||
class TestStorageFlashBackup:
|
||||
@@ -292,29 +288,40 @@ class TestStorageFlashBackup:
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="not confirmed"):
|
||||
await tool_fn(
|
||||
action="flash_backup", remote_name="r", source_path="/boot", destination_path="r:b"
|
||||
action="disk",
|
||||
subaction="flash_backup",
|
||||
remote_name="r",
|
||||
source_path="/boot",
|
||||
destination_path="r:b",
|
||||
)
|
||||
|
||||
async def test_flash_backup_requires_remote_name(self, _mock_graphql: AsyncMock) -> None:
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="remote_name"):
|
||||
await tool_fn(action="flash_backup", confirm=True)
|
||||
await tool_fn(action="disk", subaction="flash_backup", confirm=True)
|
||||
|
||||
async def test_flash_backup_requires_source_path(self, _mock_graphql: AsyncMock) -> None:
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="source_path"):
|
||||
await tool_fn(action="flash_backup", confirm=True, remote_name="r")
|
||||
await tool_fn(action="disk", subaction="flash_backup", confirm=True, remote_name="r")
|
||||
|
||||
async def test_flash_backup_requires_destination_path(self, _mock_graphql: AsyncMock) -> None:
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="destination_path"):
|
||||
await tool_fn(action="flash_backup", confirm=True, remote_name="r", source_path="/boot")
|
||||
await tool_fn(
|
||||
action="disk",
|
||||
subaction="flash_backup",
|
||||
confirm=True,
|
||||
remote_name="r",
|
||||
source_path="/boot",
|
||||
)
|
||||
|
||||
async def test_flash_backup_success(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.return_value = {"initiateFlashBackup": {"status": "started", "jobId": "j:1"}}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(
|
||||
action="flash_backup",
|
||||
action="disk",
|
||||
subaction="flash_backup",
|
||||
confirm=True,
|
||||
remote_name="r",
|
||||
source_path="/boot",
|
||||
@@ -327,7 +334,8 @@ class TestStorageFlashBackup:
|
||||
_mock_graphql.return_value = {"initiateFlashBackup": {"status": "started", "jobId": "j:2"}}
|
||||
tool_fn = _make_tool()
|
||||
await tool_fn(
|
||||
action="flash_backup",
|
||||
action="disk",
|
||||
subaction="flash_backup",
|
||||
confirm=True,
|
||||
remote_name="r",
|
||||
source_path="/boot",
|
||||
|
||||
Reference in New Issue
Block a user