17 Commits

Author SHA1 Message Date
Jacob Magar
e68d4a80e4 refactor: simplify path validation and connection_init via shared helpers
- 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>
2026-03-23 11:57:00 -04:00
Jacob Magar
dc1e5f18d8 docs: add CHANGELOG.md covering v0.1.0 through v1.1.2
Co-Authored-By: Claude <noreply@anthropic.com>
2026-03-23 11:38:27 -04:00
Jacob Magar
2b777be927 fix(security): path traversal, timing-safe auth, stale credential bindings
Security:
- Remove /mnt/ from _ALLOWED_LOG_PREFIXES to prevent Unraid share exposure
- Add early .. detection for disk/logs and live/log_tail path validation
- Add /boot/ prefix restriction for flash_backup source_path
- Use hmac.compare_digest for timing-safe API key verification in server.py
- Gate include_traceback on DEBUG log level (no tracebacks in production)

Correctness:
- Re-raise CredentialsNotConfiguredError in health check instead of swallowing
- Fix ups_device query (remove non-existent nominalPower/currentPower fields)

Best practices (BP-01, BP-05, BP-06):
- Add # noqa: ASYNC109 to timeout params in _handle_live and unraid()
- Fix start_array* → start_array in docstring (not in ARRAY_DESTRUCTIVE)
- Remove from __future__ import annotations from snapshot.py
- Replace import-time UNRAID_API_KEY/URL bindings with _settings.ATTR pattern
  in manager.py, snapshot.py, utils.py, diagnostics.py — fixes stale binding
  after apply_runtime_config() post-elicitation (BP-05)

CI/CD:
- Add .github/workflows/ci.yml (5-job pipeline: lint, typecheck, test, version-sync, audit)
- Add fail_under = 80 to [tool.coverage.report]
- Add version sync check to scripts/validate-marketplace.sh

Documentation:
- Sync plugin.json version 1.1.1 → 1.1.2 with pyproject.toml
- Update CLAUDE.md: 3 tools, system domain count 18, scripts comment fix
- Update README.md: 3 tools, security notes
- Update docs/AUTHENTICATION.md: H1 title fix
- Add UNRAID_CREDENTIALS_DIR to .env.example

Bump: 1.1.1 → 1.1.2

Co-Authored-By: Claude <noreply@anthropic.com>
2026-03-23 11:37:05 -04:00
Jacob Magar
d59f8c22a8 docs: rename GOOGLE_OAUTH.md → AUTHENTICATION.md, update references 2026-03-16 11:12:25 -04:00
Jacob Magar
cc24f1ec62 feat: add API key bearer token authentication
- ApiKeyVerifier(TokenVerifier) — validates Authorization: Bearer <key>
  against UNRAID_MCP_API_KEY; guards against empty-key bypass
- _build_auth() replaces module-level _build_google_auth() call:
  returns MultiAuth(server=google, verifiers=[api_key]) when both set,
  GoogleProvider alone, ApiKeyVerifier alone, or None
- settings.py: add UNRAID_MCP_API_KEY + is_api_key_auth_configured()
  + api_key_auth_enabled in get_config_summary()
- run_server(): improved auth status logging for all three states
- tests/test_api_key_auth.py: 9 tests covering verifier + _build_auth
- .env.example: add UNRAID_MCP_API_KEY section
- docs/GOOGLE_OAUTH.md: add API Key section
- README.md / CLAUDE.md: rename section, document both auth methods
- Fix pre-existing: test_health.py patched cache_middleware/error_middleware
  now match renamed _cache_middleware/_error_middleware in server.py
2026-03-16 11:11:38 -04:00
Jacob Magar
6f7a58a0f9 docs: add Google OAuth setup guide and update README/CLAUDE.md
- Create docs/GOOGLE_OAUTH.md: complete OAuth setup walkthrough
  (Google Cloud Console, env vars, JWT key generation, troubleshooting)
- README.md: add Google OAuth section with quick-setup steps + link
- CLAUDE.md: add JWT key generation tip + link to full guide
2026-03-16 10:59:30 -04:00
Jacob Magar
440245108a fix(tests): clear subscription_configs before auto-start tests to account for default SNAPSHOT_ACTIONS 2026-03-16 10:54:43 -04:00
Jacob Magar
9754261402 fix(auth): use setenv('') instead of delenv to prevent dotenv re-injection in tests 2026-03-16 10:51:14 -04:00
Jacob Magar
9e9915b2fa docs(auth): document Google OAuth setup in CLAUDE.md 2026-03-16 10:48:38 -04:00
Jacob Magar
2ab61be2df feat(auth): wire GoogleProvider into FastMCP, log auth status on startup
- 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)
2026-03-16 10:42:51 -04:00
Jacob Magar
b319cf4932 fix(auth): use dict[str, Any] for kwargs, add typing.Any import 2026-03-16 10:40:29 -04:00
Jacob Magar
0f46cb9713 test(auth): add _build_google_auth() unit tests and bump to v1.1.1
- 4 tests covering unconfigured/configured/no-jwt-key/stdio-warning paths
- Validates GoogleProvider is called with correct kwargs
- Verifies jwt_signing_key is omitted (not passed empty) when unset

Co-authored-by: Claude <noreply@anthropic.com>
2026-03-16 10:37:52 -04:00
Jacob Magar
1248ccd53e fix(auth): hoist _build_google_auth import to module level for ty compatibility 2026-03-16 10:37:36 -04:00
Jacob Magar
4a1ffcfd51 feat(auth): add _build_google_auth() builder with stdio warning
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.
2026-03-16 10:36:41 -04:00
Jacob Magar
f69aa94826 feat(dx): add fastmcp.json configs, module-level tool registration, tool timeout
- 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>
2026-03-16 10:32:16 -04:00
Jacob Magar
5187cf730f fix(auth): use Any return type in _reload_settings for ty compatibility 2026-03-16 10:29:56 -04:00
Jacob Magar
896fc8db1b feat(auth): add Google OAuth settings with is_google_auth_configured()
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.
2026-03-16 10:28:53 -04:00
32 changed files with 1377 additions and 135 deletions

View File

