Moves the subscription query dicts out of tools/live.py into a new
subscriptions/queries.py module so subscriptions/resources.py can
import them without creating a cross-layer subscriptions→tools dependency.
Two new test classes:
TestNoGraphQLCallsWhenUnconfirmed (parametrized over all 13 destructive actions):
- test_no_graphql_call_without_confirm: make_graphql_request must NOT be
called when confirm is absent — verifies guard fires before any I/O
- test_no_graphql_call_with_confirm_false: same with explicit confirm=False
TestNonDestructiveActionsNeverRequireConfirm (5 representative non-destructive):
- Regression guard: non-destructive mutations must work without confirm=True;
prevents accidental over-guarding from breaking normal operations
788 tests passing
stop_array can cause data loss for running containers/VMs that depend on
array shares — requires confirm=True like other destructive mutations.
- Add stop_array to DESTRUCTIVE_ACTIONS and desc_map in array.py
- Update safety audit KNOWN_DESTRUCTIVE[array] to include stop_array
- Add stop_array negative/positive tests (test_array.py, safety tests)
- Add test_snapshot_wraps_bare_exception to test_live.py (bare Exception
from subscribe_once is wrapped by tool_error_handler into ToolError)
748 tests passing
- Remove `from __future__ import annotations` from array.py, live.py,
oidc.py, plugins.py to match existing tool pattern and resolve TC002
ruff errors (fastmcp imports only needed in annotations under PEP 563)
- Add `# noqa: ASYNC109` to live.py timeout parameter (asyncio.timeout
already used internally)
- Fix test_network_sends_correct_query: query name is GetNetworkInfo
- Fix test_delete_requires_confirm: match "not confirmed" not "destructive"
- Fix test_destructive_set_matches_audit[settings]: add setup_remote_access
and enable_dynamic_remote_access to KNOWN_DESTRUCTIVE
- Fix test_logs: update mock to dict format {lines: [{timestamp, message}]}
742 tests passing, ruff clean
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.
Adds two new mutation actions to unraid_keys:
- add_role: calls apiKey.addRole with apiKeyId + role, requires key_id and roles
- remove_role: calls apiKey.removeRole with apiKeyId + role, requires key_id and roles
Updates safety audit to explicitly exempt remove_role from the delete/remove
heuristic (reversible action — role can be re-added). Updates schema coverage
test and adds schema validation tests for both new mutations.
Expands unraid_array from 5 to 13 actions: adds parity_history query,
start_array/stop_array state mutations, and disk operations (add_disk,
remove_disk, mount_disk, unmount_disk, clear_disk_stats). Destructive
actions remove_disk and clear_disk_stats require confirm=True. Safety
audit tests updated to cover the new DESTRUCTIVE_ACTIONS registry entry.
Creates unraid_mcp/tools/live.py with SNAPSHOT_ACTIONS (9 one-shot reads)
and COLLECT_ACTIONS (2 streaming collectors), plus tests/test_live.py
with 6 passing tests. Registers register_live_tool in server.py, bringing
the total to 12 tools.
- Wrap async-for loops in asyncio.timeout() so both subscribe_once and subscribe_collect
cannot hang indefinitely when no messages arrive after the handshake
- subscribe_once: TimeoutError → ToolError("Subscription timed out after Xs")
- subscribe_collect: TimeoutError → pass (return events collected so far)
- Remove manual deadline checks inside the loops (now redundant)
- subscribe_collect now raises ToolError on GraphQL payload errors instead of silently dropping them
- subscribe_collect handshake now distinguishes connection_error (auth) from unexpected type
Converts the CredentialsNotConfiguredError sentinel to a user-facing ToolError
in tool_error_handler, including the exact CREDENTIALS_ENV_PATH so users know
where to create the .env file. Removes the now-invalid per-tool elicitation
test (replaced by 2 new tests for the handler conversion behavior).
- _write_env now creates CREDENTIALS_DIR (mode 700) and writes credentials
to CREDENTIALS_ENV_PATH (mode 600) instead of PROJECT_ROOT/.env
- On first run (no .env yet), seeds file content from .env.example to
preserve comments and structure
- elicit_and_configure catches NotImplementedError from ctx.elicit() so
clients that don't support elicitation return False gracefully instead
of propagating the exception
- Updated test_elicit_and_configure_writes_env_file to patch CREDENTIALS_DIR
and CREDENTIALS_ENV_PATH instead of PROJECT_ROOT
- Added 5 new tests covering dir/file permissions, .env.example seeding,
in-place credential update, and NotImplementedError guard
Introduce a version-agnostic credential directory (~/.unraid-mcp, overridable
via UNRAID_CREDENTIALS_DIR env var) and surface it as CREDENTIALS_DIR and
CREDENTIALS_ENV_PATH module-level constants. Prepend the canonical .env path to
dotenv_paths so all runtimes (plugin, uv, Docker) resolve credentials from the
same stable location without relying on versioned plugin cache paths.
make_graphql_request now reads credentials from the settings module at call
time (via a local import) instead of relying on module-level names captured at
import time. When either credential is missing it raises CredentialsNotConfiguredError
(not ToolError), allowing callers to trigger elicitation rather than surfacing a
generic error to the MCP client.
Updated tests/test_client.py and tests/http_layer/test_request_construction.py
to patch unraid_mcp.config.settings.* instead of the now-removed client-module
attrs, and to expect CredentialsNotConfiguredError on missing credentials.
Always update both pyproject.toml and .claude-plugin/plugin.json
when bumping versions — missed in 0.4.4→0.4.5 bump.
Co-Authored-By: Claude <noreply@anthropic.com>
- Move scripts/test-tools.sh and scripts/test-actions.sh → tests/mcporter/
- Fix PROJECT_DIR path in test-tools.sh (SCRIPT_DIR/.. → SCRIPT_DIR/../..)
- Add tests/mcporter/test-destructive.sh: 2 live + 13 skipped destructive tests
- stdio transport (no running server required)
- notifications:delete (create→list→delete), keys:delete (create→delete→verify)
- 3 new skips: createDockerFolder/updateSshSettings/createRCloneRemote not in API
- Requires --confirm flag; dry-run by default
- Add tests/mcporter/README.md documenting both scripts and coverage
- Rewrite docs/DESTRUCTIVE_ACTIONS.md: merge test guide, all 15 actions with commands
- Delete docs/test-actions.md (merged into tests/mcporter/README.md)
- Fix rclone.py create_remote: send "parameters" not "config" (API field name)
- Update README.md and CLAUDE.md: 11 tools/~104 actions, new script paths
- Add AGENTS.md and GEMINI.md symlinks to CLAUDE.md
- Bump version 0.4.3 → 0.4.4
Co-authored-by: Claude <noreply@anthropic.com>
Adds scripts/test-actions.sh — a mcporter-based smoke test that exercises
all 42 non-destructive MCP actions across 10 tools. Two-phase design:
phase 1 runs param-free reads; phase 2 extracts resource IDs from those
responses to test the ID-required reads (container details/logs, network
details, disk details, log content, VM details, API key get).
Also adds docs/test-actions.md with full usage, coverage table, skip
rationale, and cleanup notes.
Fixed three bugs discovered during test run:
- Connectivity check used curl -f which treats 406 (correct MCP response
to plain GET) as failure
- run_test_capture wrote status lines to stdout causing captured $() to
contain mixed text+JSON, breaking all Phase 2 ID extraction
- run_test echoed full JSON response to terminal on every call
Co-Authored-By: Claude <noreply@anthropic.com>
- Remove duplicate _cap_log_content definition (dead code merge artifact)
from manager.py; keep byte-count version that correctly handles multibyte UTF-8
- Fix storage.py unassigned handler reading wrong key (unassignedDevices → disks)
— query already fetched `disks {}` but handler returned empty list every call
- Add null checks to all 8 Docker organizer object mutations; raise ToolError
instead of silently returning success=True with organizer=None
- Raise ToolError in docker logs when server returns no log data
- Extract notification object from create response (was returning raw GraphQL
wrapper dict instead of the notification itself)
- Raise ToolError in test_subscription_query on connection failure and unexpected
exceptions (was returning error dicts, bypassing error handling)
- Remove stale "Bug N fix" inline comments from diagnostics.py
- Update docker.py module docstring to reflect 26 actions (was 15)
- Bump version 0.4.1 → 0.4.2
Co-authored-by: Claude <claude@anthropic.com>
- diagnostics.py: fix allow-list vs field name mismatch in subscription validator
(_ALLOWED_SUBSCRIPTION_FIELDS now contains schema field names like "logFile",
not operation names like "logFileSubscription", matching what _SUBSCRIPTION_NAME_PATTERN
extracts); add _validate_subscription_query() called before any network I/O;
replace chained .replace() URL building with build_ws_url(); gate connection_issues
on current failure state via _analyze_subscription_status()
- manager.py: add _cap_log_content() with byte-count pre-check
(len(value.encode("utf-8", errors="replace")) > _MAX_RESOURCE_DATA_BYTES) so
multibyte UTF-8 content cannot bypass the 1 MB cap
- resources.py: add double-checked locking (_startup_lock) in ensure_subscriptions_started();
propagate exception from auto_start_all_subscriptions() via raise so
_subscriptions_started=True is never set after a failed init
- utils.py: add build_ws_url() that raises ValueError on unknown/missing URL scheme
instead of silently falling through; add _analyze_subscription_status() helper
that gates connection_issues on current failure state
Resolves review threads PRRT_kwDOO6Hdxs50E50Y PRRT_kwDOO6Hdxs50E50a PRRT_kwDOO6Hdxs50E50c PRRT_kwDOO6Hdxs50E50d PRRT_kwDOO6Hdxs50E2iN PRRT_kwDOO6Hdxs50E2h8
- info.py: add DESTRUCTIVE_ACTIONS set with update_ssh, add confirm param to
unraid_info signature, add destructive guard before mutation handlers
- settings.py: build user_info dict unconditionally so avatar is included
even when username/email are absent; only attach userInfo when non-empty
Resolves review threads PRRT_kwDOO6Hdxs50FgO0 PRRT_kwDOO6Hdxs50FgPC
- create_folder_with_items: forward source_entry_ids not entry_ids to sourceEntryIds
- set_folder_children: use `is not None` guard to allow children_ids=[]
- _resolve_container_id: allow short hex ID matching independent of strict mode
Resolves review threads PRRT_kwDOO6Hdxs50FgOr PRRT_kwDOO6Hdxs50FgPO PRRT_kwDOO6Hdxs50E2iH
These directories contain session-specific research artifacts and plan
documents — ephemeral content that doesn't belong in the repo history.
Resolves review threads PRRT_kwDOO6Hdxs50E50H PRRT_kwDOO6Hdxs50E2iX
PRRT_kwDOO6Hdxs50E2if PRRT_kwDOO6Hdxs50E2ig PRRT_kwDOO6Hdxs50E2ii
PRRT_kwDOO6Hdxs50E2iq
P-01: Replace single subscription_lock with two fine-grained locks:
- _task_lock guards active_subscriptions (task lifecycle operations)
- _data_lock guards resource_data (WebSocket message writes and reads)
Eliminates serialization between WebSocket updates and tool reads.
CQ-05: safe_get now preserves explicit None at terminal key.
Uses sentinel _MISSING to distinguish "key absent" (returns default)
from "key=null" (returns None). Fixes conflation that masked
intentional null values from the Unraid API.
SEC-M04: Validate list_type, importance, and notification_type against
known enums before dispatching to GraphQL. Prevents wasting rate-limited
requests on invalid values and avoids leaking schema details in errors.
_QueryCache.get/put/invalidate_all are async (use asyncio.Lock internally).
Updated 6 sync test methods to async def with proper await calls so they
test the actual async interface rather than calling unawaited coroutines.
- http_layer/test_request_construction.py: tighten JSON error match from
"invalid response" to "invalid response.*not valid JSON" to prevent
false positives
- safety/test_destructive_guards.py: add test_docker_update_all_with_confirm
to TestConfirmAllowsExecution (was missing positive coverage for update_all)
- safety/test_destructive_guards.py: expand conftest import comment to explain
why the direct conftest import is intentional and correct
Resolves review threads:
- PRRT_kwDOO6Hdxs5vNroH (Thread 36): tests now verify generic ToolError message
instead of raw exception text (security: no sensitive data in user-facing errors)
- PRRT_kwDOO6Hdxs5vNuYg (Thread 14): format_kb KB branch now uses :.2f like all
other branches (consistency fix)
- I001/F841/PERF401: fix ruff violations in http_layer, integration, safety tests
Changes:
- tests/test_array.py: match "Failed to execute array/parity_status" (not raw error)
- tests/test_keys.py: match "Failed to execute keys/list" (not raw error)
- tests/test_notifications.py: match "Failed to execute notifications/overview" (not raw error)
- tests/test_storage.py: update format_kb assertion to "512.00 KB" (:.2f format)
- tests/http_layer/test_request_construction.py: remove unused result var (F841)
+ fix import sort (I001)
- tests/safety/test_destructive_guards.py: use list.extend (PERF401) + fix import sort
- unraid_mcp/core/utils.py: format_kb returns f"{k:.2f} KB" for sub-MB values
Co-authored-by: @coderabbitai
Co-authored-by: @cubic-dev-ai
Co-authored-by: @copilot-pull-request-reviewer
Move MCP server configuration from standalone .mcp.json to inline
definition in plugin.json. This consolidates all plugin metadata
in a single location.
- Add type: stdio and env fields to inline config
- Remove redundant .mcp.json file
- Maintains same functionality with cleaner structure
Update .mcp.json to use environment variable
for the --directory argument, ensuring the MCP server works correctly
regardless of where the plugin is installed.
This follows Claude Code plugin best practices for MCP server bundling.