Files
unraid-mcp/docs/DESTRUCTIVE_ACTIONS.md
Jacob Magar efaab031ae fix: address all 17 PR review comments
Resolves review threads:
- PRRT_kwDOO6Hdxs50fewG (setup.py): non-eliciting clients now return True
  from elicit_reset_confirmation so they can reconfigure without being blocked
- PRRT_kwDOO6Hdxs50fewM (test-tools.sh): add notification/recalculate smoke test
- PRRT_kwDOO6Hdxs50fewP (test-tools.sh): add system/array smoke test
- PRRT_kwDOO6Hdxs50fewT (resources.py): surface manager error state instead of
  reporting 'connecting' for permanently failed subscriptions
- PRRT_kwDOO6Hdxs50feAj (resources.py): use is not None check for empty cached dicts
- PRRT_kwDOO6Hdxs50fewY (integration tests): remove duplicate snapshot-registration
  tests already covered in test_resources.py
- PRRT_kwDOO6Hdxs50fewe (test_resources.py): replace brittle import-detail test
  with behavior tests for connecting/error states
- PRRT_kwDOO6Hdxs50fewh (test_customization.py): strengthen public_theme assertion
- PRRT_kwDOO6Hdxs50fewk (test_customization.py): strengthen theme assertion
- PRRT_kwDOO6Hdxs50fewo (__init__.py): correct subaction count ~88 -> ~107
- PRRT_kwDOO6Hdxs50fewx (test_oidc.py): assert providers list value directly
- PRRT_kwDOO6Hdxs50fewz (unraid.py): remove unreachable raise after vm handler
- PRRT_kwDOO6Hdxs50few2 (unraid.py): remove unreachable raise after docker handler
- PRRT_kwDOO6Hdxs50fev8 (CLAUDE.md): replace legacy 15-tool table with unified
  unraid action/subaction table
- PRRT_kwDOO6Hdxs50fev_ (test_oidc.py): assert providers + defaultAllowedOrigins
- PRRT_kwDOO6Hdxs50feAz (CLAUDE.md): update tool categories to unified API shape
- PRRT_kwDOO6Hdxs50feBE (CLAUDE.md/setup.py): update unraid_health refs to
  unraid(action=health, subaction=setup)
2026-03-16 02:58:54 -04:00

11 KiB

Destructive Actions

Last Updated: 2026-03-16 Total destructive actions: 12 across 8 domains (single unraid tool)

All destructive actions require confirm=True at the call site. There is no additional environment variable gate — confirm is the sole guard.

