- Add 'logs' to _DOCKER_SUBACTIONS so the validation guard passes through
to the informative ToolError rather than a generic 'Invalid action' (P1)
- Add inline comments to _SYSTEM_QUERIES explaining that 'network' and
'variables' share the vars root field but fetch different subfields (P3)
- Process system/server response into a structured summary dict instead
of returning raw GraphQL data directly (P3)
BearerAuthMiddleware was blocking GET /.well-known/oauth-protected-resource,
causing Claude Code to cascade into failed OAuth discovery after a 401 and
surface a generic "unknown error". Add WellKnownMiddleware (RFC 9728) placed
before BearerAuthMiddleware that returns resource metadata with
bearer_methods_supported=["header"] and no authorization_servers, telling
MCP clients to use a pre-configured Bearer token with no OAuth flow.
Also adds docs/AUTHENTICATION.md and the missing README ## Authentication
section documenting token generation and the exact client config format.
Bump: 1.2.0 → 1.2.1
- Add `unraid_help` tool to `register_unraid_tool()` with full markdown
reference table for all 15 actions and subactions
- Add `unraid.subdomain.conf` SWAG reverse-proxy config pointing to
unraid-mcp:6970 (UNRAID_MCP_PORT default)
- Both changes required to pass lint-plugin.sh checks 7 and 14
arraySubscription in the Unraid API publishes with key { array } but
NestJS resolves payload["arraySubscription"] (method name), yielding
null on a non-nullable field. This is an upstream API bug — the
resolver lacks `resolve: (v) => v.array` unlike the working metrics
subscriptions.
On our side: downgrade from ERROR to WARNING on first occurrence, then
suppress repeats to DEBUG with periodic reminders at 10/100/1000.
Prevents log flooding while preserving visibility.
- Add HealthMiddleware (outside BearerAuth) so GET /health bypasses auth;
Docker healthcheck no longer 401s and triggers restart loop
- Pre-create /home/mcp/.unraid-mcp in Dockerfile with mcp:mcp ownership
so named volume mounts inherit correct permissions; bearer token now
persists across container restarts
- Remove custom SIGTERM/SIGINT handlers that silently swallowed signals;
Uvicorn manages its own shutdown
- Distinguish stdio vs HTTP startup log (was always showing host:port for stdio)
- Move _chmod_safe to module level; convert f-strings to %-format in logger calls
- Expand .dockerignore to exclude test/doc/tooling files from image
- P1 (78s): _HEADERS is now an immutable tuple; content-length derived from
len(_BODY) rather than a hardcoded magic number
- P1 (92j): Remove no-op SIGTERM/SIGINT handlers that swallowed signals without
stopping the server; delegate shutdown to Uvicorn's built-in handlers
- P2 (37t): HealthMiddleware now only responds 200 to GET /health; all other
methods fall through to the auth layer (returns 401)
- P2 (696): Extract _chmod_safe() helper; remove redundant second chmod block
in ensure_token_exists()
- P3 (6cr): Reorder middleware — HealthMiddleware is now outermost so it
intercepts /health before BearerAuth; removes the need for a bypass condition
in BearerAuthMiddleware.__call__
- P3 (4yz): Add Scope/Receive/Send type hints to HealthMiddleware.__call__
- BearerAuthMiddleware: pure ASGI __call__ pattern (no BaseHTTPMiddleware overhead),
pre-encoded token bytes, pre-built 401/429 bodies, per-IP rate limiting
(60 failures/60s → 429), log throttling (30s/IP), RFC 6750 compliant headers
- Token lifecycle: auto-generate secrets.token_urlsafe(32) on first HTTP startup,
write to ~/.unraid-mcp/.env via dotenv.set_key, print once to STDERR,
pop from os.environ after storing in module global
- Startup guard: sys.exit(1) if HTTP + no token + DISABLE_HTTP_AUTH not set
- Escape hatch: UNRAID_MCP_DISABLE_HTTP_AUTH=true for gateway-delegated auth
- Default transport: stdio → streamable-http (breaking change)
- 23 new tests covering pass-through, 401/429, RFC 6750 headers, rate limiting,
token generation, startup guard
BREAKING CHANGE: default transport is now streamable-http; stdio users must set
UNRAID_MCP_TRANSPORT=stdio (Claude Desktop plugin unaffected — plugin.json hardcodes stdio)
P1 — correctness:
- fix docstring in get_subscription_status: ABBA deadlock explanation was
factually wrong (cited pong send; actual reason is _subscription_loop
acquires _task_lock at loop exit while _data_lock may be held)
- surface start_errors after auto_start TaskGroup: individual failures were
logged but the summary WARNING with failed names was missing
- fix stop_subscription deadlock: lock → snapshot → release → await pattern;
was holding _task_lock across await task which blocked _subscription_loop
cleanup path
P2 — safety / encapsulation:
- add SubscriptionManager.get_error_state() public accessor replacing direct
_task_lock access in resources.py (fixes encapsulation + partially improves
read serialisation)
- extract DANGEROUS_KEY_PATTERN / MAX_VALUE_LENGTH to core/validation.py;
update _rclone.py and _setting.py to import from shared module
- _validate_settings_input: reject dict/list values with ToolError instead
of repr() size check; aligns with _validate_rclone_config behaviour
- flash_backup destination_path: add null-byte check + normpath-based
traversal check (was using raw ".." substring — bypassable via foo/bar/../..)
- middleware_refs.py: use TYPE_CHECKING guard + ErrorHandlingMiddleware | None
annotation instead of Any
P3 — minor correctness:
- _validate_path: split on '/' not os.sep (paths are remote Linux paths)
- DANGEROUS_KEY_PATTERN: add [\x00-\x1f] to reject newlines/control chars
Update tests/test_resources.py to mock get_error_state() AsyncMock instead
of last_error/connection_states dicts (909 tests pass).
Introduce core/middleware_refs.py as a neutral intermediary. server.py sets
middleware_refs.error_middleware = _error_middleware after creating the
instance. tools/unraid.py imports from middleware_refs (not server), breaking
the circular dependency hidden by the deferred import pattern.
The deferred `from ..server import _error_middleware` inside health/diagnose
worked at runtime but was invisible to static analysers and mypy, and created
a load-order dependency that was impossible to verify without running the server.
Move each domain's constants and handler into its own module (_array.py,
_disk.py, _docker.py, _vm.py, _notification.py, _key.py, _plugin.py,
_rclone.py, _setting.py, _customization.py, _oidc.py, _user.py,
_live.py, _system.py). Each module uses 'from ..core import client as _client'
+ '_client.make_graphql_request()' for module-attribute access, ensuring
test patches at unraid_mcp.core.client intercept all calls regardless of
which domain module is executing.
_health.py holds constants + _comprehensive_health_check helper only;
_handle_health stays in unraid.py because tests patch elicit_and_configure
at unraid_mcp.tools.unraid — keeping it there avoids circular imports
while preserving correct patch interception.
unraid.py becomes a thin dispatcher: imports handlers from domain files,
re-exports all query/mutation dicts and destructive-action sets for tests,
and owns only _handle_health inline.
835 tests passing.
- format_kb now delegates to format_bytes(kb * 1024) — eliminates the
duplicate size-formatting loop and fixes a silent truncation at TB
(the old explicit if-chain could not reach PB or EB)
- Remove _VALID_NOTIF_TYPES (identical duplicate of _VALID_LIST_TYPES);
update the one reference in _handle_notification to use _VALID_LIST_TYPES
- Remove redundant importance validation in the notification/create branch
(already validated earlier in _handle_notification before subaction dispatch)
Closes: unraid-mcp-ilg.13, unraid-mcp-ilg.14
All test _mock_graphql fixtures previously patched unraid_mcp.tools.unraid.make_graphql_request
(a local name binding). After a domain split, new module files bind their own local names,
making the tool-level patch ineffective — test mutations reached the real Unraid server and
stopped the array twice.
Fix: switch unraid.py to use module-attribute access (_client.make_graphql_request) via
`from ..core import client as _client`. Update all test patch targets to
unraid_mcp.core.client.make_graphql_request, which intercepts calls regardless of how
many modules import from that namespace.
835 tests passing. Integration test failures are pre-existing and unrelated.
- Extract _validate_path() in unraid.py — consolidates traversal check + normpath
+ prefix validation used by disk/logs and live/log_tail into one place
- Extract build_connection_init() in subscriptions/utils.py — removes 4 duplicate
connection_init payload blocks from snapshot.py (×2), manager.py, diagnostics.py;
also fixes diagnostics.py bug where x-api-key: None was sent when no key configured
- Remove _LIVE_ALLOWED_LOG_PREFIXES alias — direct reference to _ALLOWED_LOG_PREFIXES
- Move import hmac to module level in server.py (was inside verify_token hot path)
Co-Authored-By: Claude <noreply@anthropic.com>
- Call _build_google_auth() at module level before mcp = FastMCP(...)
- Pass auth=_google_auth to FastMCP() constructor
- Add startup log in run_server(): INFO when OAuth enabled (with redirect URI), WARNING when open/unauthenticated
- Add test verifying mcp has no auth provider when Google vars are absent (baseline + post-wire)
Adds _build_google_auth() to server.py that reads Google OAuth settings
and returns a configured GoogleProvider instance or None when unconfigured.
Includes warning for stdio transport incompatibility and conditional
jwt_signing_key passthrough. 4 new TDD tests in tests/test_auth_builder.py.
- Add fastmcp.http.json and fastmcp.stdio.json declarative server configs
for streamable-http (:6970) and stdio transports respectively
- Move register_all_modules() to module level in server.py so
`fastmcp run server.py --reload` discovers the fully-wired mcp object
without going through run_server() — tools registered exactly once
- Add timeout=120 to @mcp.tool() decorator as a global safety net;
any hung subaction returns a clean MCP error instead of hanging forever
- Document fastmcp run --reload, fastmcp list, fastmcp call in README
- Bump version 1.0.1 → 1.1.0
Co-authored-by: Claude <claude@anthropic.com>
Add GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, UNRAID_MCP_BASE_URL, and
UNRAID_MCP_JWT_SIGNING_KEY env vars to settings.py, along with the
is_google_auth_configured() predicate and three new keys in
get_config_summary(). TDD: 4 tests written red-first, all passing green.
Threads 1, 2, 3 — test hygiene:
- Move elicit_and_configure/elicit_reset_confirmation to module-level imports
in unraid.py so tests can patch at unraid_mcp.tools.unraid.* (thread 2)
- Add return type annotations to _make_tool() in test_customization.py (thread 1)
- Replace unused _mock_ensure_started fixture params with @usefixtures (thread 3)
Thread 4 — remove dead 'connect' subaction from _SYSTEM_QUERIES; the subaction
was always rejected with a ToolError, creating an inconsistent contract.
Thread 5 — centralize two inline "query { online }" strings by reusing
_SYSTEM_QUERIES["online"]; add _DOCKER_QUERIES["_resolve"] for container-name
resolution instead of an inline query literal.
Threads 14, 15, 16, 17, 18 — test improvements:
- test-tools.sh: reword header to "broad non-destructive smoke coverage" (t14)
- test-tools.sh: add _json_payload() helper using jq --arg for safe JSON
construction; replace all printf-based payloads (thread 15)
- test_input_validation.py: add return type annotations to _make_tool and all
nested _run_test coroutines (thread 16)
- test_query_validation.py: extract _all_domain_dicts() shared helper to
eliminate the duplicate 22-item registry (thread 17)
- test_query_validation.py: tighten regression threshold from 50 → 90 (thread 18)
- guards.py: split confirm bypass into explicit check; use .get() for
dict description to prevent KeyError on missing action keys
- resources.py: use `is not None` for logs stream cache check; add
on-demand subscribe_once fallback when auto_start is disabled so
resources return real data instead of a perpetual "connecting" placeholder
- setup.py: always prompt before overwriting credentials even on failed
probe (transient outage ≠ bad credentials); update elicitation message
- unraid.py: always elicit_reset_confirmation before overwriting creds;
use asyncio.to_thread() for os.path.realpath() to avoid blocking async
- test_health.py: update test for new always-prompt-on-overwrite behavior;
add test for declined-reset on failed probe
- test_resources.py: add tests for logs-stream None check, auto_start
disabled fallback (success and failure), and fallback error recovery
- test-tools.sh: add suite_live() covering cpu/memory/cpu_telemetry/
notifications_overview/log_tail; include in sequential and parallel runners
- CLAUDE.md: correct unraid_live → live action reference; document that
setup always prompts before overwriting; note subscribe_once fallback
Replace hard ToolError guard with gate_destructive_action() in 5 tools so
destructive actions prompt for interactive confirmation via MCP elicitation
when ctx is available, and still accept confirm=True as a bypass. Update
all test match strings from "destructive" to "not confirmed" accordingly.
Replace 7-11 line inline guard blocks in array.py, keys.py, and plugins.py
with single await gate_destructive_action(...) calls. Also fix guards.py to
raise unraid_mcp.core.exceptions.ToolError (project subclass) instead of
fastmcp.exceptions.ToolError so pytest.raises catches it correctly in tests.
settings.py: drop update_temperature, update_time, update_api,
connect_sign_in, connect_sign_out, setup_remote_access,
enable_dynamic_remote_access, update_ssh — all 8 reference mutations
confirmed absent from Unraid API v4.29.2. Keep update + configure_ups.
info.py: drop update_server (updateServerIdentity not in Mutation type)
and update_ssh (duplicate of removed settings action). MUTATIONS is now
empty; DESTRUCTIVE_ACTIONS is now an empty set.
notifications.py: drop create_unique (notifyIfUnique not in Mutation type).
Tests: remove corresponding test classes, add parametrized regression
tests asserting removed actions are not in each tool's Literal type,
update KNOWN_DESTRUCTIVE and _DESTRUCTIVE_TEST_CASES in safety audit,
update schema coverage assertions. 858 tests passing, 0 failures.