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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
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