From e548f6e6c981130e04a49100ba65386805d866b0 Mon Sep 17 00:00:00 2001 From: Jacob Magar Date: Tue, 24 Mar 2026 19:22:27 -0400 Subject: [PATCH] refactor: remove Docker and HTTP transport support, fix hypothesis cache directory --- .claude-plugin/README.md | 2 +- .claude-plugin/marketplace.json | 8 +- .claude-plugin/plugin.json | 2 +- .dockerignore | 31 -- .env.example | 51 +-- .github/workflows/ci.yml | 4 +- CHANGELOG.md | 6 + CLAUDE.md | 90 ++-- Dockerfile | 48 --- README.md | 140 +----- docker-compose.yml | 49 --- docs/AUTHENTICATION.md | 188 -------- docs/DESTRUCTIVE_ACTIONS.md | 54 +-- docs/MARKETPLACE.md | 163 ++++--- docs/PUBLISHING.md | 28 +- fastmcp.http.json | 23 - fastmcp.stdio.json | 20 - scripts/validate-marketplace.sh | 2 +- tests/conftest.py | 6 + tests/http_layer/test_request_construction.py | 21 +- tests/mcporter/README.md | 31 +- tests/mcporter/test-actions.sh | 407 ------------------ tests/mcporter/test-destructive.sh | 28 +- tests/mcporter/test-tools.sh | 7 +- tests/test_api_key_auth.py | 155 ------- tests/test_auth_builder.py | 115 ----- tests/test_auth_settings.py | 91 ---- tests/test_guards.py | 30 +- tests/test_health.py | 3 +- tests/test_keys.py | 5 +- tests/test_rclone.py | 9 +- tests/test_resources.py | 2 + tests/test_setup.py | 34 +- tests/test_storage.py | 5 + unraid_mcp/config/settings.py | 46 +- unraid_mcp/core/setup.py | 13 +- unraid_mcp/server.py | 167 ++----- unraid_mcp/subscriptions/resources.py | 13 +- unraid_mcp/tools/unraid.py | 29 +- 39 files changed, 369 insertions(+), 1757 deletions(-) delete mode 100644 .dockerignore delete mode 100644 Dockerfile delete mode 100644 docker-compose.yml delete mode 100644 docs/AUTHENTICATION.md delete mode 100644 fastmcp.http.json delete mode 100644 fastmcp.stdio.json delete mode 100755 tests/mcporter/test-actions.sh delete mode 100644 tests/test_api_key_auth.py delete mode 100644 tests/test_auth_builder.py delete mode 100644 tests/test_auth_settings.py diff --git a/.claude-plugin/README.md b/.claude-plugin/README.md index f4136ac..65d7fb1 100644 --- a/.claude-plugin/README.md +++ b/.claude-plugin/README.md @@ -41,7 +41,7 @@ Query and monitor Unraid servers via GraphQL API - array status, disk health, co - Notification management - Plugin, rclone, API key, and OIDC management -**Version:** 1.0.0 +**Version:** 1.1.2 **Category:** Infrastructure **Tags:** unraid, monitoring, homelab, graphql, docker, virtualization diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index f4ee2cc..ec9c534 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -1,12 +1,12 @@ { - "name": "jmagar-unraid-mcp", + "name": "unraid-mcp", "owner": { "name": "jmagar", "email": "jmagar@users.noreply.github.com" }, "metadata": { "description": "Comprehensive Unraid server management and monitoring via a single consolidated MCP tool (~108 actions across 15 domains)", - "version": "1.0.0", + "version": "1.1.2", "homepage": "https://github.com/jmagar/unraid-mcp", "repository": "https://github.com/jmagar/unraid-mcp" }, @@ -14,8 +14,8 @@ { "name": "unraid", "source": "./", - "description": "Query and monitor Unraid servers via GraphQL API — single `unraid` tool with action+subaction routing for array, disk, docker, VM, notifications, live metrics, and more", - "version": "1.0.0", + "description": "Query, monitor, and manage Unraid servers via GraphQL API — single `unraid` tool with action+subaction routing for array, disk, docker, VM, notifications, live metrics, and more", + "version": "1.1.2", "tags": ["unraid", "monitoring", "homelab", "graphql", "docker", "virtualization"], "category": "infrastructure" } diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 1b399f7..3822ebb 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "unraid", - "description": "Query and monitor Unraid servers via GraphQL API - array status, disk health, containers, VMs, system monitoring", + "description": "Query, monitor, and manage Unraid servers via GraphQL API - array status, disk health, containers, VMs, system monitoring", "version": "1.1.2", "author": { "name": "jmagar", diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index a5368a7..0000000 --- a/.dockerignore +++ /dev/null @@ -1,31 +0,0 @@ -Dockerfile -.dockerignore -.git -.gitignore -__pycache__ -*.pyc -*.pyo -*.pyd -.env -.env.local -.env.* -*.log -logs/ -*.db -*.sqlite3 -instance/ -.pytest_cache/ -.ty_cache/ -.venv/ -venv/ -env/ -.vscode/ -cline_docs/ -tests/ -docs/ -scripts/ -commands/ -.full-review/ -.claude-plugin/ -*.md -!README.md diff --git a/.env.example b/.env.example index 0c982a4..5ee55ab 100644 --- a/.env.example +++ b/.env.example @@ -8,7 +8,10 @@ UNRAID_API_KEY=your_unraid_api_key # MCP Server Settings # ------------------- -UNRAID_MCP_TRANSPORT=streamable-http # Options: streamable-http (recommended), sse (deprecated), stdio +# Default transport is stdio (for Claude Desktop / local use). +# Docker Compose overrides this to streamable-http automatically. +# Options: stdio (default), streamable-http, sse (deprecated) +UNRAID_MCP_TRANSPORT=stdio UNRAID_MCP_HOST=0.0.0.0 UNRAID_MCP_PORT=6970 @@ -41,41 +44,15 @@ UNRAID_MAX_RECONNECT_ATTEMPTS=10 # 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). +# Authentication +# -------------- +# This server has NO built-in authentication. +# When running as HTTP (streamable-http transport), protect the endpoint with +# an external OAuth gateway or identity-aware proxy: # -# Setup: -# 1. Google Cloud Console → APIs & Services → Credentials -# 2. Create OAuth 2.0 Client ID (Web application) -# 3. Authorized redirect URIs: /auth/callback -# 4. Copy Client ID and Client Secret below +# Reverse proxy with auth: nginx + OAuth2-proxy, Caddy + forward auth +# Identity-aware proxy: Authelia, Authentik, Pomerium +# Network isolation: bind to 127.0.0.1, use VPN/Tailscale for access +# Firewall rules: restrict source IPs at the network layer # -# 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= - -# API Key Authentication (Optional) -# ----------------------------------- -# Alternative to Google OAuth — clients present this key as a bearer token: -# Authorization: Bearer -# -# 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= \ No newline at end of file +# stdio transport (default) is inherently local — no network exposure. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 688b0d6..d8e3dd1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,9 +45,7 @@ jobs: 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 + - name: Run tests with coverage (excluding integration/slow) run: uv run pytest -m "not slow and not integration" --cov=unraid_mcp --cov-report=term-missing --tb=short -q version-sync: diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ad7482..1895c77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,12 @@ All notable changes to this project are documented here. - 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`) +### Refactored +- **Path validation**: Extracted `_validate_path()` in `unraid.py` — consolidates traversal check, `normpath`, and prefix validation used by both `disk/logs` and `live/log_tail` into one place; eliminates duplication +- **WebSocket auth payload**: Extracted `build_connection_init()` in `subscriptions/utils.py` — removes 4 duplicate `connection_init` blocks from `snapshot.py` (×2), `manager.py`, and `diagnostics.py`; also fixes a bug in `diagnostics.py` where `x-api-key: None` was sent when no API key was configured +- Removed `_LIVE_ALLOWED_LOG_PREFIXES` alias — direct reference to `_ALLOWED_LOG_PREFIXES` +- Moved `import hmac` to module level in `server.py` (was inside `verify_token` hot path) + --- ## [1.1.1] - 2026-03-16 diff --git a/CLAUDE.md b/CLAUDE.md index e95abd3..4e5e34b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,21 +38,6 @@ uv run ty check unraid_mcp/ uv run pytest ``` -### Docker Development -```bash -# Build the Docker image -docker build -t unraid-mcp-server . - -# Run with Docker Compose -docker compose up -d - -# View logs -docker compose logs -f unraid-mcp - -# Stop service -docker compose down -``` - ### Environment Setup Copy `.env.example` to `.env` and configure: @@ -61,9 +46,6 @@ Copy `.env.example` to `.env` and configure: - `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) @@ -77,36 +59,6 @@ Copy `.env.example` to `.env` and configure: **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: `/auth/callback` - -**API Key** — clients present as `Authorization: Bearer `: - -| 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 ### Core Components @@ -114,10 +66,13 @@ python3 -c "import secrets; print(secrets.token_hex(32))" - **Entry Point**: `unraid_mcp/main.py` - Application entry point and startup logic - **Configuration**: `unraid_mcp/config/` - Settings management and logging configuration - **Core Infrastructure**: `unraid_mcp/core/` - GraphQL client, exceptions, and shared types + - `guards.py` — destructive action gating via MCP elicitation + - `utils.py` — shared helpers (`safe_get`, `safe_display_url`, path validation) + - `setup.py` — elicitation-based credential setup flow - **Subscriptions**: `unraid_mcp/subscriptions/` - Real-time WebSocket subscriptions and diagnostics - **Tools**: `unraid_mcp/tools/` - Domain-specific tool implementations - **GraphQL Client**: Uses httpx for async HTTP requests to Unraid API -- **Transport Layer**: Supports streamable-http (recommended), SSE (deprecated), and stdio +- **Version Helper**: `unraid_mcp/version.py` - Reads version from package metadata via importlib ### Key Design Patterns - **Consolidated Action Pattern**: Each tool uses `action: Literal[...]` parameter to expose multiple operations via a single MCP tool, reducing context window usage @@ -165,26 +120,20 @@ The server registers **3 MCP tools**: ### Destructive Actions (require `confirm=True`) - **array**: stop_array, remove_disk, clear_disk_stats - **vm**: force_stop, reset -- **notifications**: delete, delete_archived +- **notification**: delete, delete_archived - **rclone**: delete_remote -- **keys**: delete +- **key**: delete - **disk**: flash_backup -- **settings**: configure_ups -- **plugins**: remove +- **setting**: configure_ups +- **plugin**: remove ### Environment Variable Hierarchy The server loads environment variables from multiple locations in order: 1. `~/.unraid-mcp/.env` (primary — canonical credentials dir, all runtimes) 2. `~/.unraid-mcp/.env.local` (local overrides, only used if primary is absent) -3. `/app/.env.local` (Docker container mount) -4. `../.env.local` (project root local overrides) -5. `../.env` (project root fallback) -6. `unraid_mcp/.env` (last resort) - -### Transport Configuration -- **streamable-http** (recommended): HTTP-based transport on `/mcp` endpoint -- **sse** (deprecated): Server-Sent Events transport -- **stdio**: Standard input/output for direct integration +3. `../.env.local` (project root local overrides) +4. `../.env` (project root fallback) +5. `unraid_mcp/.env` (last resort) ### Error Handling Strategy - GraphQL errors are converted to ToolError with descriptive messages @@ -192,6 +141,14 @@ The server loads environment variables from multiple locations in order: - Network errors are caught and wrapped with connection context - All errors are logged with full context for debugging +### Middleware Chain +`server.py` wraps all tools in a 5-layer stack (order matters — outermost first): +1. **LoggingMiddleware** — logs every `tools/call` and `resources/read` with duration +2. **ErrorHandlingMiddleware** — converts unhandled exceptions to proper MCP errors +3. **SlidingWindowRateLimitingMiddleware** — 540 req/min sliding window +4. **ResponseLimitingMiddleware** — truncates responses > 512 KB with a clear suffix +5. **ResponseCachingMiddleware** — caching disabled entirely for `unraid` tool (mutations and reads share one tool name, so no per-subaction exclusion is possible) + ### Performance Considerations - Increased timeouts for disk operations (90s read timeout) - Selective queries to avoid GraphQL type overflow issues @@ -216,7 +173,9 @@ tests/ ├── http_layer/ # httpx-level request/response tests (respx) ├── integration/ # WebSocket subscription lifecycle tests (slow) ├── safety/ # Destructive action guard tests -└── schema/ # GraphQL query validation (99 tests, all passing) +├── schema/ # GraphQL query validation (119 tests) +├── contract/ # Response shape contract tests +└── property/ # Input validation property-based tests ``` ### Running Targeted Tests @@ -244,6 +203,8 @@ See `tests/mcporter/README.md` for transport differences and `docs/DESTRUCTIVE_A ### API Reference Docs - `docs/UNRAID_API_COMPLETE_REFERENCE.md` — Full GraphQL schema reference - `docs/UNRAID_API_OPERATIONS.md` — All supported operations with examples +- `docs/MARKETPLACE.md` — Plugin marketplace listing and publishing guide +- `docs/PUBLISHING.md` — Step-by-step instructions for publishing to Claude plugin registry Use these when adding new queries/mutations. @@ -253,12 +214,11 @@ When bumping the version, **always update both files** — they must stay in syn - `.claude-plugin/plugin.json` → `"version": "X.Y.Z"` ### Credential Storage (`~/.unraid-mcp/.env`) -All runtimes (plugin, direct, Docker) load credentials from `~/.unraid-mcp/.env`. +All runtimes (plugin, direct `uv run`) load credentials from `~/.unraid-mcp/.env`. - **Plugin/direct:** `unraid action=health subaction=setup` writes this file automatically via elicitation, **Safe to re-run**: always prompts for confirmation before overwriting existing credentials, whether the connection is working or not (failed probe may be a transient outage, not bad creds). or manual: `mkdir -p ~/.unraid-mcp && cp .env.example ~/.unraid-mcp/.env` then edit. -- **Docker:** `docker-compose.yml` loads it via `env_file` before container start. - **No symlinks needed.** Version bumps do not affect this path. - **Permissions:** dir=700, file=600 (set automatically by elicitation; set manually if using `cp`: `chmod 700 ~/.unraid-mcp && chmod 600 ~/.unraid-mcp/.env`). diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index f8751d2..0000000 --- a/Dockerfile +++ /dev/null @@ -1,48 +0,0 @@ -# Use an official Python runtime as a parent image -FROM python:3.12-slim - -# Set the working directory in the container -WORKDIR /app - -# Install uv (pinned tag to avoid mutable latest) -COPY --from=ghcr.io/astral-sh/uv:0.9.25 /uv /uvx /usr/local/bin/ - -# Create non-root user with home directory and give ownership of /app -RUN groupadd --gid 1000 appuser && \ - useradd --uid 1000 --gid 1000 --create-home --shell /bin/false appuser && \ - chown appuser:appuser /app - -# Copy dependency files (owned by appuser via --chown) -COPY --chown=appuser:appuser pyproject.toml . -COPY --chown=appuser:appuser uv.lock . -COPY --chown=appuser:appuser README.md . -COPY --chown=appuser:appuser LICENSE . - -# Copy the source code -COPY --chown=appuser:appuser unraid_mcp/ ./unraid_mcp/ - -# Switch to non-root user before installing dependencies -USER appuser - -# Install dependencies and the package -RUN uv sync --frozen - -# Make port UNRAID_MCP_PORT available to the world outside this container -# Defaulting to 6970, but can be overridden by environment variable -EXPOSE 6970 - -# Define environment variables (defaults, can be overridden at runtime) -ENV UNRAID_MCP_PORT=6970 -ENV UNRAID_MCP_HOST="0.0.0.0" -ENV UNRAID_MCP_TRANSPORT="streamable-http" -ENV UNRAID_API_URL="" -ENV UNRAID_API_KEY="" -ENV UNRAID_VERIFY_SSL="true" -ENV UNRAID_MCP_LOG_LEVEL="INFO" - -# Health check -HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ - CMD ["python", "-c", "import os, urllib.request; port = os.getenv('UNRAID_MCP_PORT', '6970'); urllib.request.urlopen(f'http://localhost:{port}/mcp')"] - -# Run unraid-mcp-server when the container launches -CMD ["uv", "run", "unraid-mcp-server"] diff --git a/README.md b/README.md index f124d1c..4547bb0 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,7 @@ - ⚡ **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**: Optional Google OAuth 2.0 authentication + SSL/TLS + API key management +- 🔒 **Secure**: Network-layer isolation - 📝 **Rich Logging**: Structured logging with rotation and multiple levels --- @@ -25,7 +24,7 @@ - [Quick Start](#-quick-start) - [Installation](#-installation) - [Configuration](#-configuration) -- [Google OAuth](#-google-oauth-optional) +- [Authentication](#-authentication) - [Available Tools & Resources](#-available-tools--resources) - [Development](#-development) - [Architecture](#-architecture) @@ -56,7 +55,7 @@ This provides instant access to Unraid monitoring and management through Claude ### ⚙️ Credential Setup Credentials are stored in `~/.unraid-mcp/.env` — one location that works for the -Claude Code plugin, direct `uv run` invocations, and Docker. +Claude Code plugin and direct `uv run` invocations. **Option 1 — Interactive (Claude Code plugin, elicitation-supported clients):** ``` @@ -74,9 +73,6 @@ cp .env.example ~/.unraid-mcp/.env && chmod 600 ~/.unraid-mcp/.env # UNRAID_API_KEY=your-key-from-unraid-settings ``` -**Docker:** `~/.unraid-mcp/.env` is loaded via `env_file` in `docker-compose.yml` — -same file, no duplication needed. - > **Finding your API key:** Unraid → Settings → Management Access → API Keys --- @@ -84,8 +80,7 @@ same file, no duplication needed. ## 🚀 Quick Start ### Prerequisites -- Docker and Docker Compose (recommended) -- OR Python 3.12+ with [uv](https://github.com/astral-sh/uv) for development +- Python 3.12+ with [uv](https://github.com/astral-sh/uv) for development - Unraid server with GraphQL API enabled ### 1. Clone Repository @@ -96,7 +91,7 @@ cd unraid-mcp ### 2. Configure Environment ```bash -# For Docker/production use — canonical credential location (all runtimes) +# Canonical credential location (all runtimes) mkdir -p ~/.unraid-mcp && chmod 700 ~/.unraid-mcp cp .env.example ~/.unraid-mcp/.env && chmod 600 ~/.unraid-mcp/.env # Edit ~/.unraid-mcp/.env with your values @@ -105,16 +100,7 @@ cp .env.example ~/.unraid-mcp/.env && chmod 600 ~/.unraid-mcp/.env cp .env.example .env ``` -### 3. Deploy with Docker (Recommended) -```bash -# Start with Docker Compose -docker compose up -d - -# View logs -docker compose logs -f unraid-mcp -``` - -### OR 3. Run for Development +### 3. Run for Development ```bash # Install dependencies uv sync @@ -148,38 +134,6 @@ unraid-mcp/ # ${CLAUDE_PLUGIN_ROOT} ## 📦 Installation -### 🐳 Docker Deployment (Recommended) - -The easiest way to run the Unraid MCP Server is with Docker: - -```bash -# Clone repository -git clone https://github.com/jmagar/unraid-mcp -cd unraid-mcp - -# Set required environment variables -export UNRAID_API_URL="http://your-unraid-server/graphql" -export UNRAID_API_KEY="your_api_key_here" - -# Deploy with Docker Compose -docker compose up -d - -# View logs -docker compose logs -f unraid-mcp -``` - -#### Manual Docker Build -```bash -# Build and run manually -docker build -t unraid-mcp-server . -docker run -d --name unraid-mcp \ - --restart unless-stopped \ - -p 6970:6970 \ - -e UNRAID_API_URL="http://your-unraid-server/graphql" \ - -e UNRAID_API_KEY="your_api_key_here" \ - unraid-mcp-server -``` - ### 🔧 Development Installation For development and testing: @@ -209,7 +163,7 @@ uv run unraid-mcp-server ### Environment Variables -Create `.env` file in the project root: +Credentials and settings go in `~/.unraid-mcp/.env` (the canonical location loaded by all runtimes — plugin and direct `uv run`). See the [Credential Setup](#%EF%B8%8F-credential-setup) section above for how to create it. ```bash # Core API Configuration (Required) @@ -217,7 +171,7 @@ UNRAID_API_URL=https://your-unraid-server-url/graphql UNRAID_API_KEY=your_unraid_api_key # MCP Server Settings -UNRAID_MCP_TRANSPORT=streamable-http # streamable-http (recommended), sse (deprecated), stdio +UNRAID_MCP_TRANSPORT=stdio # stdio (default) UNRAID_MCP_HOST=0.0.0.0 UNRAID_MCP_PORT=6970 @@ -232,58 +186,15 @@ UNRAID_VERIFY_SSL=true # true, false, or path to CA bundle UNRAID_AUTO_START_SUBSCRIPTIONS=true # Auto-start WebSocket subscriptions on startup (default: true) UNRAID_MAX_RECONNECT_ATTEMPTS=10 # Max WebSocket reconnection attempts (default: 10) -# Optional: Log Stream Configuration -# UNRAID_AUTOSTART_LOG_PATH=/var/log/syslog # Override log path for unraid://logs/stream (auto-detects /var/log/syslog if unset) +# Optional: Auto-start log file subscription path +# Defaults to /var/log/syslog if it exists and this is unset +# UNRAID_AUTOSTART_LOG_PATH=/var/log/syslog + +# Optional: Credentials directory override (default: ~/.unraid-mcp/) +# Useful for containers or non-standard home directory layouts +# UNRAID_CREDENTIALS_DIR=/custom/path/to/credentials ``` -### Transport Options - -| Transport | Description | Use Case | -|-----------|-------------|----------| -| `streamable-http` | HTTP-based (recommended) | Most compatible, best performance | -| `sse` | Server-Sent Events (deprecated) | Legacy support only | -| `stdio` | Standard I/O | Direct integration scenarios | - ---- - -## 🔐 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: `/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 `. 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 @@ -388,8 +299,6 @@ unraid-mcp/ ├── skills/unraid/ # Claude skill assets ├── .claude-plugin/ # Plugin manifest & marketplace config ├── .env.example # Environment template -├── Dockerfile # Container image definition -├── docker-compose.yml # Docker Compose deployment ├── pyproject.toml # Project config & dependencies └── logs/ # Log files (auto-created, gitignored) ``` @@ -409,17 +318,14 @@ uv run pytest ### Integration Smoke-Tests (mcporter) -Live integration tests that exercise all non-destructive actions via [mcporter](https://github.com/mcporter/mcporter). Two scripts cover two transport modes: +Live integration tests that exercise all non-destructive actions via [mcporter](https://github.com/mcporter/mcporter). ```bash # stdio — no running server needed (good for CI) ./tests/mcporter/test-tools.sh [--parallel] [--timeout-ms N] [--verbose] - -# HTTP — connects to a live server (most up-to-date coverage) -./tests/mcporter/test-actions.sh [MCP_URL] # default: http://localhost:6970/mcp ``` -Destructive actions are always skipped in both scripts. For safe testing strategies and exact mcporter commands per destructive action, see [`docs/DESTRUCTIVE_ACTIONS.md`](docs/DESTRUCTIVE_ACTIONS.md). +Destructive actions are always skipped. For safe testing strategies and exact mcporter commands per destructive action, see [`docs/DESTRUCTIVE_ACTIONS.md`](docs/DESTRUCTIVE_ACTIONS.md). ### API Schema Docs Automation ```bash @@ -443,24 +349,12 @@ 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 diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index 98439c6..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,49 +0,0 @@ -services: - unraid-mcp: - build: - context: . - dockerfile: Dockerfile - container_name: unraid-mcp - restart: unless-stopped - read_only: true - cap_drop: - - ALL - tmpfs: - - /tmp:noexec,nosuid,size=64m - - /app/logs:noexec,nosuid,size=16m - - /app/.cache/logs:noexec,nosuid,size=8m - ports: - # HostPort:ContainerPort (maps to UNRAID_MCP_PORT inside the container, default 6970) - # Change the host port (left side) if 6970 is already in use on your host - - "${UNRAID_MCP_PORT:-6970}:${UNRAID_MCP_PORT:-6970}" - env_file: - - path: ${HOME}/.unraid-mcp/.env - required: false # Don't fail if file missing; environment: block below takes over - environment: - # Core API Configuration (Required) - # Sourced from ~/.unraid-mcp/.env via env_file above (if present), - # or set these directly here. The :? syntax fails fast if unset. - - UNRAID_API_URL=${UNRAID_API_URL:?UNRAID_API_URL is required} - - UNRAID_API_KEY=${UNRAID_API_KEY:?UNRAID_API_KEY is required} - - # MCP Server Settings - - UNRAID_MCP_PORT=${UNRAID_MCP_PORT:-6970} - - UNRAID_MCP_HOST=${UNRAID_MCP_HOST:-0.0.0.0} - - UNRAID_MCP_TRANSPORT=${UNRAID_MCP_TRANSPORT:-streamable-http} - - # SSL Configuration - - UNRAID_VERIFY_SSL=${UNRAID_VERIFY_SSL:-true} - - # Logging Configuration - - UNRAID_MCP_LOG_LEVEL=${UNRAID_MCP_LOG_LEVEL:-INFO} - - UNRAID_MCP_LOG_FILE=${UNRAID_MCP_LOG_FILE:-unraid-mcp.log} - - # Real-time Subscription Configuration - - UNRAID_AUTO_START_SUBSCRIPTIONS=${UNRAID_AUTO_START_SUBSCRIPTIONS:-true} - - UNRAID_MAX_RECONNECT_ATTEMPTS=${UNRAID_MAX_RECONNECT_ATTEMPTS:-10} - - # Optional: Custom log file path for subscription auto-start diagnostics - - UNRAID_AUTOSTART_LOG_PATH=${UNRAID_AUTOSTART_LOG_PATH} - # Optional: If you want to mount a specific directory for logs (ensure UNRAID_MCP_LOG_FILE points within this mount) - # volumes: - # - ./logs:/app/logs # Example: maps ./logs on host to /app/logs in container diff --git a/docs/AUTHENTICATION.md b/docs/AUTHENTICATION.md deleted file mode 100644 index 751b0f6..0000000 --- a/docs/AUTHENTICATION.md +++ /dev/null @@ -1,188 +0,0 @@ -# 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://:6970/auth/callback - ``` - Replace `` 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://: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 `/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 `/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 diff --git a/docs/DESTRUCTIVE_ACTIONS.md b/docs/DESTRUCTIVE_ACTIONS.md index b0b0913..041d007 100644 --- a/docs/DESTRUCTIVE_ACTIONS.md +++ b/docs/DESTRUCTIVE_ACTIONS.md @@ -1,14 +1,14 @@ # Destructive Actions -**Last Updated:** 2026-03-16 +**Last Updated:** 2026-03-24 **Total destructive actions:** 12 across 8 domains (single `unraid` tool) All destructive actions require `confirm=True` at the call site. There is no additional environment variable gate — `confirm` is the sole guard. -> **mcporter commands below** use `$MCP_URL` (default: `http://localhost:6970/mcp`). Run `test-actions.sh` for automated non-destructive coverage; destructive actions are always skipped there and tested manually per the strategies below. +> **mcporter commands below** use stdio transport. Run `test-tools.sh` for automated non-destructive coverage; destructive actions are always skipped there and tested manually per the strategies below. > > **Calling convention (v1.0.0+):** All operations use the single `unraid` tool with `action` (domain) + `subaction` (operation). For example: -> `mcporter call --http-url "$MCP_URL" --tool unraid --args '{"action":"docker","subaction":"list"}'` +> `mcporter call --stdio-cmd "uv run unraid-mcp-server" --tool unraid --args '{"action":"docker","subaction":"list"}'` --- @@ -26,7 +26,7 @@ Stopping the array unmounts all shares and can interrupt running containers and ```bash # Prerequisite: array must already be stopped; use a disk you intend to remove -mcporter call --http-url "$MCP_URL" --tool unraid \ +mcporter call --stdio-cmd "uv run unraid-mcp-server" --tool unraid \ --args '{"action":"array","subaction":"remove_disk","disk_id":"","confirm":true}' --output json ``` @@ -36,11 +36,11 @@ mcporter call --http-url "$MCP_URL" --tool unraid \ ```bash # Discover disk IDs -mcporter call --http-url "$MCP_URL" --tool unraid \ +mcporter call --stdio-cmd "uv run unraid-mcp-server" --tool unraid \ --args '{"action":"disk","subaction":"disks"}' --output json # Clear stats for a specific disk -mcporter call --http-url "$MCP_URL" --tool unraid \ +mcporter call --stdio-cmd "uv run unraid-mcp-server" --tool unraid \ --args '{"action":"array","subaction":"clear_disk_stats","disk_id":"","confirm":true}' --output json ``` @@ -54,15 +54,15 @@ mcporter call --http-url "$MCP_URL" --tool unraid \ # Prerequisite: create a minimal Alpine test VM in Unraid VM manager # (Alpine ISO, 512MB RAM, no persistent disk, name contains "mcp-test") -VID=$(mcporter call --http-url "$MCP_URL" --tool unraid \ +VID=$(mcporter call --stdio-cmd "uv run unraid-mcp-server" --tool unraid \ --args '{"action":"vm","subaction":"list"}' --output json \ | python3 -c "import json,sys; vms=json.load(sys.stdin).get('vms',[]); print(next(v.get('uuid',v.get('id','')) for v in vms if 'mcp-test' in v.get('name','')))") -mcporter call --http-url "$MCP_URL" --tool unraid \ +mcporter call --stdio-cmd "uv run unraid-mcp-server" --tool unraid \ --args "{\"action\":\"vm\",\"subaction\":\"force_stop\",\"vm_id\":\"$VID\",\"confirm\":true}" --output json # Verify: VM state should return to stopped -mcporter call --http-url "$MCP_URL" --tool unraid \ +mcporter call --stdio-cmd "uv run unraid-mcp-server" --tool unraid \ --args "{\"action\":\"vm\",\"subaction\":\"details\",\"vm_id\":\"$VID\"}" --output json ``` @@ -72,11 +72,11 @@ mcporter call --http-url "$MCP_URL" --tool unraid \ ```bash # Same minimal Alpine test VM as above -VID=$(mcporter call --http-url "$MCP_URL" --tool unraid \ +VID=$(mcporter call --stdio-cmd "uv run unraid-mcp-server" --tool unraid \ --args '{"action":"vm","subaction":"list"}' --output json \ | python3 -c "import json,sys; vms=json.load(sys.stdin).get('vms',[]); print(next(v.get('uuid',v.get('id','')) for v in vms if 'mcp-test' in v.get('name','')))") -mcporter call --http-url "$MCP_URL" --tool unraid \ +mcporter call --stdio-cmd "uv run unraid-mcp-server" --tool unraid \ --args "{\"action\":\"vm\",\"subaction\":\"reset\",\"vm_id\":\"$VID\",\"confirm\":true}" --output json ``` @@ -89,9 +89,9 @@ mcporter call --http-url "$MCP_URL" --tool unraid \ ```bash # 1. Create a test notification, then list to get the real stored ID (create response # ID is ULID-based; stored filename uses a unix timestamp, so IDs differ) -mcporter call --http-url "$MCP_URL" --tool unraid \ +mcporter call --stdio-cmd "uv run unraid-mcp-server" --tool unraid \ --args '{"action":"notification","subaction":"create","title":"mcp-test-delete","subject":"safe to delete","description":"MCP destructive action test","importance":"INFO"}' --output json -NID=$(mcporter call --http-url "$MCP_URL" --tool unraid \ +NID=$(mcporter call --stdio-cmd "uv run unraid-mcp-server" --tool unraid \ --args '{"action":"notification","subaction":"list","notification_type":"UNREAD"}' --output json \ | python3 -c " import json,sys @@ -100,11 +100,11 @@ matches=[n['id'] for n in reversed(notifs) if n.get('title')=='mcp-test-delete'] print(matches[0] if matches else '')") # 2. Delete it (notification_type required) -mcporter call --http-url "$MCP_URL" --tool unraid \ +mcporter call --stdio-cmd "uv run unraid-mcp-server" --tool unraid \ --args "{\"action\":\"notification\",\"subaction\":\"delete\",\"notification_id\":\"$NID\",\"notification_type\":\"UNREAD\",\"confirm\":true}" --output json # 3. Verify -mcporter call --http-url "$MCP_URL" --tool unraid \ +mcporter call --stdio-cmd "uv run unraid-mcp-server" --tool unraid \ --args '{"action":"notification","subaction":"list"}' --output json | python3 -c \ "import json,sys; ns=[n for n in json.load(sys.stdin).get('notifications',[]) if 'mcp-test' in n.get('title','')]; print('clean' if not ns else ns)" ``` @@ -115,21 +115,21 @@ mcporter call --http-url "$MCP_URL" --tool unraid \ ```bash # 1. Create and archive a test notification -mcporter call --http-url "$MCP_URL" --tool unraid \ +mcporter call --stdio-cmd "uv run unraid-mcp-server" --tool unraid \ --args '{"action":"notification","subaction":"create","title":"mcp-test-archive-wipe","subject":"archive me","description":"safe to delete","importance":"INFO"}' --output json -AID=$(mcporter call --http-url "$MCP_URL" --tool unraid \ +AID=$(mcporter call --stdio-cmd "uv run unraid-mcp-server" --tool unraid \ --args '{"action":"notification","subaction":"list","notification_type":"UNREAD"}' --output json \ | python3 -c " import json,sys notifs=json.load(sys.stdin).get('notifications',[]) matches=[n['id'] for n in reversed(notifs) if n.get('title')=='mcp-test-archive-wipe'] print(matches[0] if matches else '')") -mcporter call --http-url "$MCP_URL" --tool unraid \ +mcporter call --stdio-cmd "uv run unraid-mcp-server" --tool unraid \ --args "{\"action\":\"notification\",\"subaction\":\"archive\",\"notification_id\":\"$AID\"}" --output json # 2. Wipe all archived # NOTE: this deletes ALL archived notifications, not just the test one -mcporter call --http-url "$MCP_URL" --tool unraid \ +mcporter call --stdio-cmd "uv run unraid-mcp-server" --tool unraid \ --args '{"action":"notification","subaction":"delete_archived","confirm":true}' --output json ``` @@ -144,15 +144,15 @@ mcporter call --http-url "$MCP_URL" --tool unraid \ ```bash # 1. Create a throwaway local remote (points to /tmp — no real data) # Parameters: name (str), provider_type (str), config_data (dict) -mcporter call --http-url "$MCP_URL" --tool unraid \ +mcporter call --stdio-cmd "uv run unraid-mcp-server" --tool unraid \ --args '{"action":"rclone","subaction":"create_remote","name":"mcp-test-remote","provider_type":"local","config_data":{"root":"/tmp"}}' --output json # 2. Delete it -mcporter call --http-url "$MCP_URL" --tool unraid \ +mcporter call --stdio-cmd "uv run unraid-mcp-server" --tool unraid \ --args '{"action":"rclone","subaction":"delete_remote","name":"mcp-test-remote","confirm":true}' --output json # 3. Verify -mcporter call --http-url "$MCP_URL" --tool unraid \ +mcporter call --stdio-cmd "uv run unraid-mcp-server" --tool unraid \ --args '{"action":"rclone","subaction":"list_remotes"}' --output json | python3 -c \ "import json,sys; remotes=json.load(sys.stdin).get('remotes',[]); print('clean' if 'mcp-test-remote' not in remotes else 'FOUND — cleanup failed')" ``` @@ -167,16 +167,16 @@ mcporter call --http-url "$MCP_URL" --tool unraid \ ```bash # 1. Create a test key (names cannot contain hyphens; ID is at key.id) -KID=$(mcporter call --http-url "$MCP_URL" --tool unraid \ +KID=$(mcporter call --stdio-cmd "uv run unraid-mcp-server" --tool unraid \ --args '{"action":"key","subaction":"create","name":"mcp test key","roles":["VIEWER"]}' --output json \ | python3 -c "import json,sys; print(json.load(sys.stdin).get('key',{}).get('id',''))") # 2. Delete it -mcporter call --http-url "$MCP_URL" --tool unraid \ +mcporter call --stdio-cmd "uv run unraid-mcp-server" --tool unraid \ --args "{\"action\":\"key\",\"subaction\":\"delete\",\"key_id\":\"$KID\",\"confirm\":true}" --output json # 3. Verify -mcporter call --http-url "$MCP_URL" --tool unraid \ +mcporter call --stdio-cmd "uv run unraid-mcp-server" --tool unraid \ --args '{"action":"key","subaction":"list"}' --output json | python3 -c \ "import json,sys; ks=json.load(sys.stdin).get('keys',[]); print('clean' if not any('mcp test key' in k.get('name','') for k in ks) else 'FOUND — cleanup failed')" ``` @@ -191,7 +191,7 @@ mcporter call --http-url "$MCP_URL" --tool unraid \ # Prerequisite: create a dedicated test remote pointing away from real backup destination # (use rclone create_remote first, or configure mcp-test-remote manually) -mcporter call --http-url "$MCP_URL" --tool unraid \ +mcporter call --stdio-cmd "uv run unraid-mcp-server" --tool unraid \ --args '{"action":"disk","subaction":"flash_backup","remote_name":"mcp-test-remote","source_path":"/boot","destination_path":"/flash-backup-test","confirm":true}' --output json ``` @@ -217,7 +217,7 @@ Removing a plugin cannot be undone without a full re-install. Test via `tests/sa ```bash # If live testing is necessary (intentional removal only): -mcporter call --http-url "$MCP_URL" --tool unraid \ +mcporter call --stdio-cmd "uv run unraid-mcp-server" --tool unraid \ --args '{"action":"plugin","subaction":"remove","names":[""],"confirm":true}' --output json ``` diff --git a/docs/MARKETPLACE.md b/docs/MARKETPLACE.md index 54f1b92..e5f6679 100644 --- a/docs/MARKETPLACE.md +++ b/docs/MARKETPLACE.md @@ -11,37 +11,77 @@ The marketplace catalog that lists all available plugins in this repository. **Contents:** - Marketplace metadata (name, version, owner, repository) -- Plugin catalog with the "unraid" skill +- Plugin catalog with the "unraid" plugin - Categories and tags for discoverability ### 2. Plugin Manifest (`.claude-plugin/plugin.json`) -The individual plugin configuration for the Unraid skill. +The individual plugin configuration for the Unraid MCP server. **Location:** `.claude-plugin/plugin.json` **Contents:** -- Plugin name, version, author +- Plugin name (`unraid`), version (`1.1.2`), author - Repository and homepage links -- Plugin-specific metadata +- `mcpServers` block that configures the server to run via `uv run unraid-mcp-server` in stdio mode -### 3. Documentation -- `.claude-plugin/README.md` - Marketplace installation guide -- Updated root `README.md` with plugin installation section +### 3. Validation Script +- `scripts/validate-marketplace.sh` — Automated validation of marketplace structure -### 4. Validation Script -- `scripts/validate-marketplace.sh` - Automated validation of marketplace structure +## MCP Tools Exposed + +The plugin registers **3 MCP tools**: + +| Tool | Purpose | +|------|---------| +| `unraid` | Primary tool — `action` (domain) + `subaction` (operation) routing, ~107 subactions across 15 domains | +| `diagnose_subscriptions` | Inspect WebSocket subscription connection states and errors | +| `test_subscription_query` | Test a specific GraphQL subscription query (allowlisted fields only) | + +### Calling Convention + +All Unraid operations go through the single `unraid` tool: + +``` +unraid(action="docker", subaction="list") +unraid(action="system", subaction="overview") +unraid(action="array", subaction="parity_status") +unraid(action="vm", subaction="list") +unraid(action="live", subaction="cpu") +``` + +### Domains (action=) + +| action | example subactions | +|--------|--------------------| +| `system` | overview, array, network, metrics, services, ups_devices | +| `health` | check, test_connection, diagnose, setup | +| `array` | parity_status, parity_start, start_array, add_disk | +| `disk` | shares, disks, disk_details, logs | +| `docker` | list, details, start, stop, restart | +| `vm` | list, details, start, stop, pause, resume | +| `notification` | overview, list, create, archive, archive_all | +| `key` | list, get, create, update, delete | +| `plugin` | list, add, remove | +| `rclone` | list_remotes, config_form, create_remote | +| `setting` | update, configure_ups | +| `customization` | theme, set_theme, sso_enabled | +| `oidc` | providers, configuration, validate_session | +| `user` | me | +| `live` | cpu, memory, array_state, log_tail, notification_feed | + +Destructive subactions (e.g. `stop_array`, `force_stop`, `delete`) require `confirm=True`. ## Installation Methods ### Method 1: GitHub Distribution (Recommended for Users) -Once you push this to GitHub, users can install via: +Once pushed to GitHub, users install via: ```bash -# Add your marketplace +# Add the marketplace /plugin marketplace add jmagar/unraid-mcp -# Install the Unraid skill +# Install the Unraid plugin /plugin install unraid @unraid-mcp ``` @@ -59,7 +99,7 @@ For testing locally before publishing: ### Method 3: Direct URL -Users can also install from a specific commit or branch: +Install from a specific branch or commit: ```bash # From specific branch @@ -75,14 +115,14 @@ Users can also install from a specific commit or branch: unraid-mcp/ ├── .claude-plugin/ # Plugin manifest + marketplace manifest │ ├── plugin.json # Plugin configuration (name, version, mcpServers) -│ ├── marketplace.json # Marketplace catalog -│ └── README.md # Marketplace installation guide -├── skills/unraid/ # Skill documentation and helpers -│ ├── SKILL.md # Skill documentation -│ ├── README.md # Plugin documentation -│ ├── examples/ # Example scripts -│ ├── scripts/ # Helper scripts -│ └── references/ # API reference docs +│ └── marketplace.json # Marketplace catalog +├── unraid_mcp/ # Python package (the actual MCP server) +│ ├── main.py # Entry point +│ ├── server.py # FastMCP server registration +│ ├── tools/unraid.py # Consolidated tool (all 3 tools registered here) +│ ├── config/ # Settings management +│ ├── core/ # GraphQL client, exceptions, shared types +│ └── subscriptions/ # Real-time WebSocket subscription manager └── scripts/ └── validate-marketplace.sh # Validation tool ``` @@ -90,15 +130,15 @@ unraid-mcp/ ## Marketplace Metadata ### Categories -- `infrastructure` - Server management and monitoring tools +- `infrastructure` — Server management and monitoring tools ### Tags -- `unraid` - Unraid-specific functionality -- `monitoring` - System monitoring capabilities -- `homelab` - Homelab automation -- `graphql` - GraphQL API integration -- `docker` - Docker container management -- `virtualization` - VM management +- `unraid` — Unraid-specific functionality +- `monitoring` — System monitoring capabilities +- `homelab` — Homelab automation +- `graphql` — GraphQL API integration +- `docker` — Docker container management +- `virtualization` — VM management ## Publishing Checklist @@ -109,10 +149,10 @@ Before publishing to GitHub: ./scripts/validate-marketplace.sh ``` -2. **Update Version Numbers** - - Bump version in `.claude-plugin/marketplace.json` - - Bump version in `.claude-plugin/plugin.json` - - Update version in `README.md` if needed +2. **Update Version Numbers** (must be in sync) + - `pyproject.toml` → `version = "X.Y.Z"` under `[project]` + - `.claude-plugin/plugin.json` → `"version": "X.Y.Z"` + - `.claude-plugin/marketplace.json` → `"version"` in both `metadata` and `plugins[]` 3. **Test Locally** ```bash @@ -123,33 +163,38 @@ Before publishing to GitHub: 4. **Commit and Push** ```bash git add .claude-plugin/ - git commit -m "feat: add Claude Code marketplace configuration" + git commit -m "chore: bump marketplace to vX.Y.Z" git push origin main ``` -5. **Create Release Tag** (Optional) +5. **Create Release Tag** ```bash - git tag -a v1.0.0 -m "Release v1.0.0" - git push origin v1.0.0 + git tag -a vX.Y.Z -m "Release vX.Y.Z" + git push origin vX.Y.Z ``` ## User Experience -After installation, users will: +After installation, users can: -1. **See the skill in their skill list** - ```bash - /skill list +1. **Invoke Unraid operations directly in Claude Code** + ``` + unraid(action="system", subaction="overview") + unraid(action="docker", subaction="list") + unraid(action="health", subaction="check") ``` -2. **Access Unraid functionality directly** - - Claude Code will automatically detect when to invoke the skill - - Users can explicitly invoke with `/unraid` +2. **Use the credential setup tool on first run** + ``` + unraid(action="health", subaction="setup") + ``` + This triggers elicitation to collect and persist credentials to `~/.unraid-mcp/.env`. -3. **Have access to all helper scripts** - - Example scripts in `examples/` - - Utility scripts in `scripts/` - - API reference in `references/` +3. **Monitor live data via subscriptions** + ``` + unraid(action="live", subaction="cpu") + unraid(action="live", subaction="log_tail") + ``` ## Maintenance @@ -157,31 +202,21 @@ After installation, users will: To release a new version: -1. Make changes to the plugin -2. Update version in `.claude-plugin/plugin.json` -3. Update marketplace catalog in `.claude-plugin/marketplace.json` -4. Run validation: `./scripts/validate-marketplace.sh` -5. Commit and push +1. Make changes to the plugin code +2. Update version in `pyproject.toml`, `.claude-plugin/plugin.json`, and `.claude-plugin/marketplace.json` +3. Run validation: `./scripts/validate-marketplace.sh` +4. Commit and push -Users with the plugin installed will see the update available and can upgrade with: +Users with the plugin installed will see the update available and can upgrade: ```bash /plugin update unraid ``` -### Adding More Plugins - -To add additional plugins to this marketplace: - -1. Create new plugin directory: `skills/new-plugin/` -2. Add plugin manifest: `skills/new-plugin/.claude-plugin/plugin.json` -3. Update marketplace catalog: add entry to `.plugins[]` array in `.claude-plugin/marketplace.json` -4. Validate: `./scripts/validate-marketplace.sh` - ## Support - **Repository:** https://github.com/jmagar/unraid-mcp - **Issues:** https://github.com/jmagar/unraid-mcp/issues -- **Documentation:** See `.claude-plugin/README.md` and `skills/unraid/README.md` +- **Destructive Actions:** `docs/DESTRUCTIVE_ACTIONS.md` ## Validation @@ -198,5 +233,3 @@ This checks: - Plugin structure - Source path accuracy - Documentation completeness - -All 17 checks must pass before publishing. diff --git a/docs/PUBLISHING.md b/docs/PUBLISHING.md index bd56168..b3f0a67 100644 --- a/docs/PUBLISHING.md +++ b/docs/PUBLISHING.md @@ -2,6 +2,26 @@ This guide covers how to publish `unraid-mcp` to PyPI so it can be installed via `uvx` or `pip` from anywhere. +## Package Overview + +**PyPI package name:** `unraid-mcp` +**Entry point binary:** `unraid-mcp-server` (also aliased as `unraid-mcp`) +**Current version:** `1.1.2` + +The package ships a FastMCP server exposing **3 MCP tools**: +- `unraid` — primary tool with `action` + `subaction` routing (~107 subactions, 15 domains) +- `diagnose_subscriptions` — WebSocket subscription diagnostics +- `test_subscription_query` — test individual GraphQL subscription queries + +Tool call convention: `unraid(action="docker", subaction="list")` + +### Version Sync Requirement + +When bumping the version, **all three files must be updated together**: +- `pyproject.toml` → `version = "X.Y.Z"` under `[project]` +- `.claude-plugin/plugin.json` → `"version": "X.Y.Z"` +- `.claude-plugin/marketplace.json` → `"version"` in both `metadata` and `plugins[]` + ## Prerequisites 1. **PyPI Account**: Create accounts on both: @@ -40,7 +60,7 @@ Before publishing, update the version in `pyproject.toml`: ```toml [project] -version = "1.0.0" # Follow semantic versioning: MAJOR.MINOR.PATCH +version = "1.1.2" # Follow semantic versioning: MAJOR.MINOR.PATCH ``` **Semantic Versioning Guide:** @@ -82,8 +102,8 @@ uv run python -m build ``` This creates: -- `dist/unraid_mcp-VERSION-py3-none-any.whl` (wheel) -- `dist/unraid_mcp-VERSION.tar.gz` (source distribution) +- `dist/unraid_mcp-1.1.2-py3-none-any.whl` (wheel) +- `dist/unraid_mcp-1.1.2.tar.gz` (source distribution) ### 4. Validate the Package @@ -156,7 +176,7 @@ UNRAID_API_URL=https://your-server uvx unraid-mcp-server **Benefits of uvx:** - No installation required - Automatic virtual environment management -- Always uses the latest version (or specify version: `uvx unraid-mcp-server@1.0.0`) +- Always uses the latest version (or specify version: `uvx unraid-mcp-server@1.1.2`) - Clean execution environment ## Automation with GitHub Actions (Future) diff --git a/fastmcp.http.json b/fastmcp.http.json deleted file mode 100644 index 5dc1f90..0000000 --- a/fastmcp.http.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "$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}" - } - } -} diff --git a/fastmcp.stdio.json b/fastmcp.stdio.json deleted file mode 100644 index 49c92ac..0000000 --- a/fastmcp.stdio.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "$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}" - } - } -} diff --git a/scripts/validate-marketplace.sh b/scripts/validate-marketplace.sh index 665c8dc..fcb31a9 100755 --- a/scripts/validate-marketplace.sh +++ b/scripts/validate-marketplace.sh @@ -72,7 +72,7 @@ fi # Check version sync between pyproject.toml and plugin.json echo "Checking version sync..." -TOML_VER=$(grep '^version = ' pyproject.toml | sed 's/version = "//;s/"//') +TOML_VER=$(grep -m1 '^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}" diff --git a/tests/conftest.py b/tests/conftest.py index c29bb36..c1206c4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,6 +6,12 @@ from unittest.mock import AsyncMock, patch import pytest from fastmcp import FastMCP +from hypothesis import settings +from hypothesis.database import DirectoryBasedExampleDatabase + +# Configure hypothesis to use the .cache directory for its database +settings.register_profile("default", database=DirectoryBasedExampleDatabase(".cache/.hypothesis")) +settings.load_profile("default") @pytest.fixture diff --git a/tests/http_layer/test_request_construction.py b/tests/http_layer/test_request_construction.py index 1c1ac25..acda9db 100644 --- a/tests/http_layer/test_request_construction.py +++ b/tests/http_layer/test_request_construction.py @@ -8,6 +8,7 @@ to verify the full request pipeline. """ import json +from collections.abc import Callable from typing import Any from unittest.mock import patch @@ -264,7 +265,7 @@ class TestInfoToolRequests: """Verify unraid system tool constructs correct GraphQL queries.""" @staticmethod - def _get_tool(): + def _get_tool() -> Callable[..., Any]: return make_tool_fn("unraid_mcp.tools.unraid", "register_unraid_tool", "unraid") @respx.mock @@ -367,7 +368,7 @@ class TestDockerToolRequests: """Verify unraid docker tool constructs correct requests.""" @staticmethod - def _get_tool(): + def _get_tool() -> Callable[..., Any]: return make_tool_fn("unraid_mcp.tools.unraid", "register_unraid_tool", "unraid") @respx.mock @@ -535,7 +536,7 @@ class TestVMToolRequests: """Verify unraid vm tool constructs correct requests.""" @staticmethod - def _get_tool(): + def _get_tool() -> Callable[..., Any]: return make_tool_fn("unraid_mcp.tools.unraid", "register_unraid_tool", "unraid") @respx.mock @@ -625,7 +626,7 @@ class TestArrayToolRequests: """Verify unraid array tool constructs correct requests.""" @staticmethod - def _get_tool(): + def _get_tool() -> Callable[..., Any]: return make_tool_fn("unraid_mcp.tools.unraid", "register_unraid_tool", "unraid") @respx.mock @@ -701,7 +702,7 @@ class TestStorageToolRequests: """Verify unraid disk tool constructs correct requests.""" @staticmethod - def _get_tool(): + def _get_tool() -> Callable[..., Any]: return make_tool_fn("unraid_mcp.tools.unraid", "register_unraid_tool", "unraid") @respx.mock @@ -799,7 +800,7 @@ class TestNotificationsToolRequests: """Verify unraid notification tool constructs correct requests.""" @staticmethod - def _get_tool(): + def _get_tool() -> Callable[..., Any]: return make_tool_fn("unraid_mcp.tools.unraid", "register_unraid_tool", "unraid") @respx.mock @@ -932,7 +933,7 @@ class TestRCloneToolRequests: """Verify unraid rclone tool constructs correct requests.""" @staticmethod - def _get_tool(): + def _get_tool() -> Callable[..., Any]: return make_tool_fn("unraid_mcp.tools.unraid", "register_unraid_tool", "unraid") @respx.mock @@ -1029,7 +1030,7 @@ class TestUsersToolRequests: """Verify unraid user tool constructs correct requests.""" @staticmethod - def _get_tool(): + def _get_tool() -> Callable[..., Any]: return make_tool_fn("unraid_mcp.tools.unraid", "register_unraid_tool", "unraid") @respx.mock @@ -1062,7 +1063,7 @@ class TestKeysToolRequests: """Verify unraid key tool constructs correct requests.""" @staticmethod - def _get_tool(): + def _get_tool() -> Callable[..., Any]: return make_tool_fn("unraid_mcp.tools.unraid", "register_unraid_tool", "unraid") @respx.mock @@ -1157,7 +1158,7 @@ class TestHealthToolRequests: """Verify unraid health tool constructs correct requests.""" @staticmethod - def _get_tool(): + def _get_tool() -> Callable[..., Any]: return make_tool_fn("unraid_mcp.tools.unraid", "register_unraid_tool", "unraid") @respx.mock diff --git a/tests/mcporter/README.md b/tests/mcporter/README.md index 2b4150e..edb7f8b 100644 --- a/tests/mcporter/README.md +++ b/tests/mcporter/README.md @@ -4,17 +4,7 @@ Live integration smoke-tests for the unraid-mcp server, exercising real API call --- -## Two Scripts, Two Transports - -| | `test-tools.sh` | `test-actions.sh` | -|-|-----------------|-------------------| -| **Transport** | stdio | HTTP | -| **Server required** | No — launched ad-hoc per call | Yes — must be running at `$MCP_URL` | -| **Flags** | `--timeout-ms N`, `--parallel`, `--verbose` | positional `[MCP_URL]` | -| **Coverage** | 10 tools (read-only actions only) | 11 tools (all non-destructive actions) | -| **Use case** | CI / offline local check | Live server smoke-test | - -### `test-tools.sh` — stdio, no running server needed +## `test-tools.sh` — stdio, no running server needed ```bash ./tests/mcporter/test-tools.sh # sequential, 25s timeout @@ -25,19 +15,9 @@ Live integration smoke-tests for the unraid-mcp server, exercising real API call Launches `uv run unraid-mcp-server` in stdio mode for each tool call. Requires `mcporter`, `uv`, and `python3` in `PATH`. Good for CI pipelines — no persistent server process needed. -### `test-actions.sh` — HTTP, requires a live server - -```bash -./tests/mcporter/test-actions.sh # default: http://localhost:6970/mcp -./tests/mcporter/test-actions.sh http://10.1.0.2:6970/mcp # explicit URL -UNRAID_MCP_URL=http://10.1.0.2:6970/mcp ./tests/mcporter/test-actions.sh -``` - -Connects to an already-running streamable-http server. Covers all read-only actions across 10 tools (`unraid_settings` is all-mutations and skipped; all destructive mutations are explicitly skipped). - --- -## What `test-actions.sh` Tests +## What `test-tools.sh` Tests ### Phase 1 — Param-free reads @@ -137,15 +117,10 @@ curl -LsSf https://astral.sh/uv/install.sh | sh # python3 — used for inline JSON extraction python3 --version # 3.12+ - -# Running server (for test-actions.sh only) -docker compose up -d -# or -uv run unraid-mcp-server ``` --- ## Cleanup -`test-actions.sh` connects to an existing server and leaves it running; it creates no temporary files. `test-tools.sh` spawns stdio server subprocesses per call — they exit when mcporter finishes each invocation — and may write a timestamped log file under `${TMPDIR:-/tmp}`. Neither script leaves background processes. +`test-tools.sh` spawns stdio server subprocesses per call — they exit when mcporter finishes each invocation — and may write a timestamped log file under `${TMPDIR:-/tmp}`. It does not leave background processes. diff --git a/tests/mcporter/test-actions.sh b/tests/mcporter/test-actions.sh deleted file mode 100755 index 26a287d..0000000 --- a/tests/mcporter/test-actions.sh +++ /dev/null @@ -1,407 +0,0 @@ -#!/usr/bin/env bash -# test-actions.sh — Test all non-destructive Unraid MCP actions via mcporter -# -# Usage: -# ./scripts/test-actions.sh [MCP_URL] -# -# Default MCP_URL: http://localhost:6970/mcp -# Skips: destructive (confirm=True required), state-changing mutations, -# and actions requiring IDs not yet discovered. -# -# Phase 1: param-free reads -# Phase 2: ID-discovered reads (container, network, disk, vm, key, log) - -set -euo pipefail - -MCP_URL="${1:-${UNRAID_MCP_URL:-http://localhost:6970/mcp}}" - -# ── colours ────────────────────────────────────────────────────────────────── -RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m' -CYAN='\033[0;36m'; BOLD='\033[1m'; NC='\033[0m' - -PASS=0; FAIL=0; SKIP=0 -declare -a FAILED_TESTS=() - -# ── helpers ─────────────────────────────────────────────────────────────────── - -mcall() { - # mcall - local tool="$1" args="$2" - mcporter call \ - --http-url "$MCP_URL" \ - --allow-http \ - --tool "$tool" \ - --args "$args" \ - --output json \ - 2>&1 -} - -_check_output() { - # Returns 0 if output looks like a successful JSON response, 1 otherwise. - local output="$1" exit_code="$2" - [[ $exit_code -ne 0 ]] && return 1 - echo "$output" | python3 -c " -import json, sys -try: - d = json.load(sys.stdin) - if isinstance(d, dict) and (d.get('isError') or d.get('error') or 'ToolError' in str(d)): - sys.exit(1) -except Exception: - pass -sys.exit(0) -" 2>/dev/null -} - -run_test() { - # Print result; do NOT echo the JSON body (kept quiet for readability). - local label="$1" tool="$2" args="$3" - printf " %-60s" "$label" - local output exit_code=0 - output=$(mcall "$tool" "$args" 2>&1) || exit_code=$? - if _check_output "$output" "$exit_code"; then - echo -e "${GREEN}PASS${NC}" - ((PASS++)) || true - else - echo -e "${RED}FAIL${NC}" - ((FAIL++)) || true - FAILED_TESTS+=("$label") - # Show first 3 lines of error detail, indented - echo "$output" | head -3 | sed 's/^/ /' - fi -} - -run_test_capture() { - # Like run_test but echoes raw JSON to stdout for ID extraction by caller. - # Status lines go to stderr so the caller's $() captures only clean JSON. - local label="$1" tool="$2" args="$3" - local output exit_code=0 - printf " %-60s" "$label" >&2 - output=$(mcall "$tool" "$args" 2>&1) || exit_code=$? - if _check_output "$output" "$exit_code"; then - echo -e "${GREEN}PASS${NC}" >&2 - ((PASS++)) || true - else - echo -e "${RED}FAIL${NC}" >&2 - ((FAIL++)) || true - FAILED_TESTS+=("$label") - echo "$output" | head -3 | sed 's/^/ /' >&2 - fi - echo "$output" # pure JSON → captured by caller's $() -} - -extract_id() { - # Extract an ID from JSON output using a Python snippet. - # Usage: ID=$(extract_id "$JSON_OUTPUT" "$LABEL" 'python expression') - # If JSON parsing fails (malformed mcporter output), record a FAIL. - # If parsing succeeds but finds no items, return empty (caller skips). - local json_input="$1" label="$2" py_code="$3" - local result="" py_exit=0 parse_err="" - # Capture stdout (the extracted ID) and stderr (any parse errors) separately. - # A temp file is needed because $() can only capture one stream. - local errfile - errfile=$(mktemp) - result=$(echo "$json_input" | python3 -c "$py_code" 2>"$errfile") || py_exit=$? - parse_err=$(<"$errfile") - rm -f "$errfile" - if [[ $py_exit -ne 0 ]]; then - printf " %-60s${RED}FAIL${NC} (JSON parse error)\n" "$label" >&2 - [[ -n "$parse_err" ]] && echo "$parse_err" | head -2 | sed 's/^/ /' >&2 - ((FAIL++)) || true - FAILED_TESTS+=("$label (JSON parse)") - echo "" - return 1 - fi - echo "$result" -} - -skip_test() { - local label="$1" reason="$2" - printf " %-60s${YELLOW}SKIP${NC} (%s)\n" "$label" "$reason" - ((SKIP++)) || true -} - -section() { - echo "" - echo -e "${CYAN}${BOLD}━━━ $1 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" -} - -# ── connectivity check ──────────────────────────────────────────────────────── - -echo "" -echo -e "${BOLD}Unraid MCP Non-Destructive Action Test Suite${NC}" -echo -e "Server: ${CYAN}$MCP_URL${NC}" -echo "" -printf "Checking connectivity... " -# Use -s (silent) without -f: a 4xx/406 means the MCP server is up and -# responding correctly to a plain GET — only "connection refused" is fatal. -# Capture curl's exit code directly — don't mask failures with a fallback. -HTTP_CODE="" -curl_exit=0 -HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 "$MCP_URL" 2>/dev/null) || curl_exit=$? -if [[ $curl_exit -ne 0 ]]; then - echo -e "${RED}UNREACHABLE${NC} (curl exit code: $curl_exit)" - echo "Start the server first: docker compose up -d OR uv run unraid-mcp-server" - exit 1 -fi -echo -e "${GREEN}OK${NC} (HTTP $HTTP_CODE)" - -# ═══════════════════════════════════════════════════════════════════════════════ -# PHASE 1 — Param-free read actions -# ═══════════════════════════════════════════════════════════════════════════════ - -section "unraid_info (19 query actions)" -run_test "info: overview" unraid_info '{"action":"overview"}' -run_test "info: array" unraid_info '{"action":"array"}' -run_test "info: network" unraid_info '{"action":"network"}' -run_test "info: registration" unraid_info '{"action":"registration"}' -run_test "info: connect" unraid_info '{"action":"connect"}' -run_test "info: variables" unraid_info '{"action":"variables"}' -run_test "info: metrics" unraid_info '{"action":"metrics"}' -run_test "info: services" unraid_info '{"action":"services"}' -run_test "info: display" unraid_info '{"action":"display"}' -run_test "info: config" unraid_info '{"action":"config"}' -run_test "info: online" unraid_info '{"action":"online"}' -run_test "info: owner" unraid_info '{"action":"owner"}' -run_test "info: settings" unraid_info '{"action":"settings"}' -run_test "info: server" unraid_info '{"action":"server"}' -run_test "info: servers" unraid_info '{"action":"servers"}' -run_test "info: flash" unraid_info '{"action":"flash"}' -run_test "info: ups_devices" unraid_info '{"action":"ups_devices"}' -run_test "info: ups_device" unraid_info '{"action":"ups_device"}' -run_test "info: ups_config" unraid_info '{"action":"ups_config"}' -skip_test "info: update_server" "mutation — state-changing" -skip_test "info: update_ssh" "mutation — state-changing" - -section "unraid_array" -run_test "array: parity_status" unraid_array '{"action":"parity_status"}' -skip_test "array: parity_start" "mutation — starts parity check" -skip_test "array: parity_pause" "mutation — pauses parity check" -skip_test "array: parity_resume" "mutation — resumes parity check" -skip_test "array: parity_cancel" "mutation — cancels parity check" - -section "unraid_storage (param-free reads)" -STORAGE_DISKS=$(run_test_capture "storage: disks" unraid_storage '{"action":"disks"}') -run_test "storage: shares" unraid_storage '{"action":"shares"}' -run_test "storage: unassigned" unraid_storage '{"action":"unassigned"}' -LOG_FILES=$(run_test_capture "storage: log_files" unraid_storage '{"action":"log_files"}') -skip_test "storage: flash_backup" "destructive (confirm=True required)" - -section "unraid_docker (param-free reads)" -DOCKER_LIST=$(run_test_capture "docker: list" unraid_docker '{"action":"list"}') -DOCKER_NETS=$(run_test_capture "docker: networks" unraid_docker '{"action":"networks"}') -run_test "docker: port_conflicts" unraid_docker '{"action":"port_conflicts"}' -run_test "docker: check_updates" unraid_docker '{"action":"check_updates"}' -run_test "docker: sync_templates" unraid_docker '{"action":"sync_templates"}' -run_test "docker: refresh_digests" unraid_docker '{"action":"refresh_digests"}' -skip_test "docker: start" "mutation — changes container state" -skip_test "docker: stop" "mutation — changes container state" -skip_test "docker: restart" "mutation — changes container state" -skip_test "docker: pause" "mutation — changes container state" -skip_test "docker: unpause" "mutation — changes container state" -skip_test "docker: update" "mutation — updates container image" -skip_test "docker: remove" "destructive (confirm=True required)" -skip_test "docker: update_all" "destructive (confirm=True required)" -skip_test "docker: create_folder" "mutation — changes organizer state" -skip_test "docker: set_folder_children" "mutation — changes organizer state" -skip_test "docker: delete_entries" "destructive (confirm=True required)" -skip_test "docker: move_to_folder" "mutation — changes organizer state" -skip_test "docker: move_to_position" "mutation — changes organizer state" -skip_test "docker: rename_folder" "mutation — changes organizer state" -skip_test "docker: create_folder_with_items" "mutation — changes organizer state" -skip_test "docker: update_view_prefs" "mutation — changes organizer state" -skip_test "docker: reset_template_mappings" "destructive (confirm=True required)" - -section "unraid_vm (param-free reads)" -VM_LIST=$(run_test_capture "vm: list" unraid_vm '{"action":"list"}') -skip_test "vm: start" "mutation — changes VM state" -skip_test "vm: stop" "mutation — changes VM state" -skip_test "vm: pause" "mutation — changes VM state" -skip_test "vm: resume" "mutation — changes VM state" -skip_test "vm: reboot" "mutation — changes VM state" -skip_test "vm: force_stop" "destructive (confirm=True required)" -skip_test "vm: reset" "destructive (confirm=True required)" - -section "unraid_notifications" -run_test "notifications: overview" unraid_notifications '{"action":"overview"}' -run_test "notifications: list" unraid_notifications '{"action":"list"}' -run_test "notifications: warnings" unraid_notifications '{"action":"warnings"}' -run_test "notifications: recalculate" unraid_notifications '{"action":"recalculate"}' -skip_test "notifications: create" "mutation — creates notification" -skip_test "notifications: create_unique" "mutation — creates notification" -skip_test "notifications: archive" "mutation — changes notification state" -skip_test "notifications: unread" "mutation — changes notification state" -skip_test "notifications: archive_all" "mutation — changes notification state" -skip_test "notifications: archive_many" "mutation — changes notification state" -skip_test "notifications: unarchive_many" "mutation — changes notification state" -skip_test "notifications: unarchive_all" "mutation — changes notification state" -skip_test "notifications: delete" "destructive (confirm=True required)" -skip_test "notifications: delete_archived" "destructive (confirm=True required)" - -section "unraid_rclone" -run_test "rclone: list_remotes" unraid_rclone '{"action":"list_remotes"}' -run_test "rclone: config_form" unraid_rclone '{"action":"config_form"}' -skip_test "rclone: create_remote" "mutation — creates remote" -skip_test "rclone: delete_remote" "destructive (confirm=True required)" - -section "unraid_users" -run_test "users: me" unraid_users '{"action":"me"}' - -section "unraid_keys" -KEYS_LIST=$(run_test_capture "keys: list" unraid_keys '{"action":"list"}') -skip_test "keys: create" "mutation — creates API key" -skip_test "keys: update" "mutation — modifies API key" -skip_test "keys: delete" "destructive (confirm=True required)" - -section "unraid_health" -run_test "health: check" unraid_health '{"action":"check"}' -run_test "health: test_connection" unraid_health '{"action":"test_connection"}' -run_test "health: diagnose" unraid_health '{"action":"diagnose"}' - -section "unraid_settings (all mutations — skipped)" -skip_test "settings: update" "mutation — modifies settings" -skip_test "settings: update_temperature" "mutation — modifies settings" -skip_test "settings: update_time" "mutation — modifies settings" -skip_test "settings: configure_ups" "destructive (confirm=True required)" -skip_test "settings: update_api" "mutation — modifies settings" -skip_test "settings: connect_sign_in" "mutation — authentication action" -skip_test "settings: connect_sign_out" "mutation — authentication action" -skip_test "settings: setup_remote_access" "destructive (confirm=True required)" -skip_test "settings: enable_dynamic_remote_access" "destructive (confirm=True required)" - -# ═══════════════════════════════════════════════════════════════════════════════ -# PHASE 2 — ID-discovered read actions -# ═══════════════════════════════════════════════════════════════════════════════ - -section "Phase 2: ID-discovered reads" - -# ── docker container ID ─────────────────────────────────────────────────────── -CONTAINER_ID=$(extract_id "$DOCKER_LIST" "docker: extract container ID" " -import json, sys -d = json.load(sys.stdin) -containers = d.get('containers') or d.get('data', {}).get('containers') or [] -if isinstance(containers, list) and containers: - c = containers[0] - cid = c.get('id') or c.get('names', [''])[0].lstrip('/') - if cid: - print(cid) -") - -if [[ -n "$CONTAINER_ID" ]]; then - run_test "docker: details (id=$CONTAINER_ID)" \ - unraid_docker "{\"action\":\"details\",\"container_id\":\"$CONTAINER_ID\"}" - run_test "docker: logs (id=$CONTAINER_ID)" \ - unraid_docker "{\"action\":\"logs\",\"container_id\":\"$CONTAINER_ID\",\"tail_lines\":20}" -else - skip_test "docker: details" "no containers found to discover ID" - skip_test "docker: logs" "no containers found to discover ID" -fi - -# ── docker network ID ───────────────────────────────────────────────────────── -NETWORK_ID=$(extract_id "$DOCKER_NETS" "docker: extract network ID" " -import json, sys -d = json.load(sys.stdin) -nets = d.get('networks') or d.get('data', {}).get('networks') or [] -if isinstance(nets, list) and nets: - nid = nets[0].get('id') or nets[0].get('Id') - if nid: - print(nid) -") - -if [[ -n "$NETWORK_ID" ]]; then - run_test "docker: network_details (id=$NETWORK_ID)" \ - unraid_docker "{\"action\":\"network_details\",\"network_id\":\"$NETWORK_ID\"}" -else - skip_test "docker: network_details" "no networks found to discover ID" -fi - -# ── disk ID ─────────────────────────────────────────────────────────────────── -DISK_ID=$(extract_id "$STORAGE_DISKS" "storage: extract disk ID" " -import json, sys -d = json.load(sys.stdin) -disks = d.get('disks') or d.get('data', {}).get('disks') or [] -if isinstance(disks, list) and disks: - did = disks[0].get('id') or disks[0].get('device') - if did: - print(did) -") - -if [[ -n "$DISK_ID" ]]; then - run_test "storage: disk_details (id=$DISK_ID)" \ - unraid_storage "{\"action\":\"disk_details\",\"disk_id\":\"$DISK_ID\"}" -else - skip_test "storage: disk_details" "no disks found to discover ID" -fi - -# ── log path ────────────────────────────────────────────────────────────────── -LOG_PATH=$(extract_id "$LOG_FILES" "storage: extract log path" " -import json, sys -d = json.load(sys.stdin) -files = d.get('log_files') or d.get('files') or d.get('data', {}).get('log_files') or [] -if isinstance(files, list) and files: - p = files[0].get('path') or (files[0] if isinstance(files[0], str) else None) - if p: - print(p) -") - -if [[ -n "$LOG_PATH" ]]; then - run_test "storage: logs (path=$LOG_PATH)" \ - unraid_storage "{\"action\":\"logs\",\"log_path\":\"$LOG_PATH\",\"tail_lines\":20}" -else - skip_test "storage: logs" "no log files found to discover path" -fi - -# ── VM ID ───────────────────────────────────────────────────────────────────── -VM_ID=$(extract_id "$VM_LIST" "vm: extract VM ID" " -import json, sys -d = json.load(sys.stdin) -vms = d.get('vms') or d.get('data', {}).get('vms') or [] -if isinstance(vms, list) and vms: - vid = vms[0].get('uuid') or vms[0].get('id') or vms[0].get('name') - if vid: - print(vid) -") - -if [[ -n "$VM_ID" ]]; then - run_test "vm: details (id=$VM_ID)" \ - unraid_vm "{\"action\":\"details\",\"vm_id\":\"$VM_ID\"}" -else - skip_test "vm: details" "no VMs found to discover ID" -fi - -# ── API key ID ──────────────────────────────────────────────────────────────── -KEY_ID=$(extract_id "$KEYS_LIST" "keys: extract key ID" " -import json, sys -d = json.load(sys.stdin) -keys = d.get('keys') or d.get('apiKeys') or d.get('data', {}).get('keys') or [] -if isinstance(keys, list) and keys: - kid = keys[0].get('id') - if kid: - print(kid) -") - -if [[ -n "$KEY_ID" ]]; then - run_test "keys: get (id=$KEY_ID)" \ - unraid_keys "{\"action\":\"get\",\"key_id\":\"$KEY_ID\"}" -else - skip_test "keys: get" "no API keys found to discover ID" -fi - -# ═══════════════════════════════════════════════════════════════════════════════ -# SUMMARY -# ═══════════════════════════════════════════════════════════════════════════════ - -TOTAL=$((PASS + FAIL + SKIP)) -echo "" -echo -e "${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" -echo -e "${BOLD}Results: ${GREEN}${PASS} passed${NC} ${RED}${FAIL} failed${NC} ${YELLOW}${SKIP} skipped${NC} (${TOTAL} total)" - -if [[ ${#FAILED_TESTS[@]} -gt 0 ]]; then - echo "" - echo -e "${RED}${BOLD}Failed tests:${NC}" - for t in "${FAILED_TESTS[@]}"; do - echo -e " ${RED}✗${NC} $t" - done -fi - -echo "" -[[ $FAIL -eq 0 ]] && exit 0 || exit 1 diff --git a/tests/mcporter/test-destructive.sh b/tests/mcporter/test-destructive.sh index af46f52..8bc04ee 100755 --- a/tests/mcporter/test-destructive.sh +++ b/tests/mcporter/test-destructive.sh @@ -149,8 +149,8 @@ test_notifications_delete() { # Create the notification local create_raw - create_raw="$(mcall unraid_notifications \ - '{"action":"create","title":"mcp-test-delete","subject":"MCP destructive test","description":"Safe to delete","importance":"INFO"}')" + create_raw="$(mcall unraid \ + '{"action":"notification","subaction":"create","title":"mcp-test-delete","subject":"MCP destructive test","description":"Safe to delete","importance":"INFO"}')" local create_ok create_ok="$(python3 -c "import json,sys; d=json.loads('''${create_raw}'''); print(d.get('success', False))" 2>/dev/null)" if [[ "${create_ok}" != "True" ]]; then @@ -161,7 +161,7 @@ test_notifications_delete() { # The create response ID doesn't match the stored filename — list and find by title. # Use the LAST match so a stale notification with the same title is bypassed. local list_raw nid - list_raw="$(mcall unraid_notifications '{"action":"list","notification_type":"UNREAD"}')" + list_raw="$(mcall unraid '{"action":"notification","subaction":"list","notification_type":"UNREAD"}')" nid="$(python3 -c " import json,sys d = json.loads('''${list_raw}''') @@ -177,8 +177,8 @@ print(matches[0] if matches else '') fi local del_raw - del_raw="$(mcall unraid_notifications \ - "{\"action\":\"delete\",\"notification_id\":\"${nid}\",\"notification_type\":\"UNREAD\",\"confirm\":true}")" + del_raw="$(mcall unraid \ + "{\"action\":\"notification\",\"subaction\":\"delete\",\"notification_id\":\"${nid}\",\"notification_type\":\"UNREAD\",\"confirm\":true}")" # success=true OR deleteNotification key present (raw GraphQL response) both indicate success local success success="$(python3 -c " @@ -190,7 +190,7 @@ print(ok) if [[ "${success}" != "True" ]]; then # Leak: notification created but not deleted — archive it so it doesn't clutter the feed - mcall unraid_notifications "{\"action\":\"archive\",\"notification_id\":\"${nid}\"}" &>/dev/null || true + mcall unraid "{\"action\":\"notification\",\"subaction\":\"archive\",\"notification_id\":\"${nid}\"}" &>/dev/null || true fail_test "${label}" "delete did not return success=true: ${del_raw} (notification archived as fallback cleanup)" return fi @@ -201,7 +201,7 @@ print(ok) if ${CONFIRM}; then test_notifications_delete else - dry_run "notifications: delete [create notification → mcall unraid_notifications delete]" + dry_run "notifications: delete [create notification → mcall unraid action=notification subaction=delete]" fi # --------------------------------------------------------------------------- @@ -227,7 +227,7 @@ test_keys_delete() { # Guard: abort if test key already exists (don't delete a real key) # Note: API key names cannot contain hyphens — use "mcp test key" local existing_keys - existing_keys="$(mcall unraid_keys '{"action":"list"}')" + existing_keys="$(mcall unraid '{"action":"key","subaction":"list"}')" if python3 -c " import json,sys d = json.loads('''${existing_keys}''') @@ -241,8 +241,8 @@ sys.exit(1 if any(k.get('name') == 'mcp test key' for k in keys) else 0) fi local create_raw - create_raw="$(mcall unraid_keys \ - '{"action":"create","name":"mcp test key","roles":["VIEWER"]}')" + create_raw="$(mcall unraid \ + '{"action":"key","subaction":"create","name":"mcp test key","roles":["VIEWER"]}')" local kid kid="$(python3 -c "import json,sys; d=json.loads('''${create_raw}'''); print(d.get('key',{}).get('id',''))" 2>/dev/null)" @@ -252,20 +252,20 @@ sys.exit(1 if any(k.get('name') == 'mcp test key' for k in keys) else 0) fi local del_raw - del_raw="$(mcall unraid_keys "{\"action\":\"delete\",\"key_id\":\"${kid}\",\"confirm\":true}")" + del_raw="$(mcall unraid "{\"action\":\"key\",\"subaction\":\"delete\",\"key_id\":\"${kid}\",\"confirm\":true}")" local success success="$(python3 -c "import json,sys; d=json.loads('''${del_raw}'''); print(d.get('success', False))" 2>/dev/null)" if [[ "${success}" != "True" ]]; then # Cleanup: attempt to delete the leaked key so future runs are not blocked - mcall unraid_keys "{\"action\":\"delete\",\"key_id\":\"${kid}\",\"confirm\":true}" &>/dev/null || true + mcall unraid "{\"action\":\"key\",\"subaction\":\"delete\",\"key_id\":\"${kid}\",\"confirm\":true}" &>/dev/null || true fail_test "${label}" "delete did not return success=true: ${del_raw} (key delete re-attempted as fallback cleanup)" return fi # Verify gone local list_raw - list_raw="$(mcall unraid_keys '{"action":"list"}')" + list_raw="$(mcall unraid '{"action":"key","subaction":"list"}')" if python3 -c " import json,sys d = json.loads('''${list_raw}''') @@ -281,7 +281,7 @@ sys.exit(0 if not any(k.get('id') == '${kid}' for k in keys) else 1) if ${CONFIRM}; then test_keys_delete else - dry_run "keys: delete [create test key → mcall unraid_keys delete]" + dry_run "keys: delete [create test key → mcall unraid action=key subaction=delete]" fi # --------------------------------------------------------------------------- diff --git a/tests/mcporter/test-tools.sh b/tests/mcporter/test-tools.sh index 5fb7cf2..e7e9fdc 100755 --- a/tests/mcporter/test-tools.sh +++ b/tests/mcporter/test-tools.sh @@ -215,6 +215,7 @@ except Exception as e: mcporter_call() { local args_json="${1:?args_json required}" + # Redirect stderr to the log file so startup warnings/logs don't pollute the JSON stdout. mcporter call \ --stdio "uv run unraid-mcp-server" \ --cwd "${PROJECT_DIR}" \ @@ -223,7 +224,7 @@ mcporter_call() { --args "${args_json}" \ --timeout "${CALL_TIMEOUT_MS}" \ --output json \ - 2>&1 + 2>>"${LOG_FILE}" } # --------------------------------------------------------------------------- @@ -239,7 +240,7 @@ run_test() { t0="$(date +%s%N)" local output - output="$(mcporter_call "${args}" 2>&1)" || true + output="$(mcporter_call "${args}")" || true local elapsed_ms elapsed_ms="$(( ( $(date +%s%N) - t0 ) / 1000000 ))" @@ -659,7 +660,7 @@ suite_live() { run_test "live: memory" '{"action":"live","subaction":"memory"}' run_test "live: cpu_telemetry" '{"action":"live","subaction":"cpu_telemetry"}' run_test "live: notifications_overview" '{"action":"live","subaction":"notifications_overview"}' - run_test "live: log_tail" '{"action":"live","subaction":"log_tail"}' + run_test "live: log_tail" '{"action":"live","subaction":"log_tail","path":"/var/log/syslog"}' } # --------------------------------------------------------------------------- diff --git a/tests/test_api_key_auth.py b/tests/test_api_key_auth.py deleted file mode 100644 index 1a96bae..0000000 --- a/tests/test_api_key_auth.py +++ /dev/null @@ -1,155 +0,0 @@ -"""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" diff --git a/tests/test_auth_builder.py b/tests/test_auth_builder.py deleted file mode 100644 index 47a21d9..0000000 --- a/tests/test_auth_builder.py +++ /dev/null @@ -1,115 +0,0 @@ -"""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 diff --git a/tests/test_auth_settings.py b/tests/test_auth_settings.py deleted file mode 100644 index 2c40af6..0000000 --- a/tests/test_auth_settings.py +++ /dev/null @@ -1,91 +0,0 @@ -"""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() diff --git a/tests/test_guards.py b/tests/test_guards.py index 2095c45..1f4ed4e 100644 --- a/tests/test_guards.py +++ b/tests/test_guards.py @@ -17,20 +17,26 @@ class TestGateDestructiveAction: @pytest.mark.asyncio async def test_non_destructive_action_passes_through(self) -> None: """Non-destructive actions are never blocked.""" - await gate_destructive_action(None, "list", DESTRUCTIVE, False, "irrelevant") + await gate_destructive_action( + None, "list", DESTRUCTIVE, confirm=False, description="irrelevant" + ) @pytest.mark.asyncio async def test_confirm_true_bypasses_elicitation(self) -> None: """confirm=True skips elicitation entirely.""" with patch("unraid_mcp.core.guards.elicit_destructive_confirmation") as mock_elicit: - await gate_destructive_action(None, "delete", DESTRUCTIVE, True, "desc") + await gate_destructive_action( + None, "delete", DESTRUCTIVE, confirm=True, description="desc" + ) mock_elicit.assert_not_called() @pytest.mark.asyncio async def test_no_ctx_raises_tool_error(self) -> None: """ctx=None means elicitation returns False → ToolError.""" with pytest.raises(ToolError, match="not confirmed"): - await gate_destructive_action(None, "delete", DESTRUCTIVE, False, "desc") + await gate_destructive_action( + None, "delete", DESTRUCTIVE, confirm=False, description="desc" + ) @pytest.mark.asyncio async def test_elicitation_accepted_does_not_raise(self) -> None: @@ -40,7 +46,9 @@ class TestGateDestructiveAction: new_callable=AsyncMock, return_value=True, ): - await gate_destructive_action(object(), "delete", DESTRUCTIVE, False, "desc") + await gate_destructive_action( + object(), "delete", DESTRUCTIVE, confirm=False, description="desc" + ) @pytest.mark.asyncio async def test_elicitation_declined_raises_tool_error(self) -> None: @@ -53,7 +61,9 @@ class TestGateDestructiveAction: ) as mock_elicit, pytest.raises(ToolError, match="confirm=True"), ): - await gate_destructive_action(object(), "delete", DESTRUCTIVE, False, "desc") + await gate_destructive_action( + object(), "delete", DESTRUCTIVE, confirm=False, description="desc" + ) mock_elicit.assert_called_once() @pytest.mark.asyncio @@ -65,7 +75,7 @@ class TestGateDestructiveAction: return_value=True, ) as mock_elicit: await gate_destructive_action( - object(), "delete", DESTRUCTIVE, False, "Delete everything." + object(), "delete", DESTRUCTIVE, confirm=False, description="Delete everything." ) _, _, desc = mock_elicit.call_args.args assert desc == "Delete everything." @@ -79,7 +89,9 @@ class TestGateDestructiveAction: new_callable=AsyncMock, return_value=True, ) as mock_elicit: - await gate_destructive_action(object(), "wipe", DESTRUCTIVE, False, descs) + await gate_destructive_action( + object(), "wipe", DESTRUCTIVE, confirm=False, description=descs + ) _, _, desc = mock_elicit.call_args.args assert desc == "Wipe desc." @@ -87,4 +99,6 @@ class TestGateDestructiveAction: async def test_error_message_contains_action_name(self) -> None: """ToolError message includes the action name.""" with pytest.raises(ToolError, match="'delete'"): - await gate_destructive_action(None, "delete", DESTRUCTIVE, False, "desc") + await gate_destructive_action( + None, "delete", DESTRUCTIVE, confirm=False, description="desc" + ) diff --git a/tests/test_health.py b/tests/test_health.py index 2026ba6..37bb4fa 100644 --- a/tests/test_health.py +++ b/tests/test_health.py @@ -404,7 +404,8 @@ async def test_health_setup_declined_message_includes_manual_path() -> None: real_path_str = str(CREDENTIALS_ENV_PATH) mock_path = MagicMock() mock_path.exists.return_value = False - type(mock_path).__str__ = lambda self: real_path_str # type: ignore[method-assign] + # Override __str__ on the instance's mock directly — avoids mutating the shared MagicMock class. + mock_path.__str__ = MagicMock(return_value=real_path_str) with ( patch("unraid_mcp.config.settings.CREDENTIALS_ENV_PATH", mock_path), diff --git a/tests/test_keys.py b/tests/test_keys.py index 14c2342..b98f559 100644 --- a/tests/test_keys.py +++ b/tests/test_keys.py @@ -1,6 +1,7 @@ """Tests for key subactions of the consolidated unraid tool.""" -from collections.abc import Generator +from collections.abc import Callable, Generator +from typing import Any from unittest.mock import AsyncMock, patch import pytest @@ -15,7 +16,7 @@ def _mock_graphql() -> Generator[AsyncMock, None, None]: yield mock -def _make_tool(): +def _make_tool() -> Callable[..., Any]: return make_tool_fn("unraid_mcp.tools.unraid", "register_unraid_tool", "unraid") diff --git a/tests/test_rclone.py b/tests/test_rclone.py index 87fe593..9a455ca 100644 --- a/tests/test_rclone.py +++ b/tests/test_rclone.py @@ -20,20 +20,23 @@ def _make_tool(): class TestRcloneValidation: - async def test_delete_requires_confirm(self) -> None: + async def test_delete_requires_confirm(self, _mock_graphql: AsyncMock) -> None: tool_fn = _make_tool() with pytest.raises(ToolError, match="not confirmed"): await tool_fn(action="rclone", subaction="delete_remote", name="gdrive") + _mock_graphql.assert_not_awaited() - async def test_create_requires_fields(self) -> None: + async def test_create_requires_fields(self, _mock_graphql: AsyncMock) -> None: tool_fn = _make_tool() with pytest.raises(ToolError, match="requires name"): await tool_fn(action="rclone", subaction="create_remote") + _mock_graphql.assert_not_awaited() - async def test_delete_requires_name(self) -> None: + async def test_delete_requires_name(self, _mock_graphql: AsyncMock) -> None: tool_fn = _make_tool() with pytest.raises(ToolError, match="name is required"): await tool_fn(action="rclone", subaction="delete_remote", confirm=True) + _mock_graphql.assert_not_awaited() class TestRcloneActions: diff --git a/tests/test_resources.py b/tests/test_resources.py index 899a6b0..a0c725d 100644 --- a/tests/test_resources.py +++ b/tests/test_resources.py @@ -64,6 +64,8 @@ class TestLiveResourcesUseManagerCache: with patch("unraid_mcp.subscriptions.resources.subscription_manager") as mock_mgr: mock_mgr.get_resource_data = AsyncMock(return_value=None) mock_mgr.last_error = {action: "WebSocket auth failed"} + mock_mgr.connection_states = {action: "auth_failed"} + mock_mgr.auto_start_enabled = True 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. diff --git a/tests/test_setup.py b/tests/test_setup.py index c0ef844..abf5897 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -1,5 +1,7 @@ +import os +import stat from pathlib import Path -from unittest.mock import patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -100,8 +102,6 @@ def test_run_server_does_not_exit_when_creds_missing(monkeypatch): @pytest.mark.asyncio async def test_elicit_and_configure_writes_env_file(tmp_path): """elicit_and_configure writes a .env file and calls apply_runtime_config.""" - from unittest.mock import AsyncMock, MagicMock, patch - from unraid_mcp.core.setup import elicit_and_configure mock_ctx = MagicMock() @@ -133,7 +133,6 @@ async def test_elicit_and_configure_writes_env_file(tmp_path): @pytest.mark.asyncio async def test_elicit_and_configure_returns_false_on_decline(): - from unittest.mock import AsyncMock, MagicMock from unraid_mcp.core.setup import elicit_and_configure @@ -148,7 +147,6 @@ async def test_elicit_and_configure_returns_false_on_decline(): @pytest.mark.asyncio async def test_elicit_and_configure_returns_false_on_cancel(): - from unittest.mock import AsyncMock, MagicMock from unraid_mcp.core.setup import elicit_and_configure @@ -181,9 +179,6 @@ async def test_make_graphql_request_raises_sentinel_when_unconfigured(): settings_mod.UNRAID_API_KEY = original_key -import os # noqa: E402 — needed for reload-based tests below - - def test_credentials_dir_defaults_to_home_unraid_mcp(): """CREDENTIALS_DIR defaults to ~/.unraid-mcp when env var is not set.""" import importlib @@ -223,9 +218,6 @@ def test_credentials_env_path_is_dot_env_inside_credentials_dir(): assert s.CREDENTIALS_ENV_PATH == s.CREDENTIALS_DIR / ".env" -import stat # noqa: E402 - - def test_write_env_creates_credentials_dir_with_700_permissions(tmp_path): """_write_env creates CREDENTIALS_DIR with mode 700 (owner-only).""" from unraid_mcp.core.setup import _write_env @@ -342,7 +334,6 @@ def test_write_env_updates_existing_credentials_in_place(tmp_path): @pytest.mark.asyncio async def test_elicit_and_configure_returns_false_when_client_not_supported(): """elicit_and_configure returns False when client raises NotImplementedError.""" - from unittest.mock import AsyncMock, MagicMock from unraid_mcp.core.setup import elicit_and_configure @@ -404,7 +395,6 @@ async def test_elicit_reset_confirmation_returns_false_when_ctx_none(): @pytest.mark.asyncio async def test_elicit_reset_confirmation_returns_true_when_user_confirms(): """Returns True when the user accepts and answers True.""" - from unittest.mock import AsyncMock, MagicMock from unraid_mcp.core.setup import elicit_reset_confirmation @@ -421,7 +411,6 @@ async def test_elicit_reset_confirmation_returns_true_when_user_confirms(): @pytest.mark.asyncio async def test_elicit_reset_confirmation_returns_false_when_user_answers_false(): """Returns False when the user accepts but answers False (does not want to reset).""" - from unittest.mock import AsyncMock, MagicMock from unraid_mcp.core.setup import elicit_reset_confirmation @@ -438,7 +427,6 @@ async def test_elicit_reset_confirmation_returns_false_when_user_answers_false() @pytest.mark.asyncio async def test_elicit_reset_confirmation_returns_false_when_declined(): """Returns False when the user declines via action (dismisses the prompt).""" - from unittest.mock import AsyncMock, MagicMock from unraid_mcp.core.setup import elicit_reset_confirmation @@ -454,7 +442,6 @@ async def test_elicit_reset_confirmation_returns_false_when_declined(): @pytest.mark.asyncio async def test_elicit_reset_confirmation_returns_false_when_cancelled(): """Returns False when the user cancels the prompt.""" - from unittest.mock import AsyncMock, MagicMock from unraid_mcp.core.setup import elicit_reset_confirmation @@ -468,13 +455,13 @@ async def test_elicit_reset_confirmation_returns_false_when_cancelled(): @pytest.mark.asyncio -async def test_elicit_reset_confirmation_returns_true_when_not_implemented(): - """Returns True (proceed with reset) when the MCP client does not support elicitation. +async def test_elicit_reset_confirmation_returns_false_when_not_implemented(): + """Returns False (decline reset) when the MCP client does not support elicitation. - Non-interactive clients (stdio, CI) must not be permanently blocked from - reconfiguring credentials just because they can't ask the user a yes/no question. + Auto-approving a destructive credential reset on non-interactive clients would + silently overwrite working credentials. Callers must use a client that supports + elicitation or configure credentials directly via the .env file. """ - from unittest.mock import AsyncMock, MagicMock from unraid_mcp.core.setup import elicit_reset_confirmation @@ -482,13 +469,12 @@ async def test_elicit_reset_confirmation_returns_true_when_not_implemented(): mock_ctx.elicit = AsyncMock(side_effect=NotImplementedError("elicitation not supported")) result = await elicit_reset_confirmation(mock_ctx, "https://example.com") - assert result is True + assert result is False @pytest.mark.asyncio async def test_elicit_reset_confirmation_includes_current_url_in_prompt(): """The elicitation message includes the current URL so the user knows what they're replacing.""" - from unittest.mock import AsyncMock, MagicMock from unraid_mcp.core.setup import elicit_reset_confirmation @@ -507,8 +493,6 @@ async def test_elicit_reset_confirmation_includes_current_url_in_prompt(): @pytest.mark.asyncio async def test_credentials_not_configured_surfaces_as_tool_error_with_path(): """CredentialsNotConfiguredError from a tool becomes ToolError with the credentials path.""" - from unittest.mock import AsyncMock, patch - from tests.conftest import make_tool_fn from unraid_mcp.config.settings import CREDENTIALS_ENV_PATH from unraid_mcp.core.exceptions import CredentialsNotConfiguredError, ToolError diff --git a/tests/test_storage.py b/tests/test_storage.py index fcf4822..a741d35 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -56,11 +56,13 @@ class TestStorageValidation: tool_fn = _make_tool() with pytest.raises(ToolError, match="log_path"): await tool_fn(action="disk", subaction="logs") + _mock_graphql.assert_not_awaited() async def test_logs_rejects_invalid_path(self, _mock_graphql: AsyncMock) -> None: tool_fn = _make_tool() with pytest.raises(ToolError, match="log_path must start with"): await tool_fn(action="disk", subaction="logs", log_path="/etc/shadow") + _mock_graphql.assert_not_awaited() async def test_logs_rejects_path_traversal(self, _mock_graphql: AsyncMock) -> None: tool_fn = _make_tool() @@ -70,6 +72,7 @@ class TestStorageValidation: # 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") + _mock_graphql.assert_not_awaited() async def test_logs_allows_valid_paths(self, _mock_graphql: AsyncMock) -> None: _mock_graphql.return_value = {"logFile": {"path": "/var/log/syslog", "content": "ok"}} @@ -83,11 +86,13 @@ class TestStorageValidation: await tool_fn( action="disk", subaction="logs", log_path="/var/log/syslog", tail_lines=10_001 ) + _mock_graphql.assert_not_awaited() async def test_logs_tail_lines_zero_rejected(self, _mock_graphql: AsyncMock) -> None: tool_fn = _make_tool() with pytest.raises(ToolError, match="tail_lines must be between"): await tool_fn(action="disk", subaction="logs", log_path="/var/log/syslog", tail_lines=0) + _mock_graphql.assert_not_awaited() async def test_logs_tail_lines_at_max_accepted(self, _mock_graphql: AsyncMock) -> None: _mock_graphql.return_value = {"logFile": {"path": "/var/log/syslog", "content": "ok"}} diff --git a/unraid_mcp/config/settings.py b/unraid_mcp/config/settings.py index 6523d6c..809f8cd 100644 --- a/unraid_mcp/config/settings.py +++ b/unraid_mcp/config/settings.py @@ -5,6 +5,7 @@ and provides all configuration constants used throughout the application. """ import os +import sys from pathlib import Path from typing import Any @@ -51,13 +52,9 @@ def _parse_port(env_var: str, default: int) -> int: try: port = int(raw) except ValueError: - import sys - print(f"FATAL: {env_var}={raw!r} is not a valid integer port number", file=sys.stderr) sys.exit(1) if not (1 <= port <= 65535): - import sys - print(f"FATAL: {env_var}={port} outside valid port range 1-65535", file=sys.stderr) sys.exit(1) return port @@ -65,7 +62,7 @@ def _parse_port(env_var: str, default: int) -> int: UNRAID_MCP_PORT = _parse_port("UNRAID_MCP_PORT", 6970) UNRAID_MCP_HOST = os.getenv("UNRAID_MCP_HOST", "0.0.0.0") # noqa: S104 — intentional for Docker -UNRAID_MCP_TRANSPORT = os.getenv("UNRAID_MCP_TRANSPORT", "streamable-http").lower() +UNRAID_MCP_TRANSPORT = os.getenv("UNRAID_MCP_TRANSPORT", "stdio").lower() # SSL Configuration raw_verify_ssl = os.getenv("UNRAID_VERIFY_SSL", "true").lower() @@ -76,41 +73,6 @@ 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: /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") @@ -190,10 +152,6 @@ 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(), } diff --git a/unraid_mcp/core/setup.py b/unraid_mcp/core/setup.py index 20d3661..ddad82c 100644 --- a/unraid_mcp/core/setup.py +++ b/unraid_mcp/core/setup.py @@ -52,13 +52,16 @@ async def elicit_reset_confirmation(ctx: Context | None, current_url: str) -> bo response_type=bool, ) except NotImplementedError: - # Client doesn't support elicitation — treat as "proceed with reset" so - # non-interactive clients (stdio, CI) are not permanently blocked from - # reconfiguring credentials. + # Client doesn't support elicitation — return False (decline the reset). + # Auto-approving a destructive credential reset on non-interactive clients + # could silently overwrite working credentials; callers must use a client + # that supports elicitation or configure credentials directly in the .env file. logger.warning( - "MCP client does not support elicitation for reset confirmation — proceeding with reset." + "MCP client does not support elicitation for reset confirmation — declining reset. " + "To reconfigure credentials, edit %s directly.", + CREDENTIALS_ENV_PATH, ) - return True + return False if result.action != "accept": logger.info("Credential reset declined by user (%s).", result.action) diff --git a/unraid_mcp/server.py b/unraid_mcp/server.py index 5b41b49..eed6f52 100644 --- a/unraid_mcp/server.py +++ b/unraid_mcp/server.py @@ -4,20 +4,16 @@ 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 from fastmcp.server.middleware.rate_limiting import SlidingWindowRateLimitingMiddleware from fastmcp.server.middleware.response_limiting import ResponseLimitingMiddleware -from .config.logging import logger +from .config.logging import log_configuration_status, logger from .config.settings import ( LOG_LEVEL_STR, UNRAID_MCP_HOST, @@ -49,10 +45,14 @@ _error_middleware = ErrorHandlingMiddleware( include_traceback=LOG_LEVEL_STR == "DEBUG", ) -# 3. Unraid API rate limit: 100 requests per 10 seconds. -# 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. +# 3. Rate limiting: 540 requests per 60-second sliding window. +# SlidingWindowRateLimitingMiddleware only supports window_minutes (int), so the +# upstream Unraid "100 req/10 s" burst limit cannot be enforced exactly here. +# 540 req/min is a conservative 1-minute equivalent that prevents sustained +# overload while staying well under the 600 req/min ceiling. +# Note: this does NOT cap bursts within a 10 s window; a client can still send +# up to 540 requests in the first 10 s of a window. Add a sub-minute rate limiter +# in front of this server (e.g. nginx limit_req) if tighter burst control is needed. _rate_limiter = SlidingWindowRateLimitingMiddleware(max_requests=540, window_minutes=1) # 4. Cap tool responses at 512 KB to protect the client context window. @@ -80,117 +80,13 @@ _cache_middleware = ResponseCachingMiddleware( ) -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 - - 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 +# Initialize FastMCP instance — no built-in auth. +# Authentication is delegated to an external OAuth gateway (nginx, Caddy, +# Authelia, Authentik, etc.) placed in front of this server. 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, @@ -238,9 +134,6 @@ def run_server() -> None: "Server will prompt for credentials on first tool call via elicitation." ) - # Log configuration (delegated to shared function) - from .config.logging import log_configuration_status - log_configuration_status(logger) if UNRAID_VERIFY_SSL is False: @@ -250,25 +143,11 @@ def run_server() -> None: "Only use this in trusted networks or for development." ) - 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: + if UNRAID_MCP_TRANSPORT in ("streamable-http", "sse"): 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." + "⚠️ NO AUTHENTICATION — HTTP server is open to all clients on the network. " + "Protect this server with an external OAuth gateway (nginx, Caddy, Authelia, Authentik) " + "or restrict access at the network layer (firewall, VPN, Tailscale)." ) logger.info( @@ -276,13 +155,17 @@ def run_server() -> None: ) try: - if UNRAID_MCP_TRANSPORT == "streamable-http": + if UNRAID_MCP_TRANSPORT in ("streamable-http", "sse"): + if UNRAID_MCP_TRANSPORT == "sse": + logger.warning( + "SSE transport is deprecated. Consider switching to 'streamable-http'." + ) mcp.run( - transport="streamable-http", host=UNRAID_MCP_HOST, port=UNRAID_MCP_PORT, path="/mcp" + transport=UNRAID_MCP_TRANSPORT, + host=UNRAID_MCP_HOST, + port=UNRAID_MCP_PORT, + path="/mcp", ) - elif UNRAID_MCP_TRANSPORT == "sse": - logger.warning("SSE transport is deprecated. Consider switching to 'streamable-http'.") - mcp.run(transport="sse", host=UNRAID_MCP_HOST, port=UNRAID_MCP_PORT, path="/mcp") elif UNRAID_MCP_TRANSPORT == "stdio": mcp.run() else: diff --git a/unraid_mcp/subscriptions/resources.py b/unraid_mcp/subscriptions/resources.py index cb25463..9ae8295 100644 --- a/unraid_mcp/subscriptions/resources.py +++ b/unraid_mcp/subscriptions/resources.py @@ -7,7 +7,8 @@ and the MCP protocol, providing fallback queries when subscription data is unava import asyncio import json import os -from typing import Final +from collections.abc import Callable, Coroutine +from typing import Any, Final import anyio from fastmcp import FastMCP @@ -22,6 +23,8 @@ from .snapshot import subscribe_once _subscriptions_started = False _startup_lock: Final[asyncio.Lock] = asyncio.Lock() +_terminal_states = frozenset({"failed", "auth_failed", "max_retries_exceeded"}) + async def ensure_subscriptions_started() -> None: """Ensure subscriptions are started, called from async context.""" @@ -104,15 +107,17 @@ def register_subscription_resources(mcp: FastMCP) -> None: } ) - def _make_resource_fn(action: str): + def _make_resource_fn(action: str) -> Callable[[], Coroutine[Any, Any, str]]: async def _live_resource() -> str: await ensure_subscriptions_started() data = await subscription_manager.get_resource_data(action) if data is not None: return json.dumps(data, indent=2) - # Surface permanent errors instead of reporting "connecting" indefinitely + # Surface permanent errors only when the connection is in a terminal failure + # state — if the subscription has since reconnected, ignore the stale error. last_error = subscription_manager.last_error.get(action) - if last_error: + conn_state = subscription_manager.connection_states.get(action, "") + if last_error and conn_state in _terminal_states: return json.dumps( { "status": "error", diff --git a/unraid_mcp/tools/unraid.py b/unraid_mcp/tools/unraid.py index 04baf8a..3ce91e8 100644 --- a/unraid_mcp/tools/unraid.py +++ b/unraid_mcp/tools/unraid.py @@ -792,15 +792,22 @@ def _find_container( if strict: return None id_lower = identifier.lower() - for c in containers: - for name in c.get("names", []): - if name.lower().startswith(id_lower): - return c - for c in containers: - for name in c.get("names", []): - if id_lower in name.lower(): - return c - return None + # Collect prefix matches first, then fall back to substring matches. + prefix_matches = [ + c for c in containers if any(n.lower().startswith(id_lower) for n in c.get("names", [])) + ] + candidates = prefix_matches or [ + c for c in containers if any(id_lower in n.lower() for n in c.get("names", [])) + ] + if not candidates: + return None + if len(candidates) == 1: + return candidates[0] + names = [n for c in candidates for n in c.get("names", [])] + raise ToolError( + f"Container identifier '{identifier}' is ambiguous — matches: {', '.join(names[:10])}. " + "Use a more specific name or the full container ID." + ) async def _resolve_container_id(container_id: str, *, strict: bool = False) -> str: @@ -1258,6 +1265,8 @@ async def _handle_key( input_data["name"] = name if roles is not None: input_data["roles"] = roles + if permissions is not None: + input_data["permissions"] = permissions data = await make_graphql_request(_KEY_MUTATIONS["update"], {"input": input_data}) updated_key = (data.get("apiKey") or {}).get("update") if not updated_key: @@ -1277,7 +1286,7 @@ async def _handle_key( if subaction in ("add_role", "remove_role"): if not key_id: raise ToolError(f"key_id is required for key/{subaction}") - if not roles or len(roles) == 0: + if not roles: raise ToolError( f"roles is required for key/{subaction} (pass as roles=['ROLE_NAME'])" )