diff --git a/tests/safety/test_destructive_guards.py b/tests/safety/test_destructive_guards.py index f9a5eba..d29096f 100644 --- a/tests/safety/test_destructive_guards.py +++ b/tests/safety/test_destructive_guards.py @@ -25,6 +25,8 @@ from unraid_mcp.tools.keys import DESTRUCTIVE_ACTIONS as KEYS_DESTRUCTIVE from unraid_mcp.tools.keys import MUTATIONS as KEYS_MUTATIONS from unraid_mcp.tools.notifications import DESTRUCTIVE_ACTIONS as NOTIF_DESTRUCTIVE from unraid_mcp.tools.notifications import MUTATIONS as NOTIF_MUTATIONS +from unraid_mcp.tools.plugins import DESTRUCTIVE_ACTIONS as PLUGINS_DESTRUCTIVE +from unraid_mcp.tools.plugins import MUTATIONS as PLUGINS_MUTATIONS from unraid_mcp.tools.rclone import DESTRUCTIVE_ACTIONS as RCLONE_DESTRUCTIVE from unraid_mcp.tools.rclone import MUTATIONS as RCLONE_MUTATIONS from unraid_mcp.tools.settings import DESTRUCTIVE_ACTIONS as SETTINGS_DESTRUCTIVE @@ -90,6 +92,13 @@ KNOWN_DESTRUCTIVE: dict[str, dict[str, set[str] | str]] = { "actions": {"configure_ups"}, "runtime_set": SETTINGS_DESTRUCTIVE, }, + "plugins": { + "module": "unraid_mcp.tools.plugins", + "register_fn": "register_plugins_tool", + "tool_name": "unraid_plugins", + "actions": {"remove"}, + "runtime_set": PLUGINS_DESTRUCTIVE, + }, } @@ -121,6 +130,7 @@ class TestDestructiveActionRegistries: "keys": KEYS_MUTATIONS, "storage": STORAGE_MUTATIONS, "settings": SETTINGS_MUTATIONS, + "plugins": PLUGINS_MUTATIONS, } mutations = mutations_map[tool_key] for action in info["actions"]: @@ -151,6 +161,7 @@ class TestDestructiveActionRegistries: "keys": KEYS_MUTATIONS, "storage": STORAGE_MUTATIONS, "settings": SETTINGS_MUTATIONS, + "plugins": PLUGINS_MUTATIONS, } all_destructive = { "array": ARRAY_DESTRUCTIVE, @@ -160,6 +171,7 @@ class TestDestructiveActionRegistries: "keys": KEYS_DESTRUCTIVE, "storage": STORAGE_DESTRUCTIVE, "settings": SETTINGS_DESTRUCTIVE, + "plugins": PLUGINS_DESTRUCTIVE, } missing: list[str] = [] for tool_key, mutations in all_mutations.items(): @@ -204,6 +216,8 @@ _DESTRUCTIVE_TEST_CASES: list[tuple[str, str, dict]] = [ ), # Settings ("settings", "configure_ups", {"ups_config": {"mode": "slave"}}), + # Plugins + ("plugins", "remove", {"names": ["my-plugin"]}), ] @@ -252,6 +266,12 @@ def _mock_settings_graphql() -> Generator[AsyncMock, None, None]: yield m +@pytest.fixture +def _mock_plugins_graphql() -> Generator[AsyncMock, None, None]: + with patch("unraid_mcp.tools.plugins.make_graphql_request", new_callable=AsyncMock) as m: + yield m + + # Map tool_key -> (module path, register fn, tool name) _TOOL_REGISTRY = { "array": ("unraid_mcp.tools.array", "register_array_tool", "unraid_array"), @@ -265,6 +285,7 @@ _TOOL_REGISTRY = { "keys": ("unraid_mcp.tools.keys", "register_keys_tool", "unraid_keys"), "storage": ("unraid_mcp.tools.storage", "register_storage_tool", "unraid_storage"), "settings": ("unraid_mcp.tools.settings", "register_settings_tool", "unraid_settings"), + "plugins": ("unraid_mcp.tools.plugins", "register_plugins_tool", "unraid_plugins"), } @@ -284,6 +305,7 @@ class TestConfirmationGuards: _mock_keys_graphql: AsyncMock, _mock_storage_graphql: AsyncMock, _mock_settings_graphql: AsyncMock, + _mock_plugins_graphql: AsyncMock, ) -> None: """Calling a destructive action without confirm=True must raise ToolError.""" module_path, register_fn, tool_name = _TOOL_REGISTRY[tool_key] @@ -305,6 +327,7 @@ class TestConfirmationGuards: _mock_keys_graphql: AsyncMock, _mock_storage_graphql: AsyncMock, _mock_settings_graphql: AsyncMock, + _mock_plugins_graphql: AsyncMock, ) -> None: """Explicitly passing confirm=False must still raise ToolError.""" module_path, register_fn, tool_name = _TOOL_REGISTRY[tool_key] @@ -326,6 +349,7 @@ class TestConfirmationGuards: _mock_keys_graphql: AsyncMock, _mock_storage_graphql: AsyncMock, _mock_settings_graphql: AsyncMock, + _mock_plugins_graphql: AsyncMock, ) -> None: """The error message should include the action name for clarity.""" module_path, register_fn, tool_name = _TOOL_REGISTRY[tool_key] @@ -443,3 +467,11 @@ class TestConfirmAllowsExecution: tool_fn = make_tool_fn("unraid_mcp.tools.array", "register_array_tool", "unraid_array") result = await tool_fn(action="clear_disk_stats", disk_id="abc:local", confirm=True) assert result["success"] is True + + async def test_plugins_remove_with_confirm(self, _mock_plugins_graphql: AsyncMock) -> None: + _mock_plugins_graphql.return_value = {"removePlugin": True} + tool_fn = make_tool_fn( + "unraid_mcp.tools.plugins", "register_plugins_tool", "unraid_plugins" + ) + result = await tool_fn(action="remove", names=["my-plugin"], confirm=True) + assert result["success"] is True diff --git a/tests/test_plugins.py b/tests/test_plugins.py new file mode 100644 index 0000000..756eec6 --- /dev/null +++ b/tests/test_plugins.py @@ -0,0 +1,72 @@ +# tests/test_plugins.py +"""Tests for unraid_plugins tool.""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest +from fastmcp import FastMCP + + +@pytest.fixture +def mcp(): + return FastMCP("test") + + +@pytest.fixture +def _mock_graphql(): + with patch("unraid_mcp.tools.plugins.make_graphql_request") as m: + yield m + + +def _make_tool(mcp): + from unraid_mcp.tools.plugins import register_plugins_tool + + register_plugins_tool(mcp) + # FastMCP 3.x: access tool fn via internal provider components (same as conftest.make_tool_fn) + local_provider = mcp.providers[0] + tool = local_provider._components["tool:unraid_plugins@"] + return tool.fn + + +@pytest.mark.asyncio +async def test_list_returns_plugins(mcp, _mock_graphql): + _mock_graphql.return_value = { + "plugins": [ + {"name": "my-plugin", "version": "1.0.0", "hasApiModule": True, "hasCliModule": False} + ] + } + result = await _make_tool(mcp)(action="list") + assert result["success"] is True + assert len(result["data"]["plugins"]) == 1 + + +@pytest.mark.asyncio +async def test_add_requires_names(mcp, _mock_graphql): + from unraid_mcp.core.exceptions import ToolError + + with pytest.raises(ToolError, match="names"): + await _make_tool(mcp)(action="add") + + +@pytest.mark.asyncio +async def test_add_success(mcp, _mock_graphql): + _mock_graphql.return_value = {"addPlugin": False} # False = auto-restart triggered + result = await _make_tool(mcp)(action="add", names=["my-plugin"]) + assert result["success"] is True + + +@pytest.mark.asyncio +async def test_remove_requires_confirm(mcp, _mock_graphql): + from unraid_mcp.core.exceptions import ToolError + + with pytest.raises(ToolError, match="not confirmed"): + await _make_tool(mcp)(action="remove", names=["my-plugin"], confirm=False) + + +@pytest.mark.asyncio +async def test_remove_with_confirm(mcp, _mock_graphql): + _mock_graphql.return_value = {"removePlugin": True} + result = await _make_tool(mcp)(action="remove", names=["my-plugin"], confirm=True) + assert result["success"] is True diff --git a/unraid_mcp/core/setup.py b/unraid_mcp/core/setup.py index ed935b1..ca4209d 100644 --- a/unraid_mcp/core/setup.py +++ b/unraid_mcp/core/setup.py @@ -26,6 +26,54 @@ class _UnraidCredentials: api_key: str +async def elicit_destructive_confirmation( + ctx: Context | None, action: str, description: str +) -> bool: + """Prompt the user to confirm a destructive action via MCP elicitation. + + Args: + ctx: The MCP context for elicitation. If None, returns False immediately. + action: The action name (for display in the prompt). + description: Human-readable description of what the action will do. + + Returns: + True if the user accepted, False if declined, cancelled, or no context. + """ + if ctx is None: + logger.warning( + "Cannot elicit confirmation for '%s': no MCP context available. " + "Re-run with confirm=True to bypass elicitation.", + action, + ) + return False + + try: + result = await ctx.elicit( + message=( + f"**Confirm destructive action: `{action}`**\n\n" + f"{description}\n\n" + "Are you sure you want to proceed?" + ), + response_type=bool, + ) + except NotImplementedError: + logger.warning( + "MCP client does not support elicitation for action '%s'. " + "Re-run with confirm=True to bypass.", + action, + ) + return False + + if result.action != "accept": + logger.info("Destructive action '%s' declined by user (%s).", action, result.action) + return False + + confirmed: bool = result.data + if not confirmed: + logger.info("Destructive action '%s' not confirmed by user.", action) + return confirmed + + async def elicit_and_configure(ctx: Context | None) -> bool: """Prompt the user for Unraid credentials via MCP elicitation. diff --git a/unraid_mcp/server.py b/unraid_mcp/server.py index a8ef698..e4c1274 100644 --- a/unraid_mcp/server.py +++ b/unraid_mcp/server.py @@ -27,6 +27,7 @@ from .tools.info import register_info_tool from .tools.keys import register_keys_tool from .tools.live import register_live_tool from .tools.notifications import register_notifications_tool +from .tools.plugins import register_plugins_tool from .tools.rclone import register_rclone_tool from .tools.settings import register_settings_tool from .tools.storage import register_storage_tool @@ -61,6 +62,7 @@ def register_all_modules() -> None: register_docker_tool, register_vm_tool, register_notifications_tool, + register_plugins_tool, register_rclone_tool, register_users_tool, register_keys_tool, diff --git a/unraid_mcp/tools/plugins.py b/unraid_mcp/tools/plugins.py new file mode 100644 index 0000000..26f1d2d --- /dev/null +++ b/unraid_mcp/tools/plugins.py @@ -0,0 +1,113 @@ +"""Plugin management for the Unraid API. + +Provides the `unraid_plugins` tool with 3 actions: list, add, remove. +""" + +from __future__ import annotations + +from typing import Any, Literal, get_args + +from fastmcp import Context, FastMCP + +from ..config.logging import logger +from ..core.client import make_graphql_request +from ..core.exceptions import ToolError, tool_error_handler +from ..core.setup import elicit_destructive_confirmation + + +QUERIES: dict[str, str] = { + "list": """ + query ListPlugins { + plugins { name version hasApiModule hasCliModule } + } + """, +} + +MUTATIONS: dict[str, str] = { + "add": """ + mutation AddPlugin($input: PluginManagementInput!) { + addPlugin(input: $input) + } + """, + "remove": """ + mutation RemovePlugin($input: PluginManagementInput!) { + removePlugin(input: $input) + } + """, +} + +DESTRUCTIVE_ACTIONS = {"remove"} +ALL_ACTIONS = set(QUERIES) | set(MUTATIONS) + +PLUGIN_ACTIONS = Literal["add", "list", "remove"] + +if set(get_args(PLUGIN_ACTIONS)) != ALL_ACTIONS: + _missing = ALL_ACTIONS - set(get_args(PLUGIN_ACTIONS)) + _extra = set(get_args(PLUGIN_ACTIONS)) - ALL_ACTIONS + raise RuntimeError( + f"PLUGIN_ACTIONS and ALL_ACTIONS are out of sync. " + f"Missing: {_missing or 'none'}. Extra: {_extra or 'none'}" + ) + + +def register_plugins_tool(mcp: FastMCP) -> None: + """Register the unraid_plugins tool with the FastMCP instance.""" + + @mcp.tool() + async def unraid_plugins( + action: PLUGIN_ACTIONS, + ctx: Context | None = None, + confirm: bool = False, + names: list[str] | None = None, + bundled: bool = False, + restart: bool = True, + ) -> dict[str, Any]: + """Manage Unraid API plugins. + + Actions: + list - List all installed plugins with version and module info + add - Install one or more plugins (requires names: list of package names) + remove - Remove one or more plugins (requires names, confirm=True) + + Parameters: + names - List of plugin package names (required for add/remove) + bundled - Whether plugins are bundled (default: False) + restart - Whether to auto-restart API after operation (default: True) + """ + if action not in ALL_ACTIONS: + raise ToolError(f"Invalid action '{action}'. Must be one of: {sorted(ALL_ACTIONS)}") + + if action in DESTRUCTIVE_ACTIONS and not confirm: + _desc = f"Remove plugin(s) **{names}** from the Unraid API. This cannot be undone without re-installing." + confirmed = await elicit_destructive_confirmation(ctx, action, _desc) + if not confirmed: + raise ToolError( + f"Action '{action}' was not confirmed. " + "Re-run with confirm=True to bypass elicitation." + ) + + with tool_error_handler("plugins", action, logger): + logger.info(f"Executing unraid_plugins action={action}") + + if action == "list": + data = await make_graphql_request(QUERIES["list"]) + return {"success": True, "action": action, "data": data} + + if action in ("add", "remove"): + if not names: + raise ToolError(f"names is required for '{action}' action") + input_data = {"names": names, "bundled": bundled, "restart": restart} + mutation_key = "add" if action == "add" else "remove" + data = await make_graphql_request(MUTATIONS[mutation_key], {"input": input_data}) + result_key = "addPlugin" if action == "add" else "removePlugin" + restart_required = data.get(result_key) + return { + "success": True, + "action": action, + "names": names, + "manual_restart_required": restart_required, + } + + raise ToolError(f"Unhandled action '{action}' — this is a bug") + + logger.info("Plugins tool registered successfully")