mirror of
https://github.com/jmagar/unraid-mcp.git
synced 2026-03-23 12:39:24 -07:00
feat(plugins): add unraid_plugins tool with list, add, remove actions
Implements the unraid_plugins MCP tool (3 actions, 1 destructive) and adds elicit_destructive_confirmation() to core/setup to support all tools that gate dangerous mutations behind confirm=True with optional MCP elicitation.
This commit is contained in:
@@ -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.keys import MUTATIONS as KEYS_MUTATIONS
|
||||||
from unraid_mcp.tools.notifications import DESTRUCTIVE_ACTIONS as NOTIF_DESTRUCTIVE
|
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.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 DESTRUCTIVE_ACTIONS as RCLONE_DESTRUCTIVE
|
||||||
from unraid_mcp.tools.rclone import MUTATIONS as RCLONE_MUTATIONS
|
from unraid_mcp.tools.rclone import MUTATIONS as RCLONE_MUTATIONS
|
||||||
from unraid_mcp.tools.settings import DESTRUCTIVE_ACTIONS as SETTINGS_DESTRUCTIVE
|
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"},
|
"actions": {"configure_ups"},
|
||||||
"runtime_set": SETTINGS_DESTRUCTIVE,
|
"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,
|
"keys": KEYS_MUTATIONS,
|
||||||
"storage": STORAGE_MUTATIONS,
|
"storage": STORAGE_MUTATIONS,
|
||||||
"settings": SETTINGS_MUTATIONS,
|
"settings": SETTINGS_MUTATIONS,
|
||||||
|
"plugins": PLUGINS_MUTATIONS,
|
||||||
}
|
}
|
||||||
mutations = mutations_map[tool_key]
|
mutations = mutations_map[tool_key]
|
||||||
for action in info["actions"]:
|
for action in info["actions"]:
|
||||||
@@ -151,6 +161,7 @@ class TestDestructiveActionRegistries:
|
|||||||
"keys": KEYS_MUTATIONS,
|
"keys": KEYS_MUTATIONS,
|
||||||
"storage": STORAGE_MUTATIONS,
|
"storage": STORAGE_MUTATIONS,
|
||||||
"settings": SETTINGS_MUTATIONS,
|
"settings": SETTINGS_MUTATIONS,
|
||||||
|
"plugins": PLUGINS_MUTATIONS,
|
||||||
}
|
}
|
||||||
all_destructive = {
|
all_destructive = {
|
||||||
"array": ARRAY_DESTRUCTIVE,
|
"array": ARRAY_DESTRUCTIVE,
|
||||||
@@ -160,6 +171,7 @@ class TestDestructiveActionRegistries:
|
|||||||
"keys": KEYS_DESTRUCTIVE,
|
"keys": KEYS_DESTRUCTIVE,
|
||||||
"storage": STORAGE_DESTRUCTIVE,
|
"storage": STORAGE_DESTRUCTIVE,
|
||||||
"settings": SETTINGS_DESTRUCTIVE,
|
"settings": SETTINGS_DESTRUCTIVE,
|
||||||
|
"plugins": PLUGINS_DESTRUCTIVE,
|
||||||
}
|
}
|
||||||
missing: list[str] = []
|
missing: list[str] = []
|
||||||
for tool_key, mutations in all_mutations.items():
|
for tool_key, mutations in all_mutations.items():
|
||||||
@@ -204,6 +216,8 @@ _DESTRUCTIVE_TEST_CASES: list[tuple[str, str, dict]] = [
|
|||||||
),
|
),
|
||||||
# Settings
|
# Settings
|
||||||
("settings", "configure_ups", {"ups_config": {"mode": "slave"}}),
|
("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
|
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)
|
# Map tool_key -> (module path, register fn, tool name)
|
||||||
_TOOL_REGISTRY = {
|
_TOOL_REGISTRY = {
|
||||||
"array": ("unraid_mcp.tools.array", "register_array_tool", "unraid_array"),
|
"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"),
|
"keys": ("unraid_mcp.tools.keys", "register_keys_tool", "unraid_keys"),
|
||||||
"storage": ("unraid_mcp.tools.storage", "register_storage_tool", "unraid_storage"),
|
"storage": ("unraid_mcp.tools.storage", "register_storage_tool", "unraid_storage"),
|
||||||
"settings": ("unraid_mcp.tools.settings", "register_settings_tool", "unraid_settings"),
|
"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_keys_graphql: AsyncMock,
|
||||||
_mock_storage_graphql: AsyncMock,
|
_mock_storage_graphql: AsyncMock,
|
||||||
_mock_settings_graphql: AsyncMock,
|
_mock_settings_graphql: AsyncMock,
|
||||||
|
_mock_plugins_graphql: AsyncMock,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Calling a destructive action without confirm=True must raise ToolError."""
|
"""Calling a destructive action without confirm=True must raise ToolError."""
|
||||||
module_path, register_fn, tool_name = _TOOL_REGISTRY[tool_key]
|
module_path, register_fn, tool_name = _TOOL_REGISTRY[tool_key]
|
||||||
@@ -305,6 +327,7 @@ class TestConfirmationGuards:
|
|||||||
_mock_keys_graphql: AsyncMock,
|
_mock_keys_graphql: AsyncMock,
|
||||||
_mock_storage_graphql: AsyncMock,
|
_mock_storage_graphql: AsyncMock,
|
||||||
_mock_settings_graphql: AsyncMock,
|
_mock_settings_graphql: AsyncMock,
|
||||||
|
_mock_plugins_graphql: AsyncMock,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Explicitly passing confirm=False must still raise ToolError."""
|
"""Explicitly passing confirm=False must still raise ToolError."""
|
||||||
module_path, register_fn, tool_name = _TOOL_REGISTRY[tool_key]
|
module_path, register_fn, tool_name = _TOOL_REGISTRY[tool_key]
|
||||||
@@ -326,6 +349,7 @@ class TestConfirmationGuards:
|
|||||||
_mock_keys_graphql: AsyncMock,
|
_mock_keys_graphql: AsyncMock,
|
||||||
_mock_storage_graphql: AsyncMock,
|
_mock_storage_graphql: AsyncMock,
|
||||||
_mock_settings_graphql: AsyncMock,
|
_mock_settings_graphql: AsyncMock,
|
||||||
|
_mock_plugins_graphql: AsyncMock,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""The error message should include the action name for clarity."""
|
"""The error message should include the action name for clarity."""
|
||||||
module_path, register_fn, tool_name = _TOOL_REGISTRY[tool_key]
|
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")
|
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)
|
result = await tool_fn(action="clear_disk_stats", disk_id="abc:local", confirm=True)
|
||||||
assert result["success"] is 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
|
||||||
|
|||||||
72
tests/test_plugins.py
Normal file
72
tests/test_plugins.py
Normal file
@@ -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
|
||||||
@@ -26,6 +26,54 @@ class _UnraidCredentials:
|
|||||||
api_key: str
|
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:
|
async def elicit_and_configure(ctx: Context | None) -> bool:
|
||||||
"""Prompt the user for Unraid credentials via MCP elicitation.
|
"""Prompt the user for Unraid credentials via MCP elicitation.
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ from .tools.info import register_info_tool
|
|||||||
from .tools.keys import register_keys_tool
|
from .tools.keys import register_keys_tool
|
||||||
from .tools.live import register_live_tool
|
from .tools.live import register_live_tool
|
||||||
from .tools.notifications import register_notifications_tool
|
from .tools.notifications import register_notifications_tool
|
||||||
|
from .tools.plugins import register_plugins_tool
|
||||||
from .tools.rclone import register_rclone_tool
|
from .tools.rclone import register_rclone_tool
|
||||||
from .tools.settings import register_settings_tool
|
from .tools.settings import register_settings_tool
|
||||||
from .tools.storage import register_storage_tool
|
from .tools.storage import register_storage_tool
|
||||||
@@ -61,6 +62,7 @@ def register_all_modules() -> None:
|
|||||||
register_docker_tool,
|
register_docker_tool,
|
||||||
register_vm_tool,
|
register_vm_tool,
|
||||||
register_notifications_tool,
|
register_notifications_tool,
|
||||||
|
register_plugins_tool,
|
||||||
register_rclone_tool,
|
register_rclone_tool,
|
||||||
register_users_tool,
|
register_users_tool,
|
||||||
register_keys_tool,
|
register_keys_tool,
|
||||||
|
|||||||
113
unraid_mcp/tools/plugins.py
Normal file
113
unraid_mcp/tools/plugins.py
Normal file
@@ -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")
|
||||||
Reference in New Issue
Block a user