@@ -1,7 +1,7 @@
{
"name": "unraid",
"description": "Query and monitor Unraid servers via GraphQL API - array status, disk health, containers, VMs, system monitoring",
"version": "1.0.1",
"version": "1.1.2",
"author": {
"name": "jmagar",
"email": "jmagar@users.noreply.github.com"

View File

@@ -35,3 +35,47 @@ UNRAID_MAX_RECONNECT_ATTEMPTS=10
# Optional: Custom log file path for subscription auto-start diagnostics
# Defaults to standard log if not specified
# UNRAID_AUTOSTART_LOG_PATH=/custom/path/to/autostart.log
# Credentials Directory Override (Optional)
# -----------------------------------------
# Override the credentials directory (default: ~/.unraid-mcp/)
# UNRAID_CREDENTIALS_DIR=/custom/path/to/credentials
# Google OAuth Protection (Optional)
# -----------------------------------
# Protects the MCP HTTP server — clients must authenticate with Google before calling tools.
# Requires streamable-http or sse transport (not stdio).
#
# Setup:
# 1. Google Cloud Console → APIs & Services → Credentials
# 2. Create OAuth 2.0 Client ID (Web application)
# 3. Authorized redirect URIs: <UNRAID_MCP_BASE_URL>/auth/callback
# 4. Copy Client ID and Client Secret below
#
# UNRAID_MCP_BASE_URL: Public URL clients use to reach THIS server (for redirect URIs).
# Examples:
# http://10.1.0.2:6970 (LAN)
# http://100.x.x.x:6970 (Tailscale)
# https://mcp.yourdomain.com (reverse proxy)
#
# UNRAID_MCP_JWT_SIGNING_KEY: Stable secret for signing FastMCP JWT tokens.
# Generate once: python3 -c "import secrets; print(secrets.token_hex(32))"
# NEVER change after first use — all client sessions will be invalidated.
#
# Leave GOOGLE_CLIENT_ID empty to disable OAuth (server runs unprotected).
# GOOGLE_CLIENT_ID=
# GOOGLE_CLIENT_SECRET=
# UNRAID_MCP_BASE_URL=http://10.1.0.2:6970
# UNRAID_MCP_JWT_SIGNING_KEY=<generate with command above>
# API Key Authentication (Optional)
# -----------------------------------
# Alternative to Google OAuth — clients present this key as a bearer token:
# Authorization: Bearer <UNRAID_MCP_API_KEY>
#
# Can be the same value as UNRAID_API_KEY (reuse your Unraid key), or a
# separate dedicated secret. Set both GOOGLE_CLIENT_ID and UNRAID_MCP_API_KEY
# to accept either auth method (MultiAuth).
#
# Leave empty to disable API key auth.
# UNRAID_MCP_API_KEY=

79
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,79 @@
name: CI
on:
push:
branches: ["main", "feat/**", "fix/**"]
pull_request:
branches: ["main"]
jobs:
lint:
name: Lint & Format
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v5
with:
version: "0.9.25"
- name: Install dependencies
run: uv sync --group dev
- name: Ruff check
run: uv run ruff check unraid_mcp/ tests/
- name: Ruff format
run: uv run ruff format --check unraid_mcp/ tests/
typecheck:
name: Type Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v5
with:
version: "0.9.25"
- name: Install dependencies
run: uv sync --group dev
- name: ty check
run: uv run ty check unraid_mcp/
test:
name: Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v5
with:
version: "0.9.25"
- name: Install dependencies
run: uv sync --group dev
- name: Run tests (excluding integration/slow)
run: uv run pytest -m "not slow and not integration" --tb=short -q
- name: Check coverage
run: uv run pytest -m "not slow and not integration" --cov=unraid_mcp --cov-report=term-missing --tb=short -q
version-sync:
name: Version Sync Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Check pyproject.toml and plugin.json versions match
run: |
TOML_VER=$(grep '^version = ' pyproject.toml | sed 's/version = "//;s/"//')
PLUGIN_VER=$(python3 -c "import json; print(json.load(open('.claude-plugin/plugin.json'))['version'])")
echo "pyproject.toml: $TOML_VER"
echo "plugin.json: $PLUGIN_VER"
if [ "$TOML_VER" != "$PLUGIN_VER" ]; then
echo "ERROR: Version mismatch! Update .claude-plugin/plugin.json to match pyproject.toml"
exit 1
fi
echo "Versions in sync: $TOML_VER"
audit:
name: Security Audit
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v5
with:
version: "0.9.25"
- name: Dependency audit
run: uv audit

135
CHANGELOG.md Normal file
View File

@@ -0,0 +1,135 @@
# Changelog
All notable changes to this project are documented here.
## [1.1.2] - 2026-03-23
### Security
- **Path traversal**: Removed `/mnt/` from `_ALLOWED_LOG_PREFIXES` — was exposing all Unraid user shares to path-based reads
- **Path traversal**: Added early `..` detection for `disk/logs` and `live/log_tail` before any filesystem access; added `/boot/` prefix restriction for `flash_backup` source paths
- **Timing-safe auth**: `verify_token` now uses `hmac.compare_digest` instead of `==` to prevent timing oracle attacks on API key comparison
- **Traceback leak**: `include_traceback` in `ErrorHandlingMiddleware` is now gated on `DEBUG` log level; production deployments no longer expose stack traces
### Fixed
- **Health check**: `_comprehensive_health_check` now re-raises `CredentialsNotConfiguredError` instead of swallowing it into a generic unhealthy status
- **UPS device query**: Removed non-existent `nominalPower` and `currentPower` fields from `ups_device` query — every call was failing against the live API
- **Stale credential bindings**: Subscription modules (`manager.py`, `snapshot.py`, `utils.py`, `diagnostics.py`) previously captured `UNRAID_API_KEY`/`UNRAID_API_URL` at import time; replaced with `_settings.ATTR` call-time access so `apply_runtime_config()` updates propagate correctly after credential elicitation
### Added
- **CI pipeline**: `.github/workflows/ci.yml` with 5 jobs — lint (`ruff`), typecheck (`ty`), test (`pytest -m "not integration"`), version-sync check, and `uv audit` dependency scan
- **Coverage threshold**: `fail_under = 80` added to `[tool.coverage.report]`
- **Version sync check**: `scripts/validate-marketplace.sh` now verifies `pyproject.toml` and `plugin.json` versions match
### Changed
- **Docs**: Updated `CLAUDE.md`, `README.md` to reflect 3 tools (1 primary + 2 diagnostic); corrected system domain count (19→18); fixed scripts comment
- **Docs**: `docs/AUTHENTICATION.md` H1 retitled to "Authentication Setup Guide"
- **Docs**: Added `UNRAID_CREDENTIALS_DIR` commented entry to `.env.example`
- Removed `from __future__ import annotations` from `snapshot.py` (caused TC002 false positives with FastMCP)
- Added `# noqa: ASYNC109` to `timeout` parameters in `_handle_live` and `unraid()` (valid suppressions)
- Fixed `start_array*``start_array` in tool docstring table (`start_array` is not in `_ARRAY_DESTRUCTIVE`)
---
## [1.1.1] - 2026-03-16
### Added
- **API key auth**: `Authorization: Bearer <UNRAID_MCP_API_KEY>` bearer token authentication via `ApiKeyVerifier` — machine-to-machine access without OAuth browser flow
- **MultiAuth**: When both Google OAuth and API key are configured, `MultiAuth` accepts either method
- **Google OAuth**: Full `GoogleProvider` integration — browser-based OAuth 2.0 flow with JWT session tokens; `UNRAID_MCP_JWT_SIGNING_KEY` for stable tokens across restarts
- **`fastmcp.json`**: Dev tooling configs for FastMCP
### Fixed
- Auth test isolation: use `os.environ[k] = ""` instead of `delenv` to prevent dotenv re-injection between test reloads
---
## [1.1.0] - 2026-03-16
### Breaking Changes
- **Tool consolidation**: 15 individual domain tools (`unraid_docker`, `unraid_vm`, etc.) merged into single `unraid` tool with `action` + `subaction` routing
- Old: `unraid_docker(action="list")`
- New: `unraid(action="docker", subaction="list")`
### Added
- **`live` tool** (11 subactions): Real-time WebSocket subscription snapshots — `cpu`, `memory`, `cpu_telemetry`, `array_state`, `parity_progress`, `ups_status`, `notifications_overview`, `notification_feed`, `log_tail`, `owner`, `server_status`
- **`customization` tool** (5 subactions): `theme`, `public_theme`, `is_initial_setup`, `sso_enabled`, `set_theme`
- **`plugin` tool** (3 subactions): `list`, `add`, `remove`
- **`oidc` tool** (5 subactions): `providers`, `provider`, `configuration`, `public_providers`, `validate_session`
- **Persistent `SubscriptionManager`**: `unraid://live/*` MCP resources backed by long-lived WebSocket connections with auto-start and reconnection
- **`diagnose_subscriptions`** and **`test_subscription_query`** diagnostic tools
- `array`: Added `parity_history`, `start_array`, `stop_array`, `add_disk`, `remove_disk`, `mount_disk`, `unmount_disk`, `clear_disk_stats`
- `keys`: Added `add_role`, `remove_role`
- `settings`: Added `update_ssh` (confirm required)
- `stop_array` added to `_ARRAY_DESTRUCTIVE`
- `gate_destructive_action` helper in `core/guards.py` — centralized elicitation + confirm guard
- Full safety test suite: `TestNoGraphQLCallsWhenUnconfirmed` (zero-I/O guarantee for all 13 destructive actions)
### Fixed
- Removed 29 actions confirmed absent from live API v4.29.2 via GraphQL introspection (Docker organizer mutations, `unassignedDevices`, `warningsAndAlerts`, etc.)
- `log_tail` path validated against allowlist before subscription start
- WebSocket auth uses `x-api-key` connectionParams format
---
## [1.0.0] - 2026-03-14 through 2026-03-15
### Breaking Changes
- Credential storage moved to `~/.unraid-mcp/.env` (dir 700, file 600); all runtimes load from this path
- `unraid_health(action="setup")` is the only tool that triggers credential elicitation; all others propagate `CredentialsNotConfiguredError`
### Added
- `CredentialsNotConfiguredError` sentinel — propagates cleanly through `tool_error_handler` with exact credential path in the error message
- `is_configured()` and `apply_runtime_config()` in `settings.py` for runtime credential injection
- `elicit_and_configure()` with `.env` persistence and confirmation before overwrite
- 28 GraphQL mutations across storage, docker, notifications, and new settings tool
- Comprehensive test suite expansion: schema validation (99 tests), HTTP layer (respx), property tests, safety audit, contract tests
### Fixed
- Numerous PR review fixes across 50+ commits (CodeRabbit, ChatGPT-Codex review rounds)
- Shell scripts hardened against injection and null guards
- Notification enum validation, subscription lock split, safe_get semantics
---
## [0.6.0] - 2026-03-15
### Added
- Subscription byte/line cap to prevent unbounded memory growth
- `asyncio.timeout` bounds on `subscribe_once` / `subscribe_collect`
- Partial auto-start for subscriptions (best-effort on startup)
### Fixed
- WebSocket URL scheme handling (`ws://`/`wss://`)
- `flash_backup` path validation and smoke test assertions
---
## [0.5.0] - 2026-03-15
*Tool expansion and live subscription foundation.*
---
## [0.4.x] - 2026-03-13 through 2026-03-14
*Credential elicitation system, per-tool refactors, and mutation additions.*
---
## [0.2.x] - 2026-02-15 through 2026-03-13
*Initial public release hardening: PR review cycles, test suite expansion, security fixes, plugin manifest.*
---
## [0.1.0] - 2026-02-08
### Added
- Consolidated 26 tools into 10 tools with 90 actions
- FastMCP architecture migration with `uv` toolchain
- Docker Compose support with health checks
- WebSocket subscription infrastructure
---
*Format: [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). Versioning: [Semantic Versioning](https://semver.org/).*

View File

@@ -54,12 +54,58 @@ docker compose down
```
### Environment Setup
- Copy `.env.example` to `.env` and configure:
- `UNRAID_API_URL`: Unraid GraphQL endpoint (required)
- `UNRAID_API_KEY`: Unraid API key (required)
- `UNRAID_MCP_TRANSPORT`: Transport type (default: streamable-http)
- `UNRAID_MCP_PORT`: Server port (default: 6970)
- `UNRAID_MCP_HOST`: Server host (default: 0.0.0.0)
Copy `.env.example` to `.env` and configure:
**Required:**
- `UNRAID_API_URL`: Unraid GraphQL endpoint
- `UNRAID_API_KEY`: Unraid API key
**Server:**
- `UNRAID_MCP_TRANSPORT`: Transport type (default: streamable-http)
- `UNRAID_MCP_PORT`: Server port (default: 6970)
- `UNRAID_MCP_HOST`: Server host (default: 0.0.0.0)
- `UNRAID_MCP_LOG_LEVEL`: Log verbosity (default: INFO)
- `UNRAID_MCP_LOG_FILE`: Log filename in logs/ (default: unraid-mcp.log)
**SSL/TLS:**
- `UNRAID_VERIFY_SSL`: SSL verification (default: true; set `false` for self-signed certs)
**Subscriptions:**
- `UNRAID_AUTO_START_SUBSCRIPTIONS`: Auto-start live subscriptions on startup (default: true)
- `UNRAID_MAX_RECONNECT_ATTEMPTS`: WebSocket reconnect limit (default: 10)
**Credentials override:**
- `UNRAID_CREDENTIALS_DIR`: Override the `~/.unraid-mcp/` credentials directory path
### Authentication (Optional — protects the HTTP server)
Two independent methods. Use either or both — when both are set, `MultiAuth` accepts either.
**Google OAuth** — requires all three vars:
| Env Var | Purpose |
|---------|---------|
| `GOOGLE_CLIENT_ID` | Google OAuth 2.0 Client ID |
| `GOOGLE_CLIENT_SECRET` | Google OAuth 2.0 Client Secret |
| `UNRAID_MCP_BASE_URL` | Public URL of this server (e.g. `http://10.1.0.2:6970`) |
| `UNRAID_MCP_JWT_SIGNING_KEY` | Stable 32+ char secret — prevents token invalidation on restart |
Google Cloud Console setup: APIs & Services → Credentials → OAuth 2.0 Client ID (Web application) → Authorized redirect URIs: `<UNRAID_MCP_BASE_URL>/auth/callback`
**API Key** — clients present as `Authorization: Bearer <key>`:
| Env Var | Purpose |
|---------|---------|
| `UNRAID_MCP_API_KEY` | Static bearer token (can be same value as `UNRAID_API_KEY`) |
**Generate a stable JWT signing key:**
```bash
python3 -c "import secrets; print(secrets.token_hex(32))"
```
**Omit all auth vars to run without auth** (default — open server).
**Full guide:** [`docs/AUTHENTICATION.md`](docs/AUTHENTICATION.md)
## Architecture
@@ -89,13 +135,16 @@ docker compose down
while the subscription starts — callers should retry in a moment. When
`UNRAID_AUTO_START_SUBSCRIPTIONS=false`, resources fall back to on-demand `subscribe_once`.
### Tool Categories (1 Tool, ~107 Subactions)
### Tool Categories (3 Tools: 1 Primary + 2 Diagnostic)
The server registers a **single consolidated `unraid` tool** with `action` (domain) + `subaction` (operation) routing. Call it as `unraid(action="docker", subaction="list")`.
The server registers **3 MCP tools**:
- **`unraid`** — primary tool with `action` (domain) + `subaction` (operation) routing, 107 subactions. Call it as `unraid(action="docker", subaction="list")`.
- **`diagnose_subscriptions`** — inspect subscription connection states, errors, and WebSocket URLs.
- **`test_subscription_query`** — test a specific GraphQL subscription query (allowlisted fields only).
| action | subactions |
|--------|-----------|
| **system** (19) | overview, array, network, registration, variables, metrics, services, display, config, online, owner, settings, server, servers, flash, ups_devices, ups_device, ups_config |
| **system** (18) | overview, array, network, registration, variables, metrics, services, display, config, online, owner, settings, server, servers, flash, ups_devices, ups_device, ups_config |
| **health** (4) | check, test_connection, diagnose, setup |
| **array** (13) | parity_status, parity_history, parity_start, parity_pause, parity_resume, parity_cancel, start_array, stop_array*, add_disk, remove_disk*, mount_disk, unmount_disk, clear_disk_stats* |
| **disk** (6) | shares, disks, disk_details, log_files, logs, flash_backup* |
@@ -181,7 +230,7 @@ uv run pytest -x # Fail fast on first error
### Scripts
```bash
# HTTP smoke-test against a live server (11 tools, all non-destructive actions)
# HTTP smoke-test against a live server (non-destructive actions, all domains)
./tests/mcporter/test-actions.sh [MCP_URL] # default: http://localhost:6970/mcp
# stdio smoke-test, no running server needed (good for CI)

View File

@@ -8,13 +8,13 @@
## ✨ Features
- 🔧 **1 Tool, ~108 Actions**: Complete Unraid management through a single consolidated MCP tool
- 🔧 **1 primary tool + 2 diagnostic tools, 107 subactions**: Complete Unraid management through a consolidated MCP tool
- 🏗️ **Modular Architecture**: Clean, maintainable, and extensible codebase
-**High Performance**: Async/concurrent operations with optimized timeouts
- 🔄 **Real-time Data**: WebSocket subscriptions for live metrics, logs, array state, and more
- 📊 **Health Monitoring**: Comprehensive system diagnostics and status
- 🐳 **Docker Ready**: Full containerization support with Docker Compose
- 🔒 **Secure**: Proper SSL/TLS configuration and API key management
- 🔒 **Secure**: Optional Google OAuth 2.0 authentication + SSL/TLS + API key management
- 📝 **Rich Logging**: Structured logging with rotation and multiple levels
---
@@ -25,6 +25,7 @@
- [Quick Start](#-quick-start)
- [Installation](#-installation)
- [Configuration](#-configuration)
- [Google OAuth](#-google-oauth-optional)
- [Available Tools & Resources](#-available-tools--resources)
- [Development](#-development)
- [Architecture](#-architecture)
@@ -45,7 +46,7 @@
```
This provides instant access to Unraid monitoring and management through Claude Code with:
- **1 MCP tool** (`unraid`) exposing **~108 actions** via `action` + `subaction` routing
- **1 primary MCP tool** (`unraid`) exposing **107 subactions** via `action` + `subaction` routing, plus `diagnose_subscriptions` and `test_subscription_query` diagnostic tools
- Real-time system metrics and health monitoring
- Docker container and VM lifecycle management
- Disk health monitoring and storage management
@@ -139,7 +140,7 @@ unraid-mcp/ # ${CLAUDE_PLUGIN_ROOT}
└── scripts/ # Validation and helper scripts
```
- **MCP Server**: 1 `unraid` tool with ~108 actions via GraphQL API
- **MCP Server**: 3 tools — `unraid` (107 subactions) + `diagnose_subscriptions` + `test_subscription_query`
- **Skill**: `/unraid` skill for monitoring and queries
- **Entry Point**: `unraid-mcp-server` defined in pyproject.toml
@@ -229,10 +230,10 @@ UNRAID_VERIFY_SSL=true # true, false, or path to CA bundle
# Subscription Configuration
UNRAID_AUTO_START_SUBSCRIPTIONS=true # Auto-start WebSocket subscriptions on startup (default: true)
UNRAID_MAX_RECONNECT_ATTEMPTS=5 # Max WebSocket reconnection attempts (default: 5)
UNRAID_MAX_RECONNECT_ATTEMPTS=10 # Max WebSocket reconnection attempts (default: 10)
# Optional: Log Stream Configuration
# UNRAID_AUTOSTART_LOG_PATH=/var/log/syslog # Path for log streaming resource (unraid://logs/stream)
# UNRAID_AUTOSTART_LOG_PATH=/var/log/syslog # Override log path for unraid://logs/stream (auto-detects /var/log/syslog if unset)
```
### Transport Options
@@ -245,11 +246,51 @@ UNRAID_MAX_RECONNECT_ATTEMPTS=5 # Max WebSocket reconnection attempts (def
---
## 🔐 Authentication (Optional)
Two independent auth methods — use either or both.
### Google OAuth
Protect the HTTP server with Google OAuth 2.0 — clients must complete a Google login before any tool call is executed.
```bash
# Add to ~/.unraid-mcp/.env
GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=GOCSPX-your-secret
UNRAID_MCP_BASE_URL=http://10.1.0.2:6970 # public URL of this server
UNRAID_MCP_JWT_SIGNING_KEY=<64-char-hex> # prevents token invalidation on restart
```
**Quick setup:**
1. [Google Cloud Console](https://console.cloud.google.com/) → Credentials → OAuth 2.0 Client ID (Web application)
2. Authorized redirect URI: `<UNRAID_MCP_BASE_URL>/auth/callback`
3. Copy Client ID + Secret into `~/.unraid-mcp/.env`
4. Generate a signing key: `python3 -c "import secrets; print(secrets.token_hex(32))"`
5. Restart the server
### API Key (Bearer Token)
Simpler option for headless/machine access — no browser flow required:
```bash
# Add to ~/.unraid-mcp/.env
UNRAID_MCP_API_KEY=your-secret-token # can be same value as UNRAID_API_KEY
```
Clients present it as `Authorization: Bearer <UNRAID_MCP_API_KEY>`. Set both `GOOGLE_CLIENT_ID` and `UNRAID_MCP_API_KEY` to accept either method simultaneously.
Omit both to run without authentication (default — open server).
**Full guide:** [`docs/AUTHENTICATION.md`](docs/AUTHENTICATION.md)
---
## 🛠️ Available Tools & Resources
The single `unraid` tool uses `action` (domain) + `subaction` (operation) routing to expose all operations via one MCP tool, minimizing context window usage. Destructive actions require `confirm=True`.
### Single Tool, 15 Domains, ~108 Actions
### Primary Tool: 15 Domains, 107 Subactions
Call pattern: `unraid(action="<domain>", subaction="<operation>")`
@@ -299,9 +340,11 @@ The server exposes two classes of MCP resources backed by persistent WebSocket c
**`unraid://logs/stream`** — Live log file tail (path controlled by `UNRAID_AUTOSTART_LOG_PATH`)
> **Note**: Resources return cached data from persistent WebSocket subscriptions. A `{"status": "connecting"}` placeholder is returned while the subscription initializes — retry in a moment.
>
> **`log_tail` and `notification_feed`** are accessible as tool subactions (`unraid(action="live", subaction="log_tail")`) but are not registered as MCP resources — they use transient one-shot subscriptions and require parameters.
> **Security note**: The `disk/logs` and `live/log_tail` subactions allow reading files under `/var/log/` and `/boot/logs/` on the Unraid server. Authenticated MCP clients can stream any log file within these directories.
---
@@ -331,7 +374,7 @@ unraid-mcp/
│ │ ├── queries.py # Subscription query constants
│ │ ├── diagnostics.py # Diagnostic tools
│ │ └── utils.py # Subscription utility functions
│ └── tools/ # Single consolidated tool (~108 actions)
│ └── tools/ # Consolidated tools (unraid: 107 subactions + 2 diagnostic tools)
│ └── unraid.py # All 15 domains in one file
├── tests/ # Test suite
│ ├── conftest.py # Shared fixtures
@@ -399,6 +442,28 @@ uv run unraid-mcp-server
# Or run via module directly
uv run -m unraid_mcp.main
# Hot-reload dev server (restarts on file changes)
fastmcp run fastmcp.http.json --reload
# Run via named config files
fastmcp run fastmcp.http.json # streamable-http on :6970
fastmcp run fastmcp.stdio.json # stdio transport
```
### Ad-hoc Tool Testing (fastmcp CLI)
```bash
# Introspect the running server
fastmcp list http://localhost:6970/mcp
fastmcp list http://localhost:6970/mcp --input-schema
# Call a tool directly (HTTP)
fastmcp call http://localhost:6970/mcp unraid action=health subaction=check
fastmcp call http://localhost:6970/mcp unraid action=docker subaction=list
# Call without a running server (stdio config)
fastmcp list fastmcp.stdio.json
fastmcp call fastmcp.stdio.json unraid action=health subaction=check
```
---

188
docs/AUTHENTICATION.md Normal file
View File

@@ -0,0 +1,188 @@
# Authentication Setup Guide
This document covers both Google OAuth 2.0 and API key bearer token authentication for the Unraid MCP HTTP server. It explains how to protect the server using FastMCP's built-in `GoogleProvider` for OAuth, or a static bearer token for headless/machine access.
---
## Overview
By default the MCP server is **open** — any client on the network can call tools. Setting three environment variables enables Google OAuth 2.1 authentication: clients must complete a Google login flow before the server will execute any tool.
OAuth state (issued tokens, refresh tokens) is persisted to an encrypted file store at `~/.local/share/fastmcp/oauth-proxy/`, so sessions survive server restarts when `UNRAID_MCP_JWT_SIGNING_KEY` is set.
> **Transport requirement**: OAuth only works with HTTP transports (`streamable-http` or `sse`). It has no effect on `stdio` — the server logs a warning if you configure both.
---
## Prerequisites
- Google account with access to [Google Cloud Console](https://console.cloud.google.com/)
- MCP server reachable at a known URL from your browser (LAN IP, Tailscale IP, or public domain)
- `UNRAID_MCP_TRANSPORT=streamable-http` (the default)
---
## Step 1: Create a Google OAuth Client
1. Open [Google Cloud Console](https://console.cloud.google.com/) → **APIs & Services****Credentials**
2. Click **Create Credentials****OAuth 2.0 Client ID**
3. Application type: **Web application**
4. Name: anything (e.g. `Unraid MCP`)
5. **Authorized redirect URIs** — add exactly:
```
http://<your-server-ip>:6970/auth/callback
```
Replace `<your-server-ip>` with the IP/hostname your browser uses to reach the MCP server (e.g. `10.1.0.2`, `100.x.x.x` for Tailscale, or a domain name).
6. Click **Create** — copy the **Client ID** and **Client Secret**
---
## Step 2: Configure Environment Variables
Add these to `~/.unraid-mcp/.env` (the canonical credential file for all runtimes):
```bash
# Google OAuth (optional — enables authentication)
GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=GOCSPX-your-client-secret
# Public base URL of this MCP server (must match the redirect URI above)
UNRAID_MCP_BASE_URL=http://10.1.0.2:6970
# Stable JWT signing key — prevents token invalidation on server restart
# Generate one: python3 -c "import secrets; print(secrets.token_hex(32))"
UNRAID_MCP_JWT_SIGNING_KEY=your-64-char-hex-string
```
**All four variables at once** (copy-paste template):
```bash
cat >> ~/.unraid-mcp/.env <<'EOF'
# Google OAuth
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
UNRAID_MCP_BASE_URL=http://10.1.0.2:6970
UNRAID_MCP_JWT_SIGNING_KEY=
EOF
```
Then fill in the blanks.
---
## Step 3: Generate a Stable JWT Signing Key
Without `UNRAID_MCP_JWT_SIGNING_KEY`, FastMCP derives a key on startup. Any server restart invalidates all existing tokens and forces every client to re-authenticate.
Generate a stable key once:
```bash
python3 -c "import secrets; print(secrets.token_hex(32))"
```
Paste the output into `UNRAID_MCP_JWT_SIGNING_KEY`. This value never needs to change unless you intentionally want to invalidate all sessions.
---
## Step 4: Restart the Server
```bash
# Docker Compose
docker compose restart unraid-mcp
# Direct / uv
uv run unraid-mcp-server
```
On startup you should see:
```
INFO [SERVER] Google OAuth enabled — base_url=http://10.1.0.2:6970, redirect_uri=http://10.1.0.2:6970/auth/callback
```
---
## How Authentication Works
1. An MCP client connects to `http://<server>:6970/mcp`
2. The server responds with a `401 Unauthorized` and an OAuth authorization URL
3. The client opens the URL in a browser; the user logs in with Google
4. Google redirects to `<UNRAID_MCP_BASE_URL>/auth/callback` with an authorization code
5. FastMCP exchanges the code for tokens, issues a signed JWT, and returns it to the client
6. The client includes the JWT in subsequent requests — the server validates it without hitting Google again
7. Tokens persist to `~/.local/share/fastmcp/oauth-proxy/` — sessions survive server restarts
---
## Environment Variable Reference
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `GOOGLE_CLIENT_ID` | For OAuth | `""` | OAuth 2.0 Client ID from Google Cloud Console |
| `GOOGLE_CLIENT_SECRET` | For OAuth | `""` | OAuth 2.0 Client Secret from Google Cloud Console |
| `UNRAID_MCP_BASE_URL` | For OAuth | `""` | Public base URL of this server — must match the authorized redirect URI |
| `UNRAID_MCP_JWT_SIGNING_KEY` | Recommended | auto-derived | Stable 32+ char secret for JWT signing — prevents token invalidation on restart |
OAuth is activated only when **all three** of `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, and `UNRAID_MCP_BASE_URL` are non-empty. Omit any one to run without authentication.
---
## Disabling OAuth
Remove (or empty) `GOOGLE_CLIENT_ID` from `~/.unraid-mcp/.env` and restart. The server reverts to unauthenticated mode and logs:
```
WARNING [SERVER] No authentication configured — MCP server is open to all clients on the network.
```
---
## Troubleshooting
**`redirect_uri_mismatch` from Google**
The redirect URI in Google Cloud Console must exactly match `<UNRAID_MCP_BASE_URL>/auth/callback` — same scheme, host, port, and path. Trailing slashes matter.
**Tokens invalidated after restart**
Set `UNRAID_MCP_JWT_SIGNING_KEY` to a stable secret (see Step 3). Without it, FastMCP generates a new key on every start.
**`stdio` transport warning**
OAuth requires an HTTP transport. Set `UNRAID_MCP_TRANSPORT=streamable-http` (the default) or `sse`.
**Client cannot reach the callback URL**
`UNRAID_MCP_BASE_URL` must be the address your browser uses to reach the server — not `localhost` or `0.0.0.0`. Use the LAN IP, Tailscale IP, or a domain name.
**OAuth configured but server not starting**
Check `logs/unraid-mcp.log` or `docker compose logs unraid-mcp` for startup errors.
---
## API Key Authentication (Alternative / Combined)
For machine-to-machine access (scripts, CI, other agents) without a browser-based OAuth flow, set `UNRAID_MCP_API_KEY`:
```bash
# In ~/.unraid-mcp/.env
UNRAID_MCP_API_KEY=your-secret-token
```
Clients present it as a standard bearer token:
```
Authorization: Bearer your-secret-token
```
**Combining with Google OAuth**: set both `GOOGLE_CLIENT_ID` and `UNRAID_MCP_API_KEY`. The server activates `MultiAuth` and accepts either method — Google OAuth for interactive clients, API key for headless clients.
**Reusing the Unraid API key**: you can set `UNRAID_MCP_API_KEY` to the same value as `UNRAID_API_KEY` for simplicity. The two vars are kept separate so each concern has its own name.
**Standalone API key** (no Google OAuth): set only `UNRAID_MCP_API_KEY`. The server validates bearer tokens directly with no OAuth redirect flow.
---
## Security Notes
- OAuth protects the MCP HTTP interface — the Unraid GraphQL API itself still uses `UNRAID_API_KEY`
- OAuth state files at `~/.local/share/fastmcp/oauth-proxy/` should be on a private filesystem; do not expose them
- Restrict Google OAuth to specific accounts via the Google Cloud Console **OAuth consent screen** → **Test users** if you don't want to publish the app
- `UNRAID_MCP_JWT_SIGNING_KEY` is a credential — store it in `~/.unraid-mcp/.env` (mode 600), never in source control

23
fastmcp.http.json Normal file
View File

@@ -0,0 +1,23 @@
{
"$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json",
"source": {
"path": "unraid_mcp/server.py",
"entrypoint": "mcp"
},
"environment": {
"type": "uv",
"python": "3.12",
"editable": ["."]
},
"deployment": {
"transport": "http",
"host": "0.0.0.0",
"port": 6970,
"path": "/mcp",
"log_level": "INFO",
"env": {
"UNRAID_API_URL": "${UNRAID_API_URL}",
"UNRAID_API_KEY": "${UNRAID_API_KEY}"
}
}
}

20
fastmcp.stdio.json Normal file
View File

@@ -0,0 +1,20 @@
{
"$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json",
"source": {
"path": "unraid_mcp/server.py",
"entrypoint": "mcp"
},
"environment": {
"type": "uv",
"python": "3.12",
"editable": ["."]
},
"deployment": {
"transport": "stdio",
"log_level": "INFO",
"env": {
"UNRAID_API_URL": "${UNRAID_API_URL}",
"UNRAID_API_KEY": "${UNRAID_API_KEY}"
}
}
}

View File

@@ -10,7 +10,7 @@ build-backend = "hatchling.build"
# ============================================================================
[project]
name = "unraid-mcp"
version = "1.0.1"
version = "1.1.2"
description = "MCP Server for Unraid API - provides tools to interact with an Unraid server's GraphQL API"
readme = "README.md"
license = {file = "LICENSE"}
@@ -258,6 +258,7 @@ omit = [
]
[tool.coverage.report]
fail_under = 80
precision = 2
show_missing = true
skip_covered = false

View File

@@ -70,6 +70,20 @@ else
echo -e "Checking: Plugin source path is valid... ${RED}${NC} (plugin not found in marketplace)"
fi
# Check version sync between pyproject.toml and plugin.json
echo "Checking version sync..."
TOML_VER=$(grep '^version = ' pyproject.toml | sed 's/version = "//;s/"//')
PLUGIN_VER=$(python3 -c "import json; print(json.load(open('.claude-plugin/plugin.json'))['version'])" 2>/dev/null || echo "ERROR_READING")
if [ "$TOML_VER" != "$PLUGIN_VER" ]; then
echo -e "${RED}FAIL: Version mismatch — pyproject.toml=$TOML_VER, plugin.json=$PLUGIN_VER${NC}"
CHECKS=$((CHECKS + 1))
FAILED=$((FAILED + 1))
else
echo -e "${GREEN}PASS: Versions in sync ($TOML_VER)${NC}"
CHECKS=$((CHECKS + 1))
PASSED=$((PASSED + 1))
fi
echo ""
echo "=== Results ==="
echo -e "Total checks: $CHECKS"

View File

@@ -1,6 +1,6 @@
# Unraid API - Complete Reference Guide
> **⚠️ DEVELOPER REFERENCE ONLY** — This file documents the raw GraphQL API schema for development and maintenance purposes (adding new queries/mutations). Do NOT use these curl/GraphQL examples for MCP tool usage. Use `unraid(action=..., subaction=...)` calls instead. See `SKILL.md` for the correct calling convention.
> **⚠️ DEVELOPER REFERENCE ONLY** — This file documents the raw GraphQL API schema for development and maintenance purposes (adding new queries/mutations). Do NOT use these curl/GraphQL examples for MCP tool usage. Use `unraid(action=..., subaction=...)` calls instead. See [`SKILL.md`](../SKILL.md) for the correct calling convention.
**Tested on:** Unraid 7.2 x86_64
**Date:** 2026-01-21

View File

@@ -30,9 +30,8 @@ unraid(action="array", subaction="stop_array", confirm=True) # ⚠️ Stop
```python
unraid(action="disk", subaction="log_files") # List available logs
unraid(action="disk", subaction="logs", log_path="syslog", tail_lines=50) # Read syslog
unraid(action="disk", subaction="logs", log_path="/var/log/syslog") # Full path also works
unraid(action="live", subaction="log_tail", log_path="/var/log/syslog") # Live tail
unraid(action="disk", subaction="logs", log_path="/var/log/syslog", tail_lines=50) # Read syslog
unraid(action="live", subaction="log_tail", path="/var/log/syslog") # Live tail
```
### Docker Containers
@@ -64,7 +63,7 @@ unraid(action="notification", subaction="overview")
unraid(action="notification", subaction="list", list_type="UNREAD", limit=10)
unraid(action="notification", subaction="archive", notification_id="<id>")
unraid(action="notification", subaction="create", title="Test", subject="Subject",
description="Body", importance="normal")
description="Body", importance="INFO")
```
### API Keys

View File

@@ -26,15 +26,15 @@ This writes `UNRAID_API_URL` and `UNRAID_API_KEY` to `~/.unraid-mcp/.env`. Re-ru
unraid(action="health", subaction="test_connection")
```
2. Full diagnostic report:
1. Full diagnostic report:
```python
unraid(action="health", subaction="diagnose")
```
3. Check that `UNRAID_API_URL` in `~/.unraid-mcp/.env` points to the correct Unraid GraphQL endpoint.
1. Check that `UNRAID_API_URL` in `~/.unraid-mcp/.env` points to the correct Unraid GraphQL endpoint.
4. Verify the API key has the required roles. Get a new key: **Unraid UI → Settings → Management Access → API Keys → Create** (select "Viewer" role for read-only, or appropriate roles for mutations).
1. Verify the API key has the required roles. Get a new key: **Unraid UI → Settings → Management Access → API Keys → Create** (select "Viewer" role for read-only, or appropriate roles for mutations).
---

View File

@@ -816,6 +816,15 @@ class TestAutoStart:
async def test_auto_start_only_starts_marked_subscriptions(self) -> None:
mgr = SubscriptionManager()
# Clear default SNAPSHOT_ACTIONS configs; add one with auto_start=False
# to verify that unmarked subscriptions are never started.
mgr.subscription_configs.clear()
mgr.subscription_configs["no_auto_sub"] = {
"query": "subscription { test }",
"resource": "unraid://test",
"description": "Unmarked sub",
"auto_start": False,
}
with patch.object(mgr, "start_subscription", new_callable=AsyncMock) as mock_start:
await mgr.auto_start_all_subscriptions()
mock_start.assert_not_called()
@@ -837,6 +846,7 @@ class TestAutoStart:
async def test_auto_start_calls_start_for_marked(self) -> None:
mgr = SubscriptionManager()
mgr.subscription_configs.clear()
mgr.subscription_configs["auto_sub"] = {
"query": "subscription { auto }",
"resource": "unraid://auto",

View File

@@ -134,6 +134,11 @@ check_prerequisites() {
missing=true
fi
if ! command -v jq &>/dev/null; then
log_error "jq not found in PATH. Install it and re-run."
missing=true
fi
if [[ ! -f "${PROJECT_DIR}/pyproject.toml" ]]; then
log_error "pyproject.toml not found at ${PROJECT_DIR}. Wrong directory?"
missing=true
@@ -181,10 +186,12 @@ smoke_test_server() {
import sys, json
try:
d = json.load(sys.stdin)
if 'status' in d or 'success' in d or 'error' in d:
if 'error' in d:
print('error: tool returned error key — ' + str(d.get('error', '')))
elif 'status' in d or 'success' in d:
print('ok')
else:
print('missing: no status/success/error key in response')
print('missing: no status/success key in response')
except Exception as e:
print('parse_error: ' + str(e))
" 2>/dev/null
@@ -253,6 +260,31 @@ run_test() {
return 1
fi
# Always validate JSON is parseable and not an error payload
local json_check
json_check="$(
printf '%s' "${output}" | python3 -c "
import sys, json
try:
d = json.load(sys.stdin)
if isinstance(d, dict) and ('error' in d or d.get('kind') == 'error'):
print('error: ' + str(d.get('error', d.get('message', 'unknown error'))))
else:
print('ok')
except Exception as e:
print('invalid_json: ' + str(e))
" 2>/dev/null
)" || json_check="parse_error"
if [[ "${json_check}" != "ok" ]]; then
printf "${C_RED}[FAIL]${C_RESET} %-55s ${C_DIM}%dms${C_RESET}\n" \
"${label}" "${elapsed_ms}" | tee -a "${LOG_FILE}"
printf ' response validation failed: %s\n' "${json_check}" | tee -a "${LOG_FILE}"
FAIL_COUNT=$(( FAIL_COUNT + 1 ))
FAIL_NAMES+=("${label}")
return 1
fi
# Validate optional key presence
if [[ -n "${expected_key}" ]]; then
local key_check

View File

@@ -36,7 +36,7 @@ def _all_domain_dicts(unraid_mod: object) -> list[tuple[str, dict[str, str]]]:
"""
import types
m = unraid_mod # type: ignore[assignment]
m = unraid_mod
if not isinstance(m, types.ModuleType):
import importlib
@@ -417,7 +417,6 @@ class TestDockerQueries:
"details",
"networks",
"network_details",
"_resolve",
}
assert set(QUERIES.keys()) == expected

155
tests/test_api_key_auth.py Normal file
View File

@@ -0,0 +1,155 @@
"""Tests for ApiKeyVerifier and _build_auth() in server.py."""
import importlib
from unittest.mock import MagicMock, patch
import pytest
import unraid_mcp.server as srv
# ---------------------------------------------------------------------------
# ApiKeyVerifier unit tests
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_api_key_verifier_accepts_correct_key():
"""Returns AccessToken when the presented token matches the configured key."""
verifier = srv.ApiKeyVerifier("secret-key-abc123")
result = await verifier.verify_token("secret-key-abc123")
assert result is not None
assert result.client_id == "api-key-client"
assert result.token == "secret-key-abc123"
@pytest.mark.asyncio
async def test_api_key_verifier_rejects_wrong_key():
"""Returns None when the token does not match."""
verifier = srv.ApiKeyVerifier("secret-key-abc123")
result = await verifier.verify_token("wrong-key")
assert result is None
@pytest.mark.asyncio
async def test_api_key_verifier_rejects_empty_token():
"""Returns None for an empty string token."""
verifier = srv.ApiKeyVerifier("secret-key-abc123")
result = await verifier.verify_token("")
assert result is None
@pytest.mark.asyncio
async def test_api_key_verifier_empty_key_rejects_empty_token():
"""When initialised with empty key, even an empty token is rejected.
An empty UNRAID_MCP_API_KEY means auth is disabled — ApiKeyVerifier
should not be instantiated in that case. But if it is, it must not
grant access via an empty bearer token.
"""
verifier = srv.ApiKeyVerifier("")
result = await verifier.verify_token("")
assert result is None
# ---------------------------------------------------------------------------
# _build_auth() integration tests
# ---------------------------------------------------------------------------
def test_build_auth_returns_none_when_nothing_configured(monkeypatch):
"""Returns None when neither Google OAuth nor API key is set."""
monkeypatch.setenv("GOOGLE_CLIENT_ID", "")
monkeypatch.setenv("GOOGLE_CLIENT_SECRET", "")
monkeypatch.setenv("UNRAID_MCP_BASE_URL", "")
monkeypatch.setenv("UNRAID_MCP_API_KEY", "")
import unraid_mcp.config.settings as s
importlib.reload(s)
result = srv._build_auth()
assert result is None
def test_build_auth_returns_api_key_verifier_when_only_api_key_set(monkeypatch):
"""Returns ApiKeyVerifier when UNRAID_MCP_API_KEY is set but Google OAuth is not."""
monkeypatch.setenv("GOOGLE_CLIENT_ID", "")
monkeypatch.setenv("GOOGLE_CLIENT_SECRET", "")
monkeypatch.setenv("UNRAID_MCP_BASE_URL", "")
monkeypatch.setenv("UNRAID_MCP_API_KEY", "my-secret-api-key")
import unraid_mcp.config.settings as s
importlib.reload(s)
result = srv._build_auth()
assert isinstance(result, srv.ApiKeyVerifier)
def test_build_auth_returns_google_provider_when_only_oauth_set(monkeypatch):
"""Returns GoogleProvider when Google OAuth vars are set but no API key."""
monkeypatch.setenv("GOOGLE_CLIENT_ID", "test-id.apps.googleusercontent.com")
monkeypatch.setenv("GOOGLE_CLIENT_SECRET", "GOCSPX-test-secret")
monkeypatch.setenv("UNRAID_MCP_BASE_URL", "http://10.1.0.2:6970")
monkeypatch.setenv("UNRAID_MCP_API_KEY", "")
monkeypatch.setenv("UNRAID_MCP_JWT_SIGNING_KEY", "x" * 32)
import unraid_mcp.config.settings as s
importlib.reload(s)
mock_provider = MagicMock()
with patch("unraid_mcp.server.GoogleProvider", return_value=mock_provider):
result = srv._build_auth()
assert result is mock_provider
def test_build_auth_returns_multi_auth_when_both_configured(monkeypatch):
"""Returns MultiAuth when both Google OAuth and UNRAID_MCP_API_KEY are set."""
from fastmcp.server.auth import MultiAuth
monkeypatch.setenv("GOOGLE_CLIENT_ID", "test-id.apps.googleusercontent.com")
monkeypatch.setenv("GOOGLE_CLIENT_SECRET", "GOCSPX-test-secret")
monkeypatch.setenv("UNRAID_MCP_BASE_URL", "http://10.1.0.2:6970")
monkeypatch.setenv("UNRAID_MCP_API_KEY", "my-secret-api-key")
monkeypatch.setenv("UNRAID_MCP_JWT_SIGNING_KEY", "x" * 32)
import unraid_mcp.config.settings as s
importlib.reload(s)
mock_provider = MagicMock()
with patch("unraid_mcp.server.GoogleProvider", return_value=mock_provider):
result = srv._build_auth()
assert isinstance(result, MultiAuth)
# Server is the Google provider
assert result.server is mock_provider
# One additional verifier — the ApiKeyVerifier
assert len(result.verifiers) == 1
assert isinstance(result.verifiers[0], srv.ApiKeyVerifier)
def test_build_auth_multi_auth_api_key_verifier_uses_correct_key(monkeypatch):
"""The ApiKeyVerifier inside MultiAuth is seeded with the configured key."""
monkeypatch.setenv("GOOGLE_CLIENT_ID", "test-id.apps.googleusercontent.com")
monkeypatch.setenv("GOOGLE_CLIENT_SECRET", "GOCSPX-test-secret")
monkeypatch.setenv("UNRAID_MCP_BASE_URL", "http://10.1.0.2:6970")
monkeypatch.setenv("UNRAID_MCP_API_KEY", "super-secret-token")
monkeypatch.setenv("UNRAID_MCP_JWT_SIGNING_KEY", "x" * 32)
import unraid_mcp.config.settings as s
importlib.reload(s)
with patch("unraid_mcp.server.GoogleProvider", return_value=MagicMock()):
result = srv._build_auth()
verifier = result.verifiers[0]
assert verifier._api_key == "super-secret-token"

115
tests/test_auth_builder.py Normal file
View File

@@ -0,0 +1,115 @@
"""Tests for _build_google_auth() in server.py."""
import importlib
from unittest.mock import MagicMock, patch
from unraid_mcp.server import _build_google_auth
def test_build_google_auth_returns_none_when_unconfigured(monkeypatch):
"""Returns None when Google OAuth env vars are absent."""
# Use explicit empty values so dotenv reload cannot re-inject from ~/.unraid-mcp/.env.
monkeypatch.setenv("GOOGLE_CLIENT_ID", "")
monkeypatch.setenv("GOOGLE_CLIENT_SECRET", "")
monkeypatch.setenv("UNRAID_MCP_BASE_URL", "")
import unraid_mcp.config.settings as s
importlib.reload(s)
result = _build_google_auth()
assert result is None
def test_build_google_auth_returns_provider_when_configured(monkeypatch):
"""Returns GoogleProvider instance when all required vars are set."""
monkeypatch.setenv("GOOGLE_CLIENT_ID", "test-id.apps.googleusercontent.com")
monkeypatch.setenv("GOOGLE_CLIENT_SECRET", "GOCSPX-test-secret")
monkeypatch.setenv("UNRAID_MCP_BASE_URL", "http://10.1.0.2:6970")
monkeypatch.setenv("UNRAID_MCP_JWT_SIGNING_KEY", "x" * 32)
import unraid_mcp.config.settings as s
importlib.reload(s)
mock_provider = MagicMock()
mock_provider_class = MagicMock(return_value=mock_provider)
with patch("unraid_mcp.server.GoogleProvider", mock_provider_class):
result = _build_google_auth()
assert result is mock_provider
mock_provider_class.assert_called_once_with(
client_id="test-id.apps.googleusercontent.com",
client_secret="GOCSPX-test-secret",
base_url="http://10.1.0.2:6970",
extra_authorize_params={"access_type": "online", "prompt": "consent"},
require_authorization_consent=False,
jwt_signing_key="x" * 32,
)
def test_build_google_auth_omits_jwt_key_when_empty(monkeypatch):
"""jwt_signing_key is omitted (not passed as empty string) when not set."""
monkeypatch.setenv("GOOGLE_CLIENT_ID", "test-id.apps.googleusercontent.com")
monkeypatch.setenv("GOOGLE_CLIENT_SECRET", "GOCSPX-test-secret")
monkeypatch.setenv("UNRAID_MCP_BASE_URL", "http://10.1.0.2:6970")
# Use setenv("") not delenv so dotenv reload can't re-inject from ~/.unraid-mcp/.env
monkeypatch.setenv("UNRAID_MCP_JWT_SIGNING_KEY", "")
import unraid_mcp.config.settings as s
importlib.reload(s)
mock_provider_class = MagicMock(return_value=MagicMock())
with patch("unraid_mcp.server.GoogleProvider", mock_provider_class):
_build_google_auth()
call_kwargs = mock_provider_class.call_args.kwargs
assert "jwt_signing_key" not in call_kwargs
def test_build_google_auth_warns_on_stdio_transport(monkeypatch):
"""Logs a warning when Google auth is configured but transport is stdio."""
monkeypatch.setenv("GOOGLE_CLIENT_ID", "test-id.apps.googleusercontent.com")
monkeypatch.setenv("GOOGLE_CLIENT_SECRET", "GOCSPX-test-secret")
monkeypatch.setenv("UNRAID_MCP_BASE_URL", "http://10.1.0.2:6970")
monkeypatch.setenv("UNRAID_MCP_TRANSPORT", "stdio")
import unraid_mcp.config.settings as s
importlib.reload(s)
warning_messages: list[str] = []
with (
patch("unraid_mcp.server.GoogleProvider", MagicMock(return_value=MagicMock())),
patch("unraid_mcp.server.logger") as mock_logger,
):
mock_logger.warning.side_effect = lambda msg, *a, **kw: warning_messages.append(msg)
_build_google_auth()
assert any("stdio" in m.lower() for m in warning_messages)
def test_mcp_instance_has_no_auth_by_default():
"""The FastMCP mcp instance has no auth provider when Google vars are absent."""
import os
for var in ("GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET", "UNRAID_MCP_BASE_URL"):
os.environ[var] = ""
import importlib
import unraid_mcp.config.settings as s
importlib.reload(s)
import unraid_mcp.server as srv
importlib.reload(srv)
# FastMCP stores auth on ._auth_provider or .auth
auth = getattr(srv.mcp, "_auth_provider", None) or getattr(srv.mcp, "auth", None)
assert auth is None

View File

@@ -0,0 +1,91 @@
"""Tests for Google OAuth settings loading."""
import importlib
from typing import Any
def _reload_settings(monkeypatch, overrides: dict) -> Any:
"""Reload settings module with given env vars set."""
for k, v in overrides.items():
monkeypatch.setenv(k, v)
import unraid_mcp.config.settings as mod
importlib.reload(mod)
return mod
def test_google_auth_defaults_to_empty(monkeypatch):
"""Google auth vars default to empty string when not set."""
# Use setenv("", "") rather than delenv so dotenv reload can't re-inject values
# from ~/.unraid-mcp/.env (load_dotenv won't override existing env vars).
mod = _reload_settings(
monkeypatch,
{
"GOOGLE_CLIENT_ID": "",
"GOOGLE_CLIENT_SECRET": "",
"UNRAID_MCP_BASE_URL": "",
"UNRAID_MCP_JWT_SIGNING_KEY": "",
},
)
assert mod.GOOGLE_CLIENT_ID == ""
assert mod.GOOGLE_CLIENT_SECRET == ""
assert mod.UNRAID_MCP_BASE_URL == ""
assert mod.UNRAID_MCP_JWT_SIGNING_KEY == ""
def test_google_auth_reads_env_vars(monkeypatch):
"""Google auth vars are read from environment."""
mod = _reload_settings(
monkeypatch,
{
"GOOGLE_CLIENT_ID": "test-client-id.apps.googleusercontent.com",
"GOOGLE_CLIENT_SECRET": "GOCSPX-test-secret",
"UNRAID_MCP_BASE_URL": "http://10.1.0.2:6970",
"UNRAID_MCP_JWT_SIGNING_KEY": "a" * 32,
},
)
assert mod.GOOGLE_CLIENT_ID == "test-client-id.apps.googleusercontent.com"
assert mod.GOOGLE_CLIENT_SECRET == "GOCSPX-test-secret"
assert mod.UNRAID_MCP_BASE_URL == "http://10.1.0.2:6970"
assert mod.UNRAID_MCP_JWT_SIGNING_KEY == "a" * 32
def test_google_auth_enabled_requires_both_vars(monkeypatch):
"""is_google_auth_configured() requires both client_id and client_secret."""
# Only client_id — not configured
mod = _reload_settings(
monkeypatch,
{
"GOOGLE_CLIENT_ID": "test-id",
"GOOGLE_CLIENT_SECRET": "",
"UNRAID_MCP_BASE_URL": "http://10.1.0.2:6970",
},
)
monkeypatch.delenv("GOOGLE_CLIENT_SECRET", raising=False)
importlib.reload(mod)
assert not mod.is_google_auth_configured()
# Both set — configured
mod2 = _reload_settings(
monkeypatch,
{
"GOOGLE_CLIENT_ID": "test-id",
"GOOGLE_CLIENT_SECRET": "test-secret",
"UNRAID_MCP_BASE_URL": "http://10.1.0.2:6970",
},
)
assert mod2.is_google_auth_configured()
def test_google_auth_requires_base_url(monkeypatch):
"""is_google_auth_configured() is False when base_url is missing."""
mod = _reload_settings(
monkeypatch,
{
"GOOGLE_CLIENT_ID": "test-id",
"GOOGLE_CLIENT_SECRET": "test-secret",
},
)
monkeypatch.delenv("UNRAID_MCP_BASE_URL", raising=False)
importlib.reload(mod)
assert not mod.is_google_auth_configured()

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
from typing import Any
from unittest.mock import AsyncMock, patch
import pytest

View File

@@ -141,8 +141,8 @@ class TestHealthActions:
"unraid_mcp.subscriptions.utils._analyze_subscription_status",
return_value=(0, []),
),
patch("unraid_mcp.server.cache_middleware", mock_cache),
patch("unraid_mcp.server.error_middleware", mock_error),
patch("unraid_mcp.server._cache_middleware", mock_cache),
patch("unraid_mcp.server._error_middleware", mock_error),
):
result = await tool_fn(action="health", subaction="diagnose")
assert "subscriptions" in result

View File

@@ -36,6 +36,8 @@ class TestLiveResourcesUseManagerCache:
with patch("unraid_mcp.subscriptions.resources.subscription_manager") as mock_mgr:
mock_mgr.get_resource_data = AsyncMock(return_value=cached)
mcp = _make_resources()
# Accessing FastMCP internals intentionally for unit test isolation.
# This may break on FastMCP upgrades — consider a make_resource_fn() helper if it does.
resource = mcp.providers[0]._components[f"resource:unraid://live/{action}@"]
result = await resource.fn()
assert json.loads(result) == cached
@@ -49,6 +51,8 @@ class TestLiveResourcesUseManagerCache:
mock_mgr.get_resource_data = AsyncMock(return_value=None)
mock_mgr.last_error = {}
mcp = _make_resources()
# Accessing FastMCP internals intentionally for unit test isolation.
# This may break on FastMCP upgrades — consider a make_resource_fn() helper if it does.
resource = mcp.providers[0]._components[f"resource:unraid://live/{action}@"]
result = await resource.fn()
parsed = json.loads(result)
@@ -61,6 +65,8 @@ class TestLiveResourcesUseManagerCache:
mock_mgr.get_resource_data = AsyncMock(return_value=None)
mock_mgr.last_error = {action: "WebSocket auth failed"}
mcp = _make_resources()
# Accessing FastMCP internals intentionally for unit test isolation.
# This may break on FastMCP upgrades — consider a make_resource_fn() helper if it does.
resource = mcp.providers[0]._components[f"resource:unraid://live/{action}@"]
result = await resource.fn()
parsed = json.loads(result)
@@ -96,6 +102,8 @@ class TestLogsStreamResource:
mock_mgr.get_resource_data = AsyncMock(return_value=None)
mcp = _make_resources()
local_provider = mcp.providers[0]
# Accessing FastMCP internals intentionally for unit test isolation.
# This may break on FastMCP upgrades — consider a make_resource_fn() helper if it does.
resource = local_provider._components["resource:unraid://logs/stream@"]
result = await resource.fn()
parsed = json.loads(result)
@@ -108,6 +116,8 @@ class TestLogsStreamResource:
mock_mgr.get_resource_data = AsyncMock(return_value={})
mcp = _make_resources()
local_provider = mcp.providers[0]
# Accessing FastMCP internals intentionally for unit test isolation.
# This may break on FastMCP upgrades — consider a make_resource_fn() helper if it does.
resource = local_provider._components["resource:unraid://logs/stream@"]
result = await resource.fn()
assert json.loads(result) == {}
@@ -131,6 +141,8 @@ class TestAutoStartDisabledFallback:
mock_mgr.last_error = {}
mock_mgr.auto_start_enabled = False
mcp = _make_resources()
# Accessing FastMCP internals intentionally for unit test isolation.
# This may break on FastMCP upgrades — consider a make_resource_fn() helper if it does.
resource = mcp.providers[0]._components[f"resource:unraid://live/{action}@"]
result = await resource.fn()
assert json.loads(result) == fallback_data
@@ -150,6 +162,8 @@ class TestAutoStartDisabledFallback:
mock_mgr.last_error = {}
mock_mgr.auto_start_enabled = False
mcp = _make_resources()
# Accessing FastMCP internals intentionally for unit test isolation.
# This may break on FastMCP upgrades — consider a make_resource_fn() helper if it does.
resource = mcp.providers[0]._components[f"resource:unraid://live/{action}@"]
result = await resource.fn()
assert json.loads(result)["status"] == "connecting"

View File

@@ -64,12 +64,12 @@ class TestStorageValidation:
async def test_logs_rejects_path_traversal(self, _mock_graphql: AsyncMock) -> None:
tool_fn = _make_tool()
# Traversal that escapes /var/log/ to reach /etc/shadow
with pytest.raises(ToolError, match="log_path must start with"):
# Traversal that escapes /var/log/ — detected by early .. check
with pytest.raises(ToolError, match="log_path"):
await tool_fn(action="disk", subaction="logs", log_path="/var/log/../../etc/shadow")
# Traversal that escapes /mnt/ to reach /etc/passwd
with pytest.raises(ToolError, match="log_path must start with"):
await tool_fn(action="disk", subaction="logs", log_path="/mnt/../etc/passwd")
# Traversal via .. — detected by early .. check
with pytest.raises(ToolError, match="log_path"):
await tool_fn(action="disk", subaction="logs", log_path="/var/log/../etc/passwd")
async def test_logs_allows_valid_paths(self, _mock_graphql: AsyncMock) -> None:
_mock_graphql.return_value = {"logFile": {"path": "/var/log/syslog", "content": "ok"}}

View File

@@ -76,6 +76,41 @@ elif raw_verify_ssl in ["true", "1", "yes"]:
else: # Path to CA bundle
UNRAID_VERIFY_SSL = raw_verify_ssl
# Google OAuth Configuration (Optional)
# -------------------------------------
# When set, the MCP HTTP server requires Google login before tool calls.
# UNRAID_MCP_BASE_URL must match the public URL clients use to reach this server.
# Google Cloud Console → Credentials → Authorized redirect URIs:
# Add: <UNRAID_MCP_BASE_URL>/auth/callback
GOOGLE_CLIENT_ID = os.getenv("GOOGLE_CLIENT_ID", "")
GOOGLE_CLIENT_SECRET = os.getenv("GOOGLE_CLIENT_SECRET", "")
UNRAID_MCP_BASE_URL = os.getenv("UNRAID_MCP_BASE_URL", "")
# JWT signing key for FastMCP OAuth tokens.
# MUST be set to a stable secret so tokens survive server restarts.
# Generate once: python3 -c "import secrets; print(secrets.token_hex(32))"
# Never change this value — all existing tokens will be invalidated.
UNRAID_MCP_JWT_SIGNING_KEY = os.getenv("UNRAID_MCP_JWT_SIGNING_KEY", "")
def is_google_auth_configured() -> bool:
"""Return True when all required Google OAuth vars are present."""
return bool(GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET and UNRAID_MCP_BASE_URL)
# API Key Authentication (Optional)
# ----------------------------------
# A static bearer token clients can use instead of (or alongside) Google OAuth.
# Can be set to the same value as UNRAID_API_KEY for simplicity, or a separate
# dedicated secret for MCP access.
UNRAID_MCP_API_KEY = os.getenv("UNRAID_MCP_API_KEY", "")
def is_api_key_auth_configured() -> bool:
"""Return True when UNRAID_MCP_API_KEY is set."""
return bool(UNRAID_MCP_API_KEY)
# Logging Configuration
LOG_LEVEL_STR = os.getenv("UNRAID_MCP_LOG_LEVEL", "INFO").upper()
LOG_FILE_NAME = os.getenv("UNRAID_MCP_LOG_FILE", "unraid-mcp.log")
@@ -155,6 +190,10 @@ def get_config_summary() -> dict[str, Any]:
"log_file": str(LOG_FILE_PATH),
"config_valid": is_valid,
"missing_config": missing if not is_valid else None,
"google_auth_enabled": is_google_auth_configured(),
"google_auth_base_url": UNRAID_MCP_BASE_URL if is_google_auth_configured() else None,
"jwt_signing_key_configured": bool(UNRAID_MCP_JWT_SIGNING_KEY),
"api_key_auth_enabled": is_api_key_auth_configured(),
}

View File

@@ -4,9 +4,13 @@ This is the main server implementation using the modular architecture with
separate modules for configuration, core functionality, subscriptions, and tools.
"""
import hmac
import sys
from typing import Any
from fastmcp import FastMCP
from fastmcp.server.auth import AccessToken, MultiAuth, TokenVerifier
from fastmcp.server.auth.providers.google import GoogleProvider
from fastmcp.server.middleware.caching import CallToolSettings, ResponseCachingMiddleware
from fastmcp.server.middleware.error_handling import ErrorHandlingMiddleware
from fastmcp.server.middleware.logging import LoggingMiddleware
@@ -15,6 +19,7 @@ from fastmcp.server.middleware.response_limiting import ResponseLimitingMiddlewa
from .config.logging import logger
from .config.settings import (
LOG_LEVEL_STR,
UNRAID_MCP_HOST,
UNRAID_MCP_PORT,
UNRAID_MCP_TRANSPORT,
@@ -39,26 +44,32 @@ _logging_middleware = LoggingMiddleware(
# 2. Catch any unhandled exceptions and convert to proper MCP errors.
# Tracks error_counts per (exception_type:method) for health diagnose.
error_middleware = ErrorHandlingMiddleware(
_error_middleware = ErrorHandlingMiddleware(
logger=logger,
include_traceback=True,
include_traceback=LOG_LEVEL_STR == "DEBUG",
)
# 3. Unraid API rate limit: 100 requests per 10 seconds.
# Use a sliding window that stays comfortably under that cap.
_rate_limiter = SlidingWindowRateLimitingMiddleware(max_requests=90, window_minutes=1)
# SlidingWindowRateLimitingMiddleware only accepts window_minutes (int), so express
# the 10-second budget as a 1-minute equivalent: 540 req/60 s to stay comfortably
# under the 600 req/min ceiling.
_rate_limiter = SlidingWindowRateLimitingMiddleware(max_requests=540, window_minutes=1)
# 4. Cap tool responses at 512 KB to protect the client context window.
# Oversized responses are truncated with a clear suffix rather than erroring.
_response_limiter = ResponseLimitingMiddleware(max_size=512_000)
# 5. Cache tool calls in-memory (MemoryStore default — no extra deps).
# Short 30 s TTL absorbs burst duplicate requests while keeping data fresh.
# Destructive calls won't hit the cache in practice (unique confirm=True + IDs).
cache_middleware = ResponseCachingMiddleware(
# 5. Cache middleware — all call_tool caching is disabled for the `unraid` tool.
# CallToolSettings supports excluded_tools/included_tools by tool name only; there
# is no per-argument or per-subaction exclusion mechanism. The cache key is
# "{tool_name}:{arguments_str}", so a cached stop("nginx") result would be served
# back on a retry within the TTL window even though the container is already stopped.
# Mutation subactions (start, stop, restart, reboot, etc.) must never be cached.
# Because the consolidated `unraid` tool mixes reads and mutations under one name,
# the only safe option is to disable caching for the entire tool.
_cache_middleware = ResponseCachingMiddleware(
call_tool_settings=CallToolSettings(
ttl=30,
included_tools=["unraid"],
enabled=False,
),
# Disable caching for list/resource/prompt — those are cheap.
list_tools_settings={"enabled": False},
@@ -68,23 +79,134 @@ cache_middleware = ResponseCachingMiddleware(
get_prompt_settings={"enabled": False},
)
class ApiKeyVerifier(TokenVerifier):
"""Bearer token verifier that validates against a static API key.
Clients present the key as a standard OAuth bearer token:
Authorization: Bearer <UNRAID_MCP_API_KEY>
This allows machine-to-machine access (e.g. CI, scripts, other agents)
without going through the Google OAuth browser flow.
"""
def __init__(self, api_key: str) -> None:
super().__init__()
self._api_key = api_key
async def verify_token(self, token: str) -> AccessToken | None:
if self._api_key and hmac.compare_digest(token.encode(), self._api_key.encode()):
return AccessToken(
token=token,
client_id="api-key-client",
scopes=[],
)
return None
def _build_google_auth() -> "GoogleProvider | None":
"""Build GoogleProvider when OAuth env vars are configured, else return None.
Returns None (no auth) when GOOGLE_CLIENT_ID or GOOGLE_CLIENT_SECRET are absent,
preserving backward compatibility for existing unprotected setups.
"""
from .config.settings import (
GOOGLE_CLIENT_ID,
GOOGLE_CLIENT_SECRET,
UNRAID_MCP_BASE_URL,
UNRAID_MCP_JWT_SIGNING_KEY,
UNRAID_MCP_TRANSPORT,
is_google_auth_configured,
)
if not is_google_auth_configured():
return None
if UNRAID_MCP_TRANSPORT == "stdio":
logger.warning(
"Google OAuth is configured but UNRAID_MCP_TRANSPORT=stdio. "
"OAuth requires HTTP transport (streamable-http or sse). "
"Auth will be applied but may not work as expected."
)
kwargs: dict[str, Any] = {
"client_id": GOOGLE_CLIENT_ID,
"client_secret": GOOGLE_CLIENT_SECRET,
"base_url": UNRAID_MCP_BASE_URL,
# Prefer short-lived access tokens without refresh-token rotation churn.
# This reduces reconnect instability in MCP clients that re-auth frequently.
"extra_authorize_params": {"access_type": "online", "prompt": "consent"},
# Skip the FastMCP consent page — goes directly to Google.
# The consent page has a CSRF double-load race: two concurrent GET requests
# each regenerate the CSRF token, the second overwrites the first in the
# transaction store, and the POST fails with "Invalid or expired consent token".
"require_authorization_consent": False,
}
if UNRAID_MCP_JWT_SIGNING_KEY:
kwargs["jwt_signing_key"] = UNRAID_MCP_JWT_SIGNING_KEY
else:
logger.warning(
"UNRAID_MCP_JWT_SIGNING_KEY is not set. FastMCP will derive a key automatically, "
"but tokens may be invalidated on server restart. "
"Set UNRAID_MCP_JWT_SIGNING_KEY to a stable secret."
)
logger.info(
f"Google OAuth enabled — base_url={UNRAID_MCP_BASE_URL}, "
f"redirect_uri={UNRAID_MCP_BASE_URL}/auth/callback"
)
return GoogleProvider(**kwargs)
def _build_auth() -> "GoogleProvider | ApiKeyVerifier | MultiAuth | None":
"""Build the active auth stack from environment configuration.
Returns:
- MultiAuth(server=GoogleProvider, verifiers=[ApiKeyVerifier])
when both GOOGLE_CLIENT_ID and UNRAID_MCP_API_KEY are set.
- GoogleProvider alone when only Google OAuth vars are set.
- ApiKeyVerifier alone when only UNRAID_MCP_API_KEY is set.
- None when no auth vars are configured (open server).
"""
from .config.settings import UNRAID_MCP_API_KEY, is_api_key_auth_configured
google = _build_google_auth()
api_key = ApiKeyVerifier(UNRAID_MCP_API_KEY) if is_api_key_auth_configured() else None
if google and api_key:
logger.info("Auth: Google OAuth + API key both enabled (MultiAuth)")
return MultiAuth(server=google, verifiers=[api_key])
if api_key:
logger.info("Auth: API key authentication enabled")
return api_key
return google # GoogleProvider or None
# Build auth stack — GoogleProvider, ApiKeyVerifier, MultiAuth, or None.
_auth = _build_auth()
# Initialize FastMCP instance
mcp = FastMCP(
name="Unraid MCP Server",
instructions="Provides tools to interact with an Unraid server's GraphQL API.",
version=VERSION,
auth=_auth,
middleware=[
_logging_middleware,
error_middleware,
_error_middleware,
_rate_limiter,
_response_limiter,
cache_middleware,
_cache_middleware,
],
)
# Note: SubscriptionManager singleton is defined in subscriptions/manager.py
# and imported by resources.py - no duplicate instance needed here
# Register all modules at import time so `fastmcp run server.py --reload` can
# discover the fully-configured `mcp` object without going through run_server().
# run_server() no longer calls this — tools are registered exactly once here.
def register_all_modules() -> None:
"""Register all tools and resources with the MCP instance."""
@@ -103,6 +225,9 @@ def register_all_modules() -> None:
raise
register_all_modules()
def run_server() -> None:
"""Run the MCP server with the configured transport."""
# Validate required configuration before anything else
@@ -125,8 +250,26 @@ def run_server() -> None:
"Only use this in trusted networks or for development."
)
# Register all modules
register_all_modules()
if _auth is not None:
from .config.settings import is_google_auth_configured
if is_google_auth_configured():
from .config.settings import UNRAID_MCP_BASE_URL
logger.info(
"Google OAuth ENABLED — clients must authenticate before calling tools. "
f"Redirect URI: {UNRAID_MCP_BASE_URL}/auth/callback"
)
else:
logger.info(
"API key authentication ENABLED — present UNRAID_MCP_API_KEY as bearer token."
)
else:
logger.warning(
"No authentication configured — MCP server is open to all clients on the network. "
"Set GOOGLE_CLIENT_ID + GOOGLE_CLIENT_SECRET + UNRAID_MCP_BASE_URL to enable Google OAuth, "
"or set UNRAID_MCP_API_KEY to enable bearer token authentication."
)
logger.info(
f"Starting Unraid MCP Server on {UNRAID_MCP_HOST}:{UNRAID_MCP_PORT} using {UNRAID_MCP_TRANSPORT} transport..."

View File

@@ -15,13 +15,18 @@ import websockets
from fastmcp import FastMCP
from websockets.typing import Subprotocol
from ..config import settings as _settings
from ..config.logging import logger
from ..config.settings import UNRAID_API_KEY, UNRAID_API_URL
from ..core.exceptions import ToolError
from ..core.utils import safe_display_url
from .manager import subscription_manager
from .resources import ensure_subscriptions_started
from .utils import _analyze_subscription_status, build_ws_ssl_context, build_ws_url
from .utils import (
_analyze_subscription_status,
build_connection_init,
build_ws_ssl_context,
build_ws_url,
)
# Schema field names that appear inside the selection set of allowed subscriptions.
@@ -125,15 +130,8 @@ def register_diagnostic_tools(mcp: FastMCP) -> None:
ping_interval=30,
ping_timeout=10,
) as websocket:
# Send connection init (using standard X-API-Key format)
await websocket.send(
json.dumps(
{
"type": "connection_init",
"payload": {"x-api-key": UNRAID_API_KEY},
}
)
)
# Send connection init
await websocket.send(json.dumps(build_connection_init()))
# Wait for ack
response = await websocket.recv()
@@ -203,7 +201,7 @@ def register_diagnostic_tools(mcp: FastMCP) -> None:
# Calculate WebSocket URL
ws_url_display: str | None = None
if UNRAID_API_URL:
if _settings.UNRAID_API_URL:
try:
ws_url_display = build_ws_url()
except ValueError:
@@ -215,8 +213,8 @@ def register_diagnostic_tools(mcp: FastMCP) -> None:
"environment": {
"auto_start_enabled": subscription_manager.auto_start_enabled,
"max_reconnect_attempts": subscription_manager.max_reconnect_attempts,
"unraid_api_url": safe_display_url(UNRAID_API_URL),
"api_key_configured": bool(UNRAID_API_KEY),
"unraid_api_url": safe_display_url(_settings.UNRAID_API_URL),
"api_key_configured": bool(_settings.UNRAID_API_KEY),
"websocket_url": ws_url_display,
},
"subscriptions": status,

View File

@@ -15,11 +15,11 @@ from typing import Any
import websockets
from websockets.typing import Subprotocol
from ..config import settings as _settings
from ..config.logging import logger
from ..config.settings import UNRAID_API_KEY
from ..core.client import redact_sensitive
from ..core.types import SubscriptionData
from .utils import build_ws_ssl_context, build_ws_url
from .utils import build_connection_init, build_ws_ssl_context, build_ws_url
# Resource data size limits to prevent unbounded memory growth
@@ -250,7 +250,7 @@ class SubscriptionManager:
ws_url = build_ws_url()
logger.debug(f"[WEBSOCKET:{subscription_name}] Connecting to: {ws_url}")
logger.debug(
f"[WEBSOCKET:{subscription_name}] API Key present: {'Yes' if UNRAID_API_KEY else 'No'}"
f"[WEBSOCKET:{subscription_name}] API Key present: {'Yes' if _settings.UNRAID_API_KEY else 'No'}"
)
ssl_context = build_ws_ssl_context(ws_url)
@@ -284,13 +284,9 @@ class SubscriptionManager:
logger.debug(
f"[PROTOCOL:{subscription_name}] Initializing GraphQL-WS protocol..."
)
init_type = "connection_init"
init_payload: dict[str, Any] = {"type": init_type}
if UNRAID_API_KEY:
init_payload = build_connection_init()
if "payload" in init_payload:
logger.debug(f"[AUTH:{subscription_name}] Adding authentication payload")
# Use graphql-ws connectionParams format (direct key, not nested headers)
init_payload["payload"] = {"x-api-key": UNRAID_API_KEY}
else:
logger.warning(
f"[AUTH:{subscription_name}] No API key available for authentication"

View File

@@ -11,8 +11,6 @@ WebSocket per call. This is intentional: MCP tools are request-response.
Use the SubscriptionManager for long-lived monitoring resources.
"""
from __future__ import annotations
import asyncio
import json
from typing import Any
@@ -21,9 +19,8 @@ import websockets
from websockets.typing import Subprotocol
from ..config.logging import logger
from ..config.settings import UNRAID_API_KEY
from ..core.exceptions import ToolError
from .utils import build_ws_ssl_context, build_ws_url
from .utils import build_connection_init, build_ws_ssl_context, build_ws_url
async def subscribe_once(
@@ -50,10 +47,7 @@ async def subscribe_once(
sub_id = "snapshot-1"
# Handshake
init: dict[str, Any] = {"type": "connection_init"}
if UNRAID_API_KEY:
init["payload"] = {"x-api-key": UNRAID_API_KEY}
await ws.send(json.dumps(init))
await ws.send(json.dumps(build_connection_init()))
raw = await asyncio.wait_for(ws.recv(), timeout=timeout)
ack = json.loads(raw)
@@ -125,10 +119,7 @@ async def subscribe_collect(
proto = ws.subprotocol or "graphql-transport-ws"
sub_id = "snapshot-1"
init: dict[str, Any] = {"type": "connection_init"}
if UNRAID_API_KEY:
init["payload"] = {"x-api-key": UNRAID_API_KEY}
await ws.send(json.dumps(init))
await ws.send(json.dumps(build_connection_init()))
raw = await asyncio.wait_for(ws.recv(), timeout=timeout)
ack = json.loads(raw)

View File

@@ -3,11 +3,11 @@
import ssl as _ssl
from typing import Any
from ..config.settings import UNRAID_API_URL, UNRAID_VERIFY_SSL
from ..config import settings as _settings
def build_ws_url() -> str:
"""Build a WebSocket URL from the configured UNRAID_API_URL.
"""Build a WebSocket URL from the configured UNRAID_API_URL setting.
Converts http(s) scheme to ws(s) and ensures /graphql path suffix.
@@ -17,19 +17,19 @@ def build_ws_url() -> str:
Raises:
ValueError: If UNRAID_API_URL is not configured or has an unrecognised scheme.
"""
if not UNRAID_API_URL:
if not _settings.UNRAID_API_URL:
raise ValueError("UNRAID_API_URL is not configured")
if UNRAID_API_URL.startswith("https://"):
ws_url = "wss://" + UNRAID_API_URL[len("https://") :]
elif UNRAID_API_URL.startswith("http://"):
ws_url = "ws://" + UNRAID_API_URL[len("http://") :]
elif UNRAID_API_URL.startswith(("ws://", "wss://")):
ws_url = UNRAID_API_URL # Already a WebSocket URL
if _settings.UNRAID_API_URL.startswith("https://"):
ws_url = "wss://" + _settings.UNRAID_API_URL[len("https://") :]
elif _settings.UNRAID_API_URL.startswith("http://"):
ws_url = "ws://" + _settings.UNRAID_API_URL[len("http://") :]
elif _settings.UNRAID_API_URL.startswith(("ws://", "wss://")):
ws_url = _settings.UNRAID_API_URL # Already a WebSocket URL
else:
raise ValueError(
f"UNRAID_API_URL must start with http://, https://, ws://, or wss://. "
f"Got: {UNRAID_API_URL[:20]}..."
f"Got: {_settings.UNRAID_API_URL[:20]}..."
)
if not ws_url.endswith("/graphql"):
@@ -49,9 +49,9 @@ def build_ws_ssl_context(ws_url: str) -> _ssl.SSLContext | None:
"""
if not ws_url.startswith("wss://"):
return None
if isinstance(UNRAID_VERIFY_SSL, str):
return _ssl.create_default_context(cafile=UNRAID_VERIFY_SSL)
if UNRAID_VERIFY_SSL:
if isinstance(_settings.UNRAID_VERIFY_SSL, str):
return _ssl.create_default_context(cafile=_settings.UNRAID_VERIFY_SSL)
if _settings.UNRAID_VERIFY_SSL:
return _ssl.create_default_context()
# Explicitly disable verification (equivalent to verify=False)
ctx = _ssl.SSLContext(_ssl.PROTOCOL_TLS_CLIENT)
@@ -60,6 +60,18 @@ def build_ws_ssl_context(ws_url: str) -> _ssl.SSLContext | None:
return ctx
def build_connection_init() -> dict[str, Any]:
"""Build the graphql-ws connection_init message.
Omits the payload key entirely when no API key is configured —
sending {"x-api-key": None} and omitting the key differ for some servers.
"""
msg: dict[str, Any] = {"type": "connection_init"}
if _settings.UNRAID_API_KEY:
msg["payload"] = {"x-api-key": _settings.UNRAID_API_KEY}
return msg
def _analyze_subscription_status(
status: dict[str, Any],
) -> tuple[int, list[dict[str, Any]]]:

View File

@@ -21,7 +21,6 @@ Actions:
live - Real-time WebSocket subscription snapshots (11 subactions)
"""
import asyncio
import datetime
import os
import re
@@ -32,7 +31,7 @@ from fastmcp import Context, FastMCP
from ..config.logging import logger
from ..core.client import DISK_TIMEOUT, make_graphql_request
from ..core.exceptions import ToolError, tool_error_handler
from ..core.exceptions import CredentialsNotConfiguredError, ToolError, tool_error_handler
from ..core.guards import gate_destructive_action
from ..core.setup import elicit_and_configure, elicit_reset_confirmation
from ..core.utils import format_bytes, format_kb, safe_get
@@ -110,7 +109,7 @@ _SYSTEM_QUERIES: dict[str, str] = {
"servers": "query GetServers { servers { id name status wanip lanip localurl remoteurl } }",
"flash": "query GetFlash { flash { id vendor product } }",
"ups_devices": "query GetUpsDevices { upsDevices { id name model status battery { chargeLevel estimatedRuntime health } power { loadPercentage inputVoltage outputVoltage } } }",
"ups_device": "query GetUpsDevice($id: String!) { upsDeviceById(id: $id) { id name model status battery { chargeLevel estimatedRuntime health } power { loadPercentage inputVoltage outputVoltage nominalPower currentPower } } }",
"ups_device": "query GetUpsDevice($id: String!) { upsDeviceById(id: $id) { id name model status battery { chargeLevel estimatedRuntime health } power { loadPercentage inputVoltage outputVoltage } } }",
"ups_config": "query GetUpsConfig { upsConfiguration { service upsCable upsType device batteryLevel minutes timeout killUps upsName } }",
}
@@ -285,6 +284,16 @@ async def _handle_system(subaction: str, device_id: str | None) -> dict[str, Any
# ===========================================================================
_HEALTH_SUBACTIONS: set[str] = {"check", "test_connection", "diagnose", "setup"}
_HEALTH_QUERIES: dict[str, str] = {
"comprehensive_health": (
"query ComprehensiveHealthCheck {"
" info { machineId time versions { core { unraid } } os { uptime } }"
" array { state }"
" notifications { overview { unread { alert warning total } } }"
" docker { containers(skipCache: true) { id state status } }"
" }"
),
}
_SEVERITY = {"healthy": 0, "warning": 1, "degraded": 2, "unhealthy": 3}
@@ -346,7 +355,8 @@ async def _handle_health(subaction: str, ctx: Context | None) -> dict[str, Any]
return await _comprehensive_health_check()
if subaction == "diagnose":
from ..server import cache_middleware, error_middleware
from ..server import _cache_middleware as cache_middleware
from ..server import _error_middleware as error_middleware
from ..subscriptions.manager import subscription_manager
from ..subscriptions.resources import ensure_subscriptions_started
@@ -373,7 +383,7 @@ async def _handle_health(subaction: str, ctx: Context | None) -> dict[str, Any]
"call_tool": {
"hits": cache_stats.call_tool.get.hit,
"misses": cache_stats.call_tool.get.miss,
"puts": cache_stats.call_tool.put.total,
"puts": cache_stats.call_tool.put.count,
}
if cache_stats.call_tool
else {"hits": 0, "misses": 0, "puts": 0},
@@ -403,15 +413,7 @@ async def _comprehensive_health_check() -> dict[str, Any]:
health_severity = max(health_severity, _SEVERITY.get(level, 0))
try:
query = """
query ComprehensiveHealthCheck {
info { machineId time versions { core { unraid } } os { uptime } }
array { state }
notifications { overview { unread { alert warning total } } }
docker { containers(skipCache: true) { id state status } }
}
"""
data = await make_graphql_request(query)
data = await make_graphql_request(_HEALTH_QUERIES["comprehensive_health"])
api_latency = round((time.time() - start_time) * 1000, 2)
health_info: dict[str, Any] = {
@@ -502,6 +504,8 @@ async def _comprehensive_health_check() -> dict[str, Any]:
}
return health_info
except CredentialsNotConfiguredError:
raise # Let tool_error_handler convert to setup instructions
except Exception as e:
logger.error(f"Health check failed: {e}", exc_info=True)
return {
@@ -622,10 +626,27 @@ _DISK_MUTATIONS: dict[str, str] = {
_DISK_SUBACTIONS: set[str] = set(_DISK_QUERIES) | set(_DISK_MUTATIONS)
_DISK_DESTRUCTIVE: set[str] = {"flash_backup"}
_ALLOWED_LOG_PREFIXES = ("/var/log/", "/boot/logs/", "/mnt/")
_ALLOWED_LOG_PREFIXES = ("/var/log/", "/boot/logs/")
_MAX_TAIL_LINES = 10_000
def _validate_path(path: str, allowed_prefixes: tuple[str, ...], label: str) -> str:
"""Validate a remote path string for traversal and allowed prefix.
Uses pure string normalization — no filesystem access. The path is validated
locally but consumed on the remote Unraid server, so realpath would resolve
against the wrong filesystem.
Returns the normalized path. Raises ToolError on any violation.
"""
if ".." in path:
raise ToolError(f"{label} must not contain path traversal sequences (../)")
normalized = os.path.normpath(path)
if not any(normalized.startswith(p) for p in allowed_prefixes):
raise ToolError(f"{label} must start with one of: {', '.join(allowed_prefixes)}")
return normalized
async def _handle_disk(
subaction: str,
disk_id: str | None,
@@ -659,10 +680,7 @@ async def _handle_disk(
raise ToolError(f"tail_lines must be between 1 and {_MAX_TAIL_LINES}, got {tail_lines}")
if not log_path:
raise ToolError("log_path is required for disk/logs")
normalized = await asyncio.to_thread(os.path.realpath, log_path)
if not any(normalized.startswith(p) for p in _ALLOWED_LOG_PREFIXES):
raise ToolError(f"log_path must start with one of: {', '.join(_ALLOWED_LOG_PREFIXES)}")
log_path = normalized
log_path = _validate_path(log_path, _ALLOWED_LOG_PREFIXES, "log_path")
if subaction == "flash_backup":
if not remote_name:
@@ -671,6 +689,15 @@ async def _handle_disk(
raise ToolError("source_path is required for disk/flash_backup")
if not destination_path:
raise ToolError("destination_path is required for disk/flash_backup")
# Validate paths — flash backup source must come from /boot/ only
if ".." in source_path:
raise ToolError("source_path must not contain path traversal sequences (../)")
normalized = os.path.normpath(source_path) # noqa: ASYNC240 — pure string, no I/O
if not (normalized == "/boot" or normalized.startswith("/boot/")):
raise ToolError("source_path must start with /boot/ (flash drive only)")
source_path = normalized
if ".." in destination_path:
raise ToolError("destination_path must not contain path traversal sequences (../)")
input_data: dict[str, Any] = {
"remoteName": remote_name,
"sourcePath": source_path,
@@ -738,9 +765,13 @@ _DOCKER_QUERIES: dict[str, str] = {
"details": "query GetContainerDetails { docker { containers(skipCache: false) { id names image imageId command created ports { ip privatePort publicPort type } sizeRootFs labels state status hostConfig { networkMode } networkSettings mounts autoStart } } }",
"networks": "query GetDockerNetworks { docker { networks { id name driver scope } } }",
"network_details": "query GetDockerNetwork { docker { networks { id name driver scope enableIPv6 internal attachable containers options labels } } }",
"_resolve": "query ResolveContainerID { docker { containers(skipCache: true) { id names } } }",
}
# Internal query used only for container ID resolution — not a public subaction.
_DOCKER_RESOLVE_QUERY = (
"query ResolveContainerID { docker { containers(skipCache: true) { id names } } }"
)
_DOCKER_MUTATIONS: dict[str, str] = {
"start": "mutation StartContainer($id: PrefixedID!) { docker { start(id: $id) { id names state status } } }",
"stop": "mutation StopContainer($id: PrefixedID!) { docker { stop(id: $id) { id names state status } } }",
@@ -775,7 +806,7 @@ def _find_container(
async def _resolve_container_id(container_id: str, *, strict: bool = False) -> str:
if _DOCKER_ID_PATTERN.match(container_id):
return container_id
data = await make_graphql_request(_DOCKER_QUERIES["_resolve"])
data = await make_graphql_request(_DOCKER_RESOLVE_QUERY)
containers = safe_get(data, "docker", "containers", default=[])
if _DOCKER_SHORT_ID_PATTERN.match(container_id):
id_lower = container_id.lower()
@@ -1622,11 +1653,12 @@ async def _handle_user(subaction: str) -> dict[str, Any]:
# LIVE (subscriptions)
# ===========================================================================
_LIVE_ALLOWED_LOG_PREFIXES = ("/var/log/", "/boot/logs/", "/mnt/")
async def _handle_live(
subaction: str, path: str | None, collect_for: float, timeout: float
subaction: str,
path: str | None,
collect_for: float,
timeout: float, # noqa: ASYNC109
) -> dict[str, Any]:
from ..subscriptions.queries import COLLECT_ACTIONS, EVENT_DRIVEN_ACTIONS, SNAPSHOT_ACTIONS
from ..subscriptions.snapshot import subscribe_collect, subscribe_once
@@ -1640,10 +1672,7 @@ async def _handle_live(
if subaction == "log_tail":
if not path:
raise ToolError("path is required for live/log_tail")
normalized = os.path.realpath(path) # noqa: ASYNC240
if not any(normalized.startswith(p) for p in _LIVE_ALLOWED_LOG_PREFIXES):
raise ToolError(f"path must start with one of: {', '.join(_LIVE_ALLOWED_LOG_PREFIXES)}")
path = normalized
path = _validate_path(path, _ALLOWED_LOG_PREFIXES, "path")
with tool_error_handler("live", subaction, logger):
logger.info(f"Executing unraid action=live subaction={subaction} timeout={timeout}")
@@ -1722,7 +1751,7 @@ UNRAID_ACTIONS = Literal[
def register_unraid_tool(mcp: FastMCP) -> None:
"""Register the single `unraid` tool with the FastMCP instance."""
@mcp.tool()
@mcp.tool(timeout=120)
async def unraid(
action: UNRAID_ACTIONS,
subaction: str,
@@ -1780,7 +1809,7 @@ def register_unraid_tool(mcp: FastMCP) -> None:
# live
path: str | None = None,
collect_for: float = 5.0,
timeout: float = 10.0,
timeout: float = 10.0, # noqa: ASYNC109
) -> dict[str, Any] | str:
"""Interact with an Unraid server's GraphQL API.
@@ -1797,7 +1826,7 @@ def register_unraid_tool(mcp: FastMCP) -> None:
│ health │ check, test_connection, diagnose, setup │
├─────────────────┼──────────────────────────────────────────────────────────────────────┤
│ array │ parity_status, parity_history, parity_start, parity_pause, │
│ │ parity_resume, parity_cancel, start_array*, stop_array*, │
│ │ parity_resume, parity_cancel, start_array, stop_array*,
│ │ add_disk, remove_disk*, mount_disk, unmount_disk, clear_disk_stats* │
├─────────────────┼──────────────────────────────────────────────────────────────────────┤
│ disk │ shares, disks, disk_details, log_files, logs, flash_backup* │

2
uv.lock generated
View File

@@ -1572,7 +1572,7 @@ wheels = [
[[package]]
name = "unraid-mcp"
version = "1.0.0"
version = "1.1.2"
source = { editable = "." }
dependencies = [
{ name = "fastapi" },