mcporter commands below use $MCP_URL (default: http://localhost:6970/mcp). Run test-actions.sh for automated non-destructive coverage; destructive actions are always skipped there and tested manually per the strategies below.

Calling convention (v1.0.0+): All operations use the single unraid tool with action (domain) + subaction (operation). For example: mcporter call --http-url "$MCP_URL" --tool unraid --args '{"action":"docker","subaction":"list"}'


array

stop_array — Stop the Unraid array

Strategy: mock/safety audit only. Stopping the array unmounts all shares and can interrupt running containers and VMs accessing array data. Test via tests/safety/ confirming the confirm=False guard raises ToolError. Do not run live unless all containers and VMs are shut down first.


remove_disk — Remove a disk from the array

# Prerequisite: array must already be stopped; use a disk you intend to remove

mcporter call --http-url "$MCP_URL" --tool unraid \
  --args '{"action":"array","subaction":"remove_disk","disk_id":"<DISK_ID>","confirm":true}' --output json

clear_disk_stats — Clear I/O statistics for a disk (irreversible)

# Discover disk IDs
mcporter call --http-url "$MCP_URL" --tool unraid \
  --args '{"action":"disk","subaction":"disks"}' --output json

# Clear stats for a specific disk
mcporter call --http-url "$MCP_URL" --tool unraid \
  --args '{"action":"array","subaction":"clear_disk_stats","disk_id":"<DISK_ID>","confirm":true}' --output json

vm

force_stop — Hard power-off a VM (potential data corruption)

# Prerequisite: create a minimal Alpine test VM in Unraid VM manager
# (Alpine ISO, 512MB RAM, no persistent disk, name contains "mcp-test")

VID=$(mcporter call --http-url "$MCP_URL" --tool unraid \
  --args '{"action":"vm","subaction":"list"}' --output json \
  | python3 -c "import json,sys; vms=json.load(sys.stdin).get('vms',[]); print(next(v.get('uuid',v.get('id','')) for v in vms if 'mcp-test' in v.get('name','')))")

mcporter call --http-url "$MCP_URL" --tool unraid \
  --args "{\"action\":\"vm\",\"subaction\":\"force_stop\",\"vm_id\":\"$VID\",\"confirm\":true}" --output json

# Verify: VM state should return to stopped
mcporter call --http-url "$MCP_URL" --tool unraid \
  --args "{\"action\":\"vm\",\"subaction\":\"details\",\"vm_id\":\"$VID\"}" --output json

reset — Hard reset a VM (power cycle without graceful shutdown)

# Same minimal Alpine test VM as above
VID=$(mcporter call --http-url "$MCP_URL" --tool unraid \
  --args '{"action":"vm","subaction":"list"}' --output json \
  | python3 -c "import json,sys; vms=json.load(sys.stdin).get('vms',[]); print(next(v.get('uuid',v.get('id','')) for v in vms if 'mcp-test' in v.get('name','')))")

mcporter call --http-url "$MCP_URL" --tool unraid \
  --args "{\"action\":\"vm\",\"subaction\":\"reset\",\"vm_id\":\"$VID\",\"confirm\":true}" --output json

notification

delete — Permanently delete a notification

# 1. Create a test notification, then list to get the real stored ID (create response
#    ID is ULID-based; stored filename uses a unix timestamp, so IDs differ)
mcporter call --http-url "$MCP_URL" --tool unraid \
  --args '{"action":"notification","subaction":"create","title":"mcp-test-delete","subject":"safe to delete","description":"MCP destructive action test","importance":"INFO"}' --output json
NID=$(mcporter call --http-url "$MCP_URL" --tool unraid \
  --args '{"action":"notification","subaction":"list","notification_type":"UNREAD"}' --output json \
  | python3 -c "
import json,sys
notifs=json.load(sys.stdin).get('notifications',[])
matches=[n['id'] for n in reversed(notifs) if n.get('title')=='mcp-test-delete']
print(matches[0] if matches else '')")

# 2. Delete it (notification_type required)
mcporter call --http-url "$MCP_URL" --tool unraid \
  --args "{\"action\":\"notification\",\"subaction\":\"delete\",\"notification_id\":\"$NID\",\"notification_type\":\"UNREAD\",\"confirm\":true}" --output json

# 3. Verify
mcporter call --http-url "$MCP_URL" --tool unraid \
  --args '{"action":"notification","subaction":"list"}' --output json | python3 -c \
  "import json,sys; ns=[n for n in json.load(sys.stdin).get('notifications',[]) if 'mcp-test' in n.get('title','')]; print('clean' if not ns else ns)"

delete_archived — Wipe all archived notifications (bulk, irreversible)

# 1. Create and archive a test notification
mcporter call --http-url "$MCP_URL" --tool unraid \
  --args '{"action":"notification","subaction":"create","title":"mcp-test-archive-wipe","subject":"archive me","description":"safe to delete","importance":"INFO"}' --output json
AID=$(mcporter call --http-url "$MCP_URL" --tool unraid \
  --args '{"action":"notification","subaction":"list","notification_type":"UNREAD"}' --output json \
  | python3 -c "
import json,sys
notifs=json.load(sys.stdin).get('notifications',[])
matches=[n['id'] for n in reversed(notifs) if n.get('title')=='mcp-test-archive-wipe']
print(matches[0] if matches else '')")
mcporter call --http-url "$MCP_URL" --tool unraid \
  --args "{\"action\":\"notification\",\"subaction\":\"archive\",\"notification_id\":\"$AID\"}" --output json

# 2. Wipe all archived
# NOTE: this deletes ALL archived notifications, not just the test one
mcporter call --http-url "$MCP_URL" --tool unraid \
  --args '{"action":"notification","subaction":"delete_archived","confirm":true}' --output json

Run on shart if archival history on tootie matters.


rclone

delete_remote — Remove an rclone remote configuration

# 1. Create a throwaway local remote (points to /tmp — no real data)
#    Parameters: name (str), provider_type (str), config_data (dict)
mcporter call --http-url "$MCP_URL" --tool unraid \
  --args '{"action":"rclone","subaction":"create_remote","name":"mcp-test-remote","provider_type":"local","config_data":{"root":"/tmp"}}' --output json

# 2. Delete it
mcporter call --http-url "$MCP_URL" --tool unraid \
  --args '{"action":"rclone","subaction":"delete_remote","name":"mcp-test-remote","confirm":true}' --output json

# 3. Verify
mcporter call --http-url "$MCP_URL" --tool unraid \
  --args '{"action":"rclone","subaction":"list_remotes"}' --output json | python3 -c \
  "import json,sys; remotes=json.load(sys.stdin).get('remotes',[]); print('clean' if 'mcp-test-remote' not in remotes else 'FOUND — cleanup failed')"

Note: delete_remote removes the config only — it does NOT delete data in the remote storage.


key

delete — Delete an API key (immediately revokes access)

# 1. Create a test key (names cannot contain hyphens; ID is at key.id)
KID=$(mcporter call --http-url "$MCP_URL" --tool unraid \
  --args '{"action":"key","subaction":"create","name":"mcp test key","roles":["VIEWER"]}' --output json \
  | python3 -c "import json,sys; print(json.load(sys.stdin).get('key',{}).get('id',''))")

# 2. Delete it
mcporter call --http-url "$MCP_URL" --tool unraid \
  --args "{\"action\":\"key\",\"subaction\":\"delete\",\"key_id\":\"$KID\",\"confirm\":true}" --output json

# 3. Verify
mcporter call --http-url "$MCP_URL" --tool unraid \
  --args '{"action":"key","subaction":"list"}' --output json | python3 -c \
  "import json,sys; ks=json.load(sys.stdin).get('keys',[]); print('clean' if not any('mcp test key' in k.get('name','') for k in ks) else 'FOUND — cleanup failed')"

disk

flash_backup — Rclone backup of flash drive (overwrites destination)

# Prerequisite: create a dedicated test remote pointing away from real backup destination
# (use rclone create_remote first, or configure mcp-test-remote manually)

mcporter call --http-url "$MCP_URL" --tool unraid \
  --args '{"action":"disk","subaction":"flash_backup","remote_name":"mcp-test-remote","source_path":"/boot","destination_path":"/flash-backup-test","confirm":true}' --output json

Never point at the same destination as your real flash backup. Create a dedicated mcp-test-remote (see rclone: delete_remote above for provisioning pattern).


setting

configure_ups — Overwrite UPS monitoring configuration

Strategy: mock/safety audit only. Wrong config can break UPS integration. If live testing is required: read current config via unraid(action="system", subaction="ups_config"), save values, re-apply identical values (no-op), verify response matches. Test via tests/safety/ for guard behavior.


plugin

remove — Uninstall a plugin (irreversible without re-install)

Strategy: mock/safety audit only. Removing a plugin cannot be undone without a full re-install. Test via tests/safety/ confirming the confirm=False guard raises ToolError. Do not run live unless the plugin is intentionally being uninstalled.

# If live testing is necessary (intentional removal only):
mcporter call --http-url "$MCP_URL" --tool unraid \
  --args '{"action":"plugin","subaction":"remove","names":["<plugin-name>"],"confirm":true}' --output json

Safety Audit (Automated)

The tests/safety/ directory contains pytest tests that verify:

  • Every destructive action raises ToolError when called with confirm=False
  • Every destructive action raises ToolError when called without the confirm parameter
  • The _*_DESTRUCTIVE sets in unraid_mcp/tools/unraid.py stay in sync with the actions listed above
  • No GraphQL request reaches the network layer when confirmation is missing (TestNoGraphQLCallsWhenUnconfirmed)
  • Non-destructive actions never require confirm (TestNonDestructiveActionsNeverRequireConfirm)

These run as part of the standard test suite:

uv run pytest tests/safety/ -v

Summary Table

Domain (action=) Subaction Strategy Target Server
array stop_array Mock/safety audit only
array remove_disk Array must be stopped; use intended disk either
array clear_disk_stats Discover disk ID → clear either
vm force_stop Minimal Alpine test VM either
vm reset Minimal Alpine test VM either
notification delete Create notification → destroy either
notification delete_archived Create → archive → wipe shart preferred
rclone delete_remote Create local:/tmp remote → destroy either
key delete Create test key → destroy either
disk flash_backup Dedicated test remote, isolated path either
setting configure_ups Mock/safety audit only
plugin remove Mock/safety audit only