Commit graph

291 commits

Author SHA1 Message Date
Jacob Magar
34c4e98ddc chore(hooks): remove SessionStart sync-env hook
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.
2026-04-03 21:25:43 -04:00
Jacob Magar
102c38c3be test: add HTTP live e2e smoke-test (test-http.sh)
Covers the full HTTP stack against a running server:
  Phase 1 — middleware (health, well-known OAuth discovery, no auth)
  Phase 2 — auth enforcement (no-token 401, bad-token 401, good-token pass)
  Phase 3 — MCP protocol (initialize, tools/list, ping)
  Phase 4 — 14 non-destructive tool subactions across all domains
  Phase 4b — destructive guard bypass (confirm=True path)

Requires curl + jq. Supports --url, --token, --skip-auth, --verbose.
Token auto-detected from ~/.unraid-mcp/.env if not supplied.
2026-04-03 21:15:47 -04:00
Jacob Magar
e6c44b4a6e chore: sync docs and manifests 2026-04-03 21:14:32 -04:00
Jacob Magar
d83a6a74e4 chore: restructure README, sync versions to 1.2.2, adopt Keep a Changelog format
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.
2026-04-03 20:10:51 -04:00
Jacob Magar
306f364c4e fix: exempt OAuth discovery endpoint from auth, add authentication docs (closes #17)
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
2026-04-03 07:03:18 -04:00
Jacob Magar
51d11127d3 docs: standardize README format 2026-04-03 06:58:13 -04:00
Jacob Magar
d533060276 feat: add just publish recipe for tag-triggered releases 2026-04-03 02:49:23 -04:00
Jacob Magar
1ff2268fe2 ci: add package registry publish workflow (tag-triggered) 2026-04-03 02:19:27 -04:00
Jacob Magar
ea487104c5 chore: sync versions across manifests, add version-sync check, update CLAUDE.md 2026-04-03 01:56:09 -04:00
Jacob Magar
ed08481b1b chore: CI hardening — format check, setup-uv@v5, Node v22, standardize dockerignore 2026-04-03 01:30:21 -04:00
Jacob Magar
a06892182f security: pin actions to SHA, add Trivy scan, SBOM/provenance, pin uv 2026-04-03 01:28:36 -04:00
Jacob Magar
fd127eff05 docs: add version bumping enforcement to CLAUDE.md 2026-04-03 01:13:25 -04:00
Jacob Magar
dd23a646d7 perf(docker): optimize Dockerfile — multi-stage, no docs in image, uv cache mounts 2026-04-03 01:09:21 -04:00
Jacob Magar
3ac1202191 feat: add gemini-extension.json for Gemini CLI extension support 2026-04-03 00:28:18 -04:00
Jacob Magar
dd14ba8c4e ci: add ghcr.io Docker image build workflow
Build and push multi-arch (amd64/arm64) images on push to main and tags.
2026-04-02 22:30:32 -04:00
Jacob Magar
4108d25dab feat: add Codex marketplace manifest
Add .agents/plugins/marketplace.json for Codex plugin discovery.
2026-04-02 22:29:46 -04:00
Jacob Magar
4a1a2b0519 chore: align .gitignore, remove logs/backups dirs, untrack ignored files
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).
2026-04-02 22:29:13 -04:00
Jacob Magar
4006cabb65 chore: remove subdomain.conf and SWAG lint check
SWAG proxy configs are managed by swag-mcp, not bundled in each repo.
2026-04-02 21:37:07 -04:00
Jacob Magar
4bfa02ef0f fix(compose): source env from ~/.claude-homelab/.env 2026-04-01 16:52:56 -04:00
Jacob Magar
78ff0ec091 fix(mcp): use env var for URL; fix healthcheck (wget GET not HEAD) 2026-04-01 03:14:19 -04:00
Jacob Magar
6e1b95d5ca chore: add logs/.gitkeep for required directory 2026-04-01 02:19:19 -04:00
Jacob Magar
2feb900aeb fix: add unraid_help tool, SWAG conf
- 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
2026-04-01 02:17:59 -04:00
Jacob Magar
0a95608e8e fix: lint-plugin.sh jq false-detection bug 2026-04-01 02:11:02 -04:00
Jacob Magar
86bb5ac6f8 fix: address remaining PR review comments
- 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)
2026-04-01 02:03:15 -04:00
Jacob Magar
a11049185f fix: leave DOCKER_NETWORK empty in .env.example to avoid fresh-setup failures
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.
2026-04-01 01:29:43 -04:00
Jacob Magar
4df14505d1 fix: address remaining PR #16 review comments
- .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
2026-04-01 01:28:59 -04:00
Jacob Magar
05c188be69 fix: address PR #16 review comments — config correctness and robustness
- 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
2026-04-01 01:17:22 -04:00
Jacob Magar
b94221898b chore: untrack .beads/, .lavra/, logs/ from git index 2026-04-01 00:28:25 -04:00
Jacob Magar
c9b1dc5026 fix(hooks): align sync-env/ensure-ignore-files with plugin spec (cw1.1, ova)
- 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
2026-03-31 17:58:48 -04:00
Jacob Magar
4c19364f72 chore(unraid): add codex manifests, assets, nginx conf (syk)
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.
2026-03-31 17:46:44 -04:00
Jacob Magar
583fed3cd1 chore(quality-gates): add Justfile, .pre-commit-config.yaml, and check scripts
- Justfile: standard recipes (dev, test, lint, fmt, typecheck, validate-skills,
  build, up, down, restart, logs, health, test-live, setup, gen-token,
  check-contract, clean) using uv/ruff for Python
- .pre-commit-config.yaml: four hooks (skills-validate, docker-security,
  no-baked-env, ensure-ignore-files)
- scripts/: copy check-docker-security.sh, check-no-baked-env.sh,
  check-outdated-deps.sh, ensure-ignore-files.sh, lint-plugin.sh from
  claude-homelab canonical source; all chmod +x

Closes claude-homelab-5sy
2026-03-31 17:25:56 -04:00
Jacob Magar
80d1fc02c6 chore(gitignore): add .lavra/ pattern, trim .env.example to spec vars, fix .dockerignore
- .gitignore: replace .lavra/memory/session-state.md with .lavra/ (full dir), add *.db-shm and *.db-wal
- .env.example: replace non-spec vars with canonical set (UNRAID_URL, UNRAID_API_KEY, UNRAID_MCP_TOKEN, UNRAID_MCP_PORT, UNRAID_MCP_TRANSPORT, NO_AUTH, ALLOW_DESTRUCTIVE, ALLOW_YOLO)
- .dockerignore: add .codex-plugin entry required by ensure-ignore-files check

Closes claude-homelab-8b8
2026-03-31 17:25:49 -04:00
Jacob Magar
ae55b5b7e0 feat: improve auth, server, subscriptions, tools, and add regression tests 2026-03-31 17:14:30 -04:00
Jacob Magar
943877a802 chore: remove .omc/ state files from git tracking
These files are gitignored but were previously committed.
Removes from tracking while keeping local copies.

Co-authored-by: Claude <noreply@anthropic.com>
2026-03-31 16:47:22 -04:00
Jacob Magar
74ec92e094 fix(subscriptions): deduplicate GraphQL error log spam for array_state
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.
2026-03-31 10:44:44 -04:00
Jacob Magar
c393092b4f fix(docker): healthcheck 401 loop, credential persistence, startup log clarity
- 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
2026-03-30 23:52:04 -04:00
Jacob Magar
edece9e205 fix(unraid-mcp-78s,92j,37t,696,6cr,4yz): P1/P2/P3 sweep — fix HealthMiddleware + signal handlers
- 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__
2026-03-30 23:39:52 -04:00
Jacob Magar
b23ea2ad49 docs: add v1.2.0 changelog entry for HTTP bearer token auth 2026-03-30 10:10:40 -04:00
Jacob Magar
f24f7fac0a chore: update uv.lock for v1.2.0 2026-03-30 10:01:45 -04:00
Jacob Magar
a5e1c30ae9 feat(unraid-mcp-1nx): add HTTP bearer token auth, make streamable-http default (v1.2.0)
- 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)
2026-03-30 10:01:34 -04:00
Jacob Magar
3142897402 fix(unraid-mcp-n7i): flash_backup path traversal fix + v1.1.6 bump 2026-03-30 09:35:21 -04:00
Jacob Magar
dfc6d5118e fix(unraid-mcp-4iw,c8m,0uv,14g,1n2,6uw,alm,cad): P2/P3 sweep — lock docs, middleware import, live assertion, validation tests 2026-03-30 00:48:48 -04:00
Jacob Magar
d01dcf1c67 fix(unraid-mcp-478,cdd,5rl,ex9,4ea,dx9): manager.py lock/guard fixes and _setting.py comment 2026-03-30 00:10:30 -04:00
Jacob Magar
84f2c7de5d test(unraid-mcp-4r7): verify ToolError-wrapped httpx.ConnectError returns unhealthy 2026-03-30 00:04:29 -04:00
Jacob Magar
008a2151e5 fix: address all PR #15 review comments (threads 1-13)
Resolves review threads PRRT_kwDOO6Hdxs53kmxT PRRT_kwDOO6Hdxs53kmxV
PRRT_kwDOO6Hdxs53kmxW PRRT_kwDOO6Hdxs53kmxY PRRT_kwDOO6Hdxs53kmnR
PRRT_kwDOO6Hdxs53kmnT PRRT_kwDOO6Hdxs53kmgx PRRT_kwDOO6Hdxs53kmgr
PRRT_kwDOO6Hdxs53kmWj PRRT_kwDOO6Hdxs53kmWn PRRT_kwDOO6Hdxs53kmWv
PRRT_kwDOO6Hdxs53kmWV

