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

@@ -6,11 +6,11 @@ Uses Hypothesis to fuzz tool inputs and verify the core invariant:
other unhandled exception from arbitrary inputs is a bug.
Each test class targets a distinct tool domain and strategy profile:
- Docker: arbitrary container IDs, action names, numeric params
- Docker: arbitrary container IDs, subaction names, numeric params
- Notifications: importance strings, list_type strings, field lengths
- Keys: arbitrary key IDs, role lists, name strings
- VM: arbitrary VM IDs, action names
- Info: invalid action names (cross-tool invariant for the action guard)
- VM: arbitrary VM IDs, subaction names
- Info: invalid subaction names (cross-tool invariant for the subaction guard)
"""
import asyncio
@@ -60,6 +60,10 @@ def _assert_only_tool_error(exc: BaseException) -> None:
)
def _make_tool():
return make_tool_fn("unraid_mcp.tools.unraid", "register_unraid_tool", "unraid")
# ---------------------------------------------------------------------------
# Docker: arbitrary container IDs
# ---------------------------------------------------------------------------
@@ -79,16 +83,14 @@ class TestDockerContainerIdFuzzing:
"""Arbitrary container IDs for 'details' must not crash the tool."""
async def _run_test():
tool_fn = make_tool_fn(
"unraid_mcp.tools.docker", "register_docker_tool", "unraid_docker"
)
tool_fn = _make_tool()
with patch(
"unraid_mcp.tools.docker.make_graphql_request", new_callable=AsyncMock
"unraid_mcp.tools.unraid.make_graphql_request", new_callable=AsyncMock
) as mock:
mock.return_value = {"docker": {"containers": []}}
with contextlib.suppress(ToolError):
# ToolError is the only acceptable exception — suppress it
await tool_fn(action="details", container_id=container_id)
await tool_fn(action="docker", subaction="details", container_id=container_id)
_run(_run_test())
@@ -98,15 +100,13 @@ class TestDockerContainerIdFuzzing:
"""Arbitrary container IDs for 'start' must not crash the tool."""
async def _run_test():
tool_fn = make_tool_fn(
"unraid_mcp.tools.docker", "register_docker_tool", "unraid_docker"
)
tool_fn = _make_tool()
with patch(
"unraid_mcp.tools.docker.make_graphql_request", new_callable=AsyncMock
"unraid_mcp.tools.unraid.make_graphql_request", new_callable=AsyncMock
) as mock:
mock.return_value = {"docker": {"containers": []}}
with contextlib.suppress(ToolError):
await tool_fn(action="start", container_id=container_id)
await tool_fn(action="docker", subaction="start", container_id=container_id)
_run(_run_test())
@@ -116,15 +116,13 @@ class TestDockerContainerIdFuzzing:
"""Arbitrary container IDs for 'stop' must not crash the tool."""
async def _run_test():
tool_fn = make_tool_fn(
"unraid_mcp.tools.docker", "register_docker_tool", "unraid_docker"
)
tool_fn = _make_tool()
with patch(
"unraid_mcp.tools.docker.make_graphql_request", new_callable=AsyncMock
"unraid_mcp.tools.unraid.make_graphql_request", new_callable=AsyncMock
) as mock:
mock.return_value = {"docker": {"containers": []}}
with contextlib.suppress(ToolError):
await tool_fn(action="stop", container_id=container_id)
await tool_fn(action="docker", subaction="stop", container_id=container_id)
_run(_run_test())
@@ -134,80 +132,57 @@ class TestDockerContainerIdFuzzing:
"""Arbitrary container IDs for 'restart' must not crash the tool."""
async def _run_test():
tool_fn = make_tool_fn(
"unraid_mcp.tools.docker", "register_docker_tool", "unraid_docker"
)
tool_fn = _make_tool()
with patch(
"unraid_mcp.tools.docker.make_graphql_request", new_callable=AsyncMock
"unraid_mcp.tools.unraid.make_graphql_request", new_callable=AsyncMock
) as mock:
# stop then start both need container list + mutation responses
mock.return_value = {"docker": {"containers": []}}
with contextlib.suppress(ToolError):
await tool_fn(action="restart", container_id=container_id)
await tool_fn(action="docker", subaction="restart", container_id=container_id)
_run(_run_test())
# ---------------------------------------------------------------------------
# Docker: invalid action names
# Docker: invalid subaction names
# ---------------------------------------------------------------------------
class TestDockerInvalidActions:
"""Fuzz the action parameter with arbitrary strings.
"""Fuzz the subaction parameter with arbitrary strings for the docker domain.
Invariant: invalid action names raise ToolError, never KeyError or crash.
This validates the action guard that sits at the top of every tool function.
Invariant: invalid subaction names raise ToolError, never KeyError or crash.
This validates the subaction guard that sits inside every domain handler.
"""
@given(st.text())
@settings(max_examples=200, suppress_health_check=[HealthCheck.function_scoped_fixture])
def test_invalid_action_raises_tool_error(self, action: str) -> None:
"""Any non-valid action string must raise ToolError, not crash."""
valid_actions = {
def test_invalid_action_raises_tool_error(self, subaction: str) -> None:
"""Any non-valid subaction string for docker must raise ToolError, not crash."""
valid_subactions = {
"list",
"details",
"start",
"stop",
"restart",
"pause",
"unpause",
"remove",
"update",
"update_all",
"logs",
"networks",
"network_details",
"port_conflicts",
"check_updates",
"create_folder",
"set_folder_children",
"delete_entries",
"move_to_folder",
"move_to_position",
"rename_folder",
"create_folder_with_items",
"update_view_prefs",
"sync_templates",
"reset_template_mappings",
"refresh_digests",
}
if action in valid_actions:
return # Skip valid actions — they have different semantics
if subaction in valid_subactions:
return # Skip valid subactions — they have different semantics
async def _run_test():
tool_fn = make_tool_fn(
"unraid_mcp.tools.docker", "register_docker_tool", "unraid_docker"
)
with patch("unraid_mcp.tools.docker.make_graphql_request", new_callable=AsyncMock):
tool_fn = _make_tool()
with patch("unraid_mcp.tools.unraid.make_graphql_request", new_callable=AsyncMock):
try:
await tool_fn(action=action)
await tool_fn(action="docker", subaction=subaction)
except ToolError:
pass # Correct: invalid action raises ToolError
pass # Correct: invalid subaction raises ToolError
except Exception as exc:
# Any other exception is a bug
pytest.fail(
f"Action '{action!r}' raised {type(exc).__name__} "
f"subaction={subaction!r} raised {type(exc).__name__} "
f"instead of ToolError: {exc!r}"
)
@@ -235,13 +210,9 @@ class TestNotificationsEnumFuzzing:
return # Skip valid values
async def _run_test():
tool_fn = make_tool_fn(
"unraid_mcp.tools.notifications",
"register_notifications_tool",
"unraid_notifications",
)
tool_fn = _make_tool()
with patch(
"unraid_mcp.tools.notifications.make_graphql_request",
"unraid_mcp.tools.unraid.make_graphql_request",
new_callable=AsyncMock,
) as mock:
mock.return_value = {
@@ -249,7 +220,8 @@ class TestNotificationsEnumFuzzing:
}
try:
await tool_fn(
action="create",
action="notification",
subaction="create",
title="Test",
subject="Sub",
description="Desc",
@@ -271,18 +243,14 @@ class TestNotificationsEnumFuzzing:
return # Skip valid values
async def _run_test():
tool_fn = make_tool_fn(
"unraid_mcp.tools.notifications",
"register_notifications_tool",
"unraid_notifications",
)
tool_fn = _make_tool()
with patch(
"unraid_mcp.tools.notifications.make_graphql_request",
"unraid_mcp.tools.unraid.make_graphql_request",
new_callable=AsyncMock,
) as mock:
mock.return_value = {"notifications": {"list": []}}
try:
await tool_fn(action="list", list_type=list_type)
await tool_fn(action="notification", subaction="list", list_type=list_type)
except ToolError:
pass
except Exception as exc:
@@ -306,13 +274,9 @@ class TestNotificationsEnumFuzzing:
"""
async def _run_test():
tool_fn = make_tool_fn(
"unraid_mcp.tools.notifications",
"register_notifications_tool",
"unraid_notifications",
)
tool_fn = _make_tool()
with patch(
"unraid_mcp.tools.notifications.make_graphql_request",
"unraid_mcp.tools.unraid.make_graphql_request",
new_callable=AsyncMock,
) as mock:
mock.return_value = {
@@ -320,7 +284,8 @@ class TestNotificationsEnumFuzzing:
}
try:
await tool_fn(
action="create",
action="notification",
subaction="create",
title=title,
subject=subject,
description=description,
@@ -344,19 +309,16 @@ class TestNotificationsEnumFuzzing:
return
async def _run_test():
tool_fn = make_tool_fn(
"unraid_mcp.tools.notifications",
"register_notifications_tool",
"unraid_notifications",
)
tool_fn = _make_tool()
with patch(
"unraid_mcp.tools.notifications.make_graphql_request",
"unraid_mcp.tools.unraid.make_graphql_request",
new_callable=AsyncMock,
) as mock:
mock.return_value = {"deleteNotification": {}}
try:
await tool_fn(
action="delete",
action="notification",
subaction="delete",
notification_id="some-id",
notification_type=notif_type,
confirm=True,
@@ -372,12 +334,11 @@ class TestNotificationsEnumFuzzing:
@given(st.text())
@settings(max_examples=100, suppress_health_check=[HealthCheck.function_scoped_fixture])
def test_invalid_action_raises_tool_error(self, action: str) -> None:
"""Invalid action names for notifications tool raise ToolError."""
valid_actions = {
def test_invalid_action_raises_tool_error(self, subaction: str) -> None:
"""Invalid subaction names for notifications domain raise ToolError."""
valid_subactions = {
"overview",
"list",
"warnings",
"create",
"archive",
"unread",
@@ -385,31 +346,26 @@ class TestNotificationsEnumFuzzing:
"delete_archived",
"archive_all",
"archive_many",
"create_unique",
"unarchive_many",
"unarchive_all",
"recalculate",
}
if action in valid_actions:
if subaction in valid_subactions:
return
async def _run_test():
tool_fn = make_tool_fn(
"unraid_mcp.tools.notifications",
"register_notifications_tool",
"unraid_notifications",
)
tool_fn = _make_tool()
with patch(
"unraid_mcp.tools.notifications.make_graphql_request",
"unraid_mcp.tools.unraid.make_graphql_request",
new_callable=AsyncMock,
):
try:
await tool_fn(action=action)
await tool_fn(action="notification", subaction=subaction)
except ToolError:
pass
except Exception as exc:
pytest.fail(
f"Action {action!r} raised {type(exc).__name__} "
f"subaction={subaction!r} raised {type(exc).__name__} "
f"instead of ToolError: {exc!r}"
)
@@ -425,7 +381,7 @@ class TestKeysInputFuzzing:
"""Fuzz API key management parameters.
Invariant: arbitrary key_id strings, names, and role lists never crash
the keys tool — only ToolError or clean return values are acceptable.
the keys domain — only ToolError or clean return values are acceptable.
"""
@given(st.text())
@@ -434,13 +390,13 @@ class TestKeysInputFuzzing:
"""Arbitrary key_id for 'get' must not crash the tool."""
async def _run_test():
tool_fn = make_tool_fn("unraid_mcp.tools.keys", "register_keys_tool", "unraid_keys")
tool_fn = _make_tool()
with patch(
"unraid_mcp.tools.keys.make_graphql_request", new_callable=AsyncMock
"unraid_mcp.tools.unraid.make_graphql_request", new_callable=AsyncMock
) as mock:
mock.return_value = {"apiKey": None}
try:
await tool_fn(action="get", key_id=key_id)
await tool_fn(action="key", subaction="get", key_id=key_id)
except ToolError:
pass
except Exception as exc:
@@ -454,15 +410,15 @@ class TestKeysInputFuzzing:
"""Arbitrary name strings for 'create' must not crash the tool."""
async def _run_test():
tool_fn = make_tool_fn("unraid_mcp.tools.keys", "register_keys_tool", "unraid_keys")
tool_fn = _make_tool()
with patch(
"unraid_mcp.tools.keys.make_graphql_request", new_callable=AsyncMock
"unraid_mcp.tools.unraid.make_graphql_request", new_callable=AsyncMock
) as mock:
mock.return_value = {
"apiKey": {"create": {"id": "1", "name": name, "key": "k", "roles": []}}
}
try:
await tool_fn(action="create", name=name)
await tool_fn(action="key", subaction="create", name=name)
except ToolError:
pass
except Exception as exc:
@@ -476,13 +432,15 @@ class TestKeysInputFuzzing:
"""Arbitrary role lists for 'add_role' must not crash the tool."""
async def _run_test():
tool_fn = make_tool_fn("unraid_mcp.tools.keys", "register_keys_tool", "unraid_keys")
tool_fn = _make_tool()
with patch(
"unraid_mcp.tools.keys.make_graphql_request", new_callable=AsyncMock
"unraid_mcp.tools.unraid.make_graphql_request", new_callable=AsyncMock
) as mock:
mock.return_value = {"apiKey": {"addRole": True}}
try:
await tool_fn(action="add_role", key_id="some-key-id", roles=roles)
await tool_fn(
action="key", subaction="add_role", key_id="some-key-id", roles=roles
)
except ToolError:
pass
except Exception as exc:
@@ -492,22 +450,22 @@ class TestKeysInputFuzzing:
@given(st.text())
@settings(max_examples=100, suppress_health_check=[HealthCheck.function_scoped_fixture])
def test_invalid_action_raises_tool_error(self, action: str) -> None:
"""Invalid action names for keys tool raise ToolError."""
valid_actions = {"list", "get", "create", "update", "delete", "add_role", "remove_role"}
if action in valid_actions:
def test_invalid_action_raises_tool_error(self, subaction: str) -> None:
"""Invalid subaction names for keys domain raise ToolError."""
valid_subactions = {"list", "get", "create", "update", "delete", "add_role", "remove_role"}
if subaction in valid_subactions:
return
async def _run_test():
tool_fn = make_tool_fn("unraid_mcp.tools.keys", "register_keys_tool", "unraid_keys")
with patch("unraid_mcp.tools.keys.make_graphql_request", new_callable=AsyncMock):
tool_fn = _make_tool()
with patch("unraid_mcp.tools.unraid.make_graphql_request", new_callable=AsyncMock):
try:
await tool_fn(action=action)
await tool_fn(action="key", subaction=subaction)
except ToolError:
pass
except Exception as exc:
pytest.fail(
f"Action {action!r} raised {type(exc).__name__} "
f"subaction={subaction!r} raised {type(exc).__name__} "
f"instead of ToolError: {exc!r}"
)
@@ -515,15 +473,15 @@ class TestKeysInputFuzzing:
# ---------------------------------------------------------------------------
# VM: arbitrary VM IDs and action names
# VM: arbitrary VM IDs and subaction names
# ---------------------------------------------------------------------------
class TestVMInputFuzzing:
"""Fuzz VM management parameters.
Invariant: arbitrary vm_id strings and action names must never crash
the VM tool — only ToolError or clean return values are acceptable.
Invariant: arbitrary vm_id strings and subaction names must never crash
the VM domain — only ToolError or clean return values are acceptable.
"""
@given(st.text())
@@ -532,16 +490,14 @@ class TestVMInputFuzzing:
"""Arbitrary vm_id for 'start' must not crash the tool."""
async def _run_test():
tool_fn = make_tool_fn(
"unraid_mcp.tools.virtualization", "register_vm_tool", "unraid_vm"
)
tool_fn = _make_tool()
with patch(
"unraid_mcp.tools.virtualization.make_graphql_request",
"unraid_mcp.tools.unraid.make_graphql_request",
new_callable=AsyncMock,
) as mock:
mock.return_value = {"vm": {"start": True}}
try:
await tool_fn(action="start", vm_id=vm_id)
await tool_fn(action="vm", subaction="start", vm_id=vm_id)
except ToolError:
pass
except Exception as exc:
@@ -555,16 +511,14 @@ class TestVMInputFuzzing:
"""Arbitrary vm_id for 'stop' must not crash the tool."""
async def _run_test():
tool_fn = make_tool_fn(
"unraid_mcp.tools.virtualization", "register_vm_tool", "unraid_vm"
)
tool_fn = _make_tool()
with patch(
"unraid_mcp.tools.virtualization.make_graphql_request",
"unraid_mcp.tools.unraid.make_graphql_request",
new_callable=AsyncMock,
) as mock:
mock.return_value = {"vm": {"stop": True}}
try:
await tool_fn(action="stop", vm_id=vm_id)
await tool_fn(action="vm", subaction="stop", vm_id=vm_id)
except ToolError:
pass
except Exception as exc:
@@ -578,17 +532,15 @@ class TestVMInputFuzzing:
"""Arbitrary vm_id for 'details' must not crash the tool."""
async def _run_test():
tool_fn = make_tool_fn(
"unraid_mcp.tools.virtualization", "register_vm_tool", "unraid_vm"
)
tool_fn = _make_tool()
with patch(
"unraid_mcp.tools.virtualization.make_graphql_request",
"unraid_mcp.tools.unraid.make_graphql_request",
new_callable=AsyncMock,
) as mock:
# Return an empty VM list so the lookup gracefully fails
mock.return_value = {"vms": {"domains": []}}
try:
await tool_fn(action="details", vm_id=vm_id)
await tool_fn(action="vm", subaction="details", vm_id=vm_id)
except ToolError:
pass
except Exception as exc:
@@ -598,9 +550,9 @@ class TestVMInputFuzzing:
@given(st.text())
@settings(max_examples=200, suppress_health_check=[HealthCheck.function_scoped_fixture])
def test_invalid_action_raises_tool_error(self, action: str) -> None:
"""Invalid action names for VM tool raise ToolError."""
valid_actions = {
def test_invalid_action_raises_tool_error(self, subaction: str) -> None:
"""Invalid subaction names for VM domain raise ToolError."""
valid_subactions = {
"list",
"details",
"start",
@@ -611,24 +563,22 @@ class TestVMInputFuzzing:
"reboot",
"reset",
}
if action in valid_actions:
if subaction in valid_subactions:
return
async def _run_test():
tool_fn = make_tool_fn(
"unraid_mcp.tools.virtualization", "register_vm_tool", "unraid_vm"
)
tool_fn = _make_tool()
with patch(
"unraid_mcp.tools.virtualization.make_graphql_request",
"unraid_mcp.tools.unraid.make_graphql_request",
new_callable=AsyncMock,
):
try:
await tool_fn(action=action)
await tool_fn(action="vm", subaction=subaction)
except ToolError:
pass
except Exception as exc:
pytest.fail(
f"Action {action!r} raised {type(exc).__name__} "
f"subaction={subaction!r} raised {type(exc).__name__} "
f"instead of ToolError: {exc!r}"
)
@@ -664,18 +614,16 @@ class TestBoundaryValues:
)
@settings(max_examples=50, suppress_health_check=[HealthCheck.function_scoped_fixture])
def test_docker_details_adversarial_inputs(self, container_id: str) -> None:
"""Adversarial container_id values must not crash the Docker tool."""
"""Adversarial container_id values must not crash the Docker domain."""
async def _run_test():
tool_fn = make_tool_fn(
"unraid_mcp.tools.docker", "register_docker_tool", "unraid_docker"
)
tool_fn = _make_tool()
with patch(
"unraid_mcp.tools.docker.make_graphql_request", new_callable=AsyncMock
"unraid_mcp.tools.unraid.make_graphql_request", new_callable=AsyncMock
) as mock:
mock.return_value = {"docker": {"containers": []}}
try:
await tool_fn(action="details", container_id=container_id)
await tool_fn(action="docker", subaction="details", container_id=container_id)
except ToolError:
pass
except Exception as exc:
@@ -702,13 +650,9 @@ class TestBoundaryValues:
"""Adversarial importance values must raise ToolError, not crash."""
async def _run_test():
tool_fn = make_tool_fn(
"unraid_mcp.tools.notifications",
"register_notifications_tool",
"unraid_notifications",
)
tool_fn = _make_tool()
with patch(
"unraid_mcp.tools.notifications.make_graphql_request",
"unraid_mcp.tools.unraid.make_graphql_request",
new_callable=AsyncMock,
) as mock:
mock.return_value = {
@@ -716,7 +660,8 @@ class TestBoundaryValues:
}
try:
await tool_fn(
action="create",
action="notification",
subaction="create",
title="t",
subject="s",
description="d",
@@ -743,13 +688,13 @@ class TestBoundaryValues:
"""Adversarial key_id values must not crash the keys get action."""
async def _run_test():
tool_fn = make_tool_fn("unraid_mcp.tools.keys", "register_keys_tool", "unraid_keys")
tool_fn = _make_tool()
with patch(
"unraid_mcp.tools.keys.make_graphql_request", new_callable=AsyncMock
"unraid_mcp.tools.unraid.make_graphql_request", new_callable=AsyncMock
) as mock:
mock.return_value = {"apiKey": None}
try:
await tool_fn(action="get", key_id=key_id)
await tool_fn(action="key", subaction="get", key_id=key_id)
except ToolError:
pass
except Exception as exc:
@@ -759,49 +704,46 @@ class TestBoundaryValues:
# ---------------------------------------------------------------------------
# Info: action guard (invalid actions on a read-only tool)
# Top-level action guard (invalid domain names)
# ---------------------------------------------------------------------------
class TestInfoActionGuard:
"""Fuzz the action parameter on unraid_info.
"""Fuzz the top-level action parameter (domain selector).
Invariant: the info tool exposes no mutations and its action guard must
reject any invalid action with a ToolError rather than a KeyError crash.
Invariant: the consolidated unraid tool must reject any invalid domain
with a ToolError rather than a KeyError crash.
"""
@given(st.text())
@settings(max_examples=200, suppress_health_check=[HealthCheck.function_scoped_fixture])
def test_invalid_action_raises_tool_error(self, action: str) -> None:
"""Invalid action names for the info tool raise ToolError."""
"""Invalid domain names raise ToolError."""
valid_actions = {
"overview",
"array",
"network",
"registration",
"variables",
"metrics",
"services",
"display",
"config",
"online",
"owner",
"settings",
"server",
"servers",
"flash",
"ups_devices",
"ups_device",
"ups_config",
"customization",
"disk",
"docker",
"health",
"key",
"live",
"notification",
"oidc",
"plugin",
"rclone",
"setting",
"system",
"user",
"vm",
}
if action in valid_actions:
return
async def _run_test():
tool_fn = make_tool_fn("unraid_mcp.tools.info", "register_info_tool", "unraid_info")
with patch("unraid_mcp.tools.info.make_graphql_request", new_callable=AsyncMock):
tool_fn = _make_tool()
with patch("unraid_mcp.tools.unraid.make_graphql_request", new_callable=AsyncMock):
try:
await tool_fn(action=action)
await tool_fn(action=action, subaction="list")
except ToolError:
pass
except Exception as exc: