sync-env.sh was exiting 1 whenever UNRAID_MCP_BEARER_TOKEN was not
configured via plugin userConfig, causing startup hook errors every
session for users not using the plugin config flow.
Compact README rewrite — removes marketing prose, replaces with minimal
tables and direct headings. Syncs .codex-plugin/plugin.json and
gemini-extension.json from stale 1.2.0 to 1.2.2. Adopts Keep a Changelog
format for CHANGELOG.md.
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
Standardize .gitignore across all repos with canonical sections.
Remove logs/ and backups/ directories (runtime artifacts, not tracked).
Untrack files now covered by .gitignore (.beads/, .lavra/, .omc/, etc).
- 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
- docker-compose: align network default fallback (jakenet) so service
reference matches the network name: key value
- entrypoint: match server-side boolean parsing for DISABLE_HTTP_AUTH
(accept true/1/yes, consistent with settings.py)
- ensure-ignore-files: deduplicate pattern list by driving awk from
the REQUIRED shell array via a | -separated -v argument
- sync-env: use grep -qE for ERE .+ instead of BRE .\+ (non-POSIX)
Hard-coding DOCKER_NETWORK=jakenet causes docker compose up to fail for new
users who haven't pre-created that external network. Default to empty so the
container uses only the default bridge network unless explicitly configured.
- .codex-plugin/plugin.json: add interface.displayName field required by
lint-plugin.sh validation check
- .env.example: fix dotenv-linter key ordering across all variable groups
(MCP settings, safety flags, Docker vars all now alphabetically sorted)
- hooks/scripts/sync-env.sh: scope lock file to CLAUDE_PLUGIN_ROOT instead
of global /tmp/ to avoid cross-repo lock contention
- entrypoint.sh: only require UNRAID_MCP_BEARER_TOKEN when transport is
not stdio and HTTP auth is not disabled (fixes false startup failures)
- docker-compose.yaml: use fixed network key `unraid-mcp-external` with
`name: ${DOCKER_NETWORK:-unraid-mcp-external}` to avoid compose errors
when DOCKER_NETWORK differs from the declared network name
- docker-compose.yaml: make healthcheck transport-aware — skip HTTP probe
when UNRAID_MCP_TRANSPORT=stdio to prevent false unhealthy status
- docs/unraid.subdomain.conf: fix proxy_pass port from 3000 to 6970
- .codex-plugin/plugin.json: fix MCP URL port from 3000 to 6970
- .env.example: reorder UNRAID_API_KEY before UNRAID_API_URL (alpha);
correct UNRAID_MCP_BEARER_TOKEN comment — required conditionally not always
- hooks/scripts/ensure-ignore-files.sh: write .gitignore atomically via
temp file + mv to prevent truncation on interrupted rewrite
- hooks/scripts/sync-env.sh: chmod 600 .env immediately after touch,
before any early-exit paths that could leave secrets world-readable
- sync-env.sh: replace sed with awk for safe value replacement, add flock
on /tmp/unraid-sync-env.lock, remove auto-token-generation (fail with
clear error if UNRAID_MCP_BEARER_TOKEN not set)
- ensure-ignore-files.sh: rename from ensure-gitignore.sh, add --check mode
that exits non-zero without modifying file (for CI/pre-commit use)
- hooks.json: update both references to new ensure-ignore-files.sh name
- docker-compose.yaml: add user PUID/PGID, external network, deploy.resources
limits (1024M/1cpu), wget healthcheck, start_period=30s
- Dockerfile: install wget, use wget healthcheck, start_period=30s,
add entrypoint.sh, ENTRYPOINT points to /entrypoint.sh
- entrypoint.sh: env validation (UNRAID_API_URL, UNRAID_API_KEY,
UNRAID_MCP_BEARER_TOKEN) with exec for signal forwarding
- .env.example: add PUID, PGID, DOCKER_NETWORK, UNRAID_MCP_ALLOW_DESTRUCTIVE,
UNRAID_MCP_ALLOW_YOLO; fix UNRAID_MCP_BEARER_TOKEN key name
Add .codex-plugin/plugin.json and .app.json plugin manifests, assets/
directory with icon.png and logo.svg placeholders, and
docs/unraid.subdomain.conf SWAG nginx reverse proxy config.
These files are gitignored but were previously committed.
Removes from tracking while keeping local copies.
Co-authored-by: Claude <noreply@anthropic.com>
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.