- validation.py: expand DANGEROUS_KEY_PATTERN with &<>'"# chars (thread 1)
- CLAUDE.md: update middleware stack docs from 5-layer to 4-layer (thread 2)
- manager.py _start_one: add CancelledError guard before bare except (threads 3, 12)
- _disk.py audit log: include destination_path in flash_backup trace (thread 4)
- _health.py: catch ToolError wrapping httpx errors for degraded-state health check (threads 5, 8, 9)
- manager.py init JSON decode: raise RuntimeError instead of continue to preserve reconnect backoff (threads 6, 7)
- _disk.py: replace os.path.normpath with posixpath.normpath for remote Linux paths (thread 10)
- unraid.py: guard error_middleware None check before calling get_error_stats() (thread 13)
2026-03-29 23:45:16 -04:00
Jacob Magar
2401b85ce5 fix(unraid-mcp-7jm): address P1/P2 findings from post-review sweep
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).
2026-03-29 03:00:00 -04:00
Jacob Magar
574ac03251 fix(unraid-mcp-ilg.4): break server.py ↔ tools/unraid.py circular import
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.
2026-03-29 02:08:45 -04:00
Jacob Magar
d008d96a19 fix(unraid-mcp-ilg): resolve 11 code review bugs from ilg P1/P2 sweep
ilg.1  — get_subscription_status: acquire _task_lock and _data_lock separately
         to avoid deadlock with _subscription_loop holding _data_lock across await
ilg.2  — start_subscription: move duplicate-check inside _task_lock (was TOCTOU)
ilg.3  — subscription_loop: change break→continue on JSON decode in init handshake
         (break killed the outer retry loop; transient errors should retry)
ilg.5  — diagnose_subscriptions: apply safe_display_url() to ws_url_display
ilg.6  — setting/update: add _validate_settings_input() with key/value allowlist
         and size bound, modeled on _validate_rclone_config
ilg.7  — resources.py: guard last_error/connection_states reads under _task_lock
ilg.8  — server.py: remove fully-disabled ResponseCachingMiddleware from stack;
         update health/diagnose to emit static cache note instead of stats
ilg.9  — test_subscription_query: use negotiated subprotocol for start_type
         instead of hardcoded "start" (graphql-transport-ws uses "subscribe")
ilg.10 — _validate_path: normalize before checking for ".." (pre-normpath check
         was bypassable); also reject null bytes
ilg.11 — auto_start_all_subscriptions: parallelize with asyncio.TaskGroup
         (sequential for-loop blocked first MCP request for ~370s cold start)
ilg.15 — client.py: remove dead _TIMEOUT_PROFILES dict and
         get_timeout_for_operation() (unused since direct timeout refs replaced them)
2026-03-29 02:03:29 -04:00
Jacob Magar
ae497ed522 fix(unraid-mcp-cat): raise ToolError on null response in disk/logs, user/me, oidc/validate_session and oidc/provider 2026-03-28 21:07:48 -04:00
Jacob Magar
925d6a0889 refactor(unraid-mcp-t28): schema tests import from domain modules, not unraid.py re-export shim 2026-03-28 21:00:35 -04:00