mirror of
https://github.com/jmagar/unraid-mcp.git
synced 2026-03-23 12:39:24 -07:00
Compare commits
17 Commits
refactor/c
...
feat/googl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e68d4a80e4 | ||
|
|
dc1e5f18d8 | ||
|
|
2b777be927 | ||
|
|
d59f8c22a8 | ||
|
|
cc24f1ec62 | ||
|
|
6f7a58a0f9 | ||
|
|
440245108a | ||
|
|
9754261402 | ||
|
|
9e9915b2fa | ||
|
|
2ab61be2df | ||
|
|
b319cf4932 | ||
|
|
0f46cb9713 | ||
|
|
1248ccd53e | ||
|
|
4a1ffcfd51 | ||
|
|
f69aa94826 | ||
|
|
5187cf730f | ||
|
|
896fc8db1b |
@@ -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"
|
||||
|
||||
46
.env.example
46
.env.example
@@ -34,4 +34,48 @@ 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
|
||||
# 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
79
.github/workflows/ci.yml
vendored
Normal 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
135
CHANGELOG.md
Normal 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/).*
|
||||
69
CLAUDE.md
69
CLAUDE.md
@@ -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)
|
||||
|
||||
83
README.md
83
README.md
@@ -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
188
docs/AUTHENTICATION.md
Normal 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
23
fastmcp.http.json
Normal 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
20
fastmcp.stdio.json
Normal 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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
155
tests/test_api_key_auth.py
Normal 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
115
tests/test_auth_builder.py
Normal 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
|
||||
91
tests/test_auth_settings.py
Normal file
91
tests/test_auth_settings.py
Normal 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()
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"}}
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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..."
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]]]:
|
||||
|
||||
@@ -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* │
|
||||
|
||||
Reference in New Issue
Block a user