mirror of
https://github.com/jmagar/unraid-mcp.git
synced 2026-03-25 21:34:12 -07:00
refactor: remove Docker and HTTP transport support, fix hypothesis cache directory
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
51
.env.example
51
.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: <UNRAID_MCP_BASE_URL>/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=<generate with command above>
|
||||
|
||||
# API Key Authentication (Optional)
|
||||
# -----------------------------------
|
||||
# Alternative to Google OAuth — clients present this key as a bearer token:
|
||||
# Authorization: Bearer <UNRAID_MCP_API_KEY>
|
||||
#
|
||||
# Can be the same value as UNRAID_API_KEY (reuse your Unraid key), or a
|
||||
# separate dedicated secret. Set both GOOGLE_CLIENT_ID and UNRAID_MCP_API_KEY
|
||||
# to accept either auth method (MultiAuth).
|
||||
#
|
||||
# Leave empty to disable API key auth.
|
||||
# UNRAID_MCP_API_KEY=
|
||||
# stdio transport (default) is inherently local — no network exposure.
|
||||
|
||||
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
90
CLAUDE.md
90
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: `<UNRAID_MCP_BASE_URL>/auth/callback`
|
||||
|
||||
**API Key** — clients present as `Authorization: Bearer <key>`:
|
||||
|
||||
| Env Var | Purpose |
|
||||
|---------|---------|
|
||||
| `UNRAID_MCP_API_KEY` | Static bearer token (can be same value as `UNRAID_API_KEY`) |
|
||||
|
||||
**Generate a stable JWT signing key:**
|
||||
```bash
|
||||
python3 -c "import secrets; print(secrets.token_hex(32))"
|
||||
```
|
||||
|
||||
**Omit all auth vars to run without auth** (default — open server).
|
||||
|
||||
**Full guide:** [`docs/AUTHENTICATION.md`](docs/AUTHENTICATION.md)
|
||||
|
||||
## Architecture
|
||||
|
||||
### 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`).
|
||||
|
||||
48
Dockerfile
48
Dockerfile
@@ -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"]
|
||||
140
README.md
140
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: `<UNRAID_MCP_BASE_URL>/auth/callback`
|
||||
3. Copy Client ID + Secret into `~/.unraid-mcp/.env`
|
||||
4. Generate a signing key: `python3 -c "import secrets; print(secrets.token_hex(32))"`
|
||||
5. Restart the server
|
||||
|
||||
### API Key (Bearer Token)
|
||||
|
||||
Simpler option for headless/machine access — no browser flow required:
|
||||
|
||||
```bash
|
||||
# Add to ~/.unraid-mcp/.env
|
||||
UNRAID_MCP_API_KEY=your-secret-token # can be same value as UNRAID_API_KEY
|
||||
```
|
||||
|
||||
Clients present it as `Authorization: Bearer <UNRAID_MCP_API_KEY>`. Set both `GOOGLE_CLIENT_ID` and `UNRAID_MCP_API_KEY` to accept either method simultaneously.
|
||||
|
||||
Omit both to run without authentication (default — open server).
|
||||
|
||||
**Full guide:** [`docs/AUTHENTICATION.md`](docs/AUTHENTICATION.md)
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Available Tools & Resources
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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://<your-server-ip>:6970/auth/callback
|
||||
```
|
||||
Replace `<your-server-ip>` with the IP/hostname your browser uses to reach the MCP server (e.g. `10.1.0.2`, `100.x.x.x` for Tailscale, or a domain name).
|
||||
6. Click **Create** — copy the **Client ID** and **Client Secret**
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Configure Environment Variables
|
||||
|
||||
Add these to `~/.unraid-mcp/.env` (the canonical credential file for all runtimes):
|
||||
|
||||
```bash
|
||||
# Google OAuth (optional — enables authentication)
|
||||
GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com
|
||||
GOOGLE_CLIENT_SECRET=GOCSPX-your-client-secret
|
||||
|
||||
# Public base URL of this MCP server (must match the redirect URI above)
|
||||
UNRAID_MCP_BASE_URL=http://10.1.0.2:6970
|
||||
|
||||
# Stable JWT signing key — prevents token invalidation on server restart
|
||||
# Generate one: python3 -c "import secrets; print(secrets.token_hex(32))"
|
||||
UNRAID_MCP_JWT_SIGNING_KEY=your-64-char-hex-string
|
||||
```
|
||||
|
||||
**All four variables at once** (copy-paste template):
|
||||
|
||||
```bash
|
||||
cat >> ~/.unraid-mcp/.env <<'EOF'
|
||||
|
||||
# Google OAuth
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
UNRAID_MCP_BASE_URL=http://10.1.0.2:6970
|
||||
UNRAID_MCP_JWT_SIGNING_KEY=
|
||||
EOF
|
||||
```
|
||||
|
||||
Then fill in the blanks.
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Generate a Stable JWT Signing Key
|
||||
|
||||
Without `UNRAID_MCP_JWT_SIGNING_KEY`, FastMCP derives a key on startup. Any server restart invalidates all existing tokens and forces every client to re-authenticate.
|
||||
|
||||
Generate a stable key once:
|
||||
|
||||
```bash
|
||||
python3 -c "import secrets; print(secrets.token_hex(32))"
|
||||
```
|
||||
|
||||
Paste the output into `UNRAID_MCP_JWT_SIGNING_KEY`. This value never needs to change unless you intentionally want to invalidate all sessions.
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Restart the Server
|
||||
|
||||
```bash
|
||||
# Docker Compose
|
||||
docker compose restart unraid-mcp
|
||||
|
||||
# Direct / uv
|
||||
uv run unraid-mcp-server
|
||||
```
|
||||
|
||||
On startup you should see:
|
||||
|
||||
```
|
||||
INFO [SERVER] Google OAuth enabled — base_url=http://10.1.0.2:6970, redirect_uri=http://10.1.0.2:6970/auth/callback
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## How Authentication Works
|
||||
|
||||
1. An MCP client connects to `http://<server>:6970/mcp`
|
||||
2. The server responds with a `401 Unauthorized` and an OAuth authorization URL
|
||||
3. The client opens the URL in a browser; the user logs in with Google
|
||||
4. Google redirects to `<UNRAID_MCP_BASE_URL>/auth/callback` with an authorization code
|
||||
5. FastMCP exchanges the code for tokens, issues a signed JWT, and returns it to the client
|
||||
6. The client includes the JWT in subsequent requests — the server validates it without hitting Google again
|
||||
7. Tokens persist to `~/.local/share/fastmcp/oauth-proxy/` — sessions survive server restarts
|
||||
|
||||
---
|
||||
|
||||
## Environment Variable Reference
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|----------|----------|---------|-------------|
|
||||
| `GOOGLE_CLIENT_ID` | For OAuth | `""` | OAuth 2.0 Client ID from Google Cloud Console |
|
||||
| `GOOGLE_CLIENT_SECRET` | For OAuth | `""` | OAuth 2.0 Client Secret from Google Cloud Console |
|
||||
| `UNRAID_MCP_BASE_URL` | For OAuth | `""` | Public base URL of this server — must match the authorized redirect URI |
|
||||
| `UNRAID_MCP_JWT_SIGNING_KEY` | Recommended | auto-derived | Stable 32+ char secret for JWT signing — prevents token invalidation on restart |
|
||||
|
||||
OAuth is activated only when **all three** of `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, and `UNRAID_MCP_BASE_URL` are non-empty. Omit any one to run without authentication.
|
||||
|
||||
---
|
||||
|
||||
## Disabling OAuth
|
||||
|
||||
Remove (or empty) `GOOGLE_CLIENT_ID` from `~/.unraid-mcp/.env` and restart. The server reverts to unauthenticated mode and logs:
|
||||
|
||||
```
|
||||
WARNING [SERVER] No authentication configured — MCP server is open to all clients on the network.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**`redirect_uri_mismatch` from Google**
|
||||
The redirect URI in Google Cloud Console must exactly match `<UNRAID_MCP_BASE_URL>/auth/callback` — same scheme, host, port, and path. Trailing slashes matter.
|
||||
|
||||
**Tokens invalidated after restart**
|
||||
Set `UNRAID_MCP_JWT_SIGNING_KEY` to a stable secret (see Step 3). Without it, FastMCP generates a new key on every start.
|
||||
|
||||
**`stdio` transport warning**
|
||||
OAuth requires an HTTP transport. Set `UNRAID_MCP_TRANSPORT=streamable-http` (the default) or `sse`.
|
||||
|
||||
**Client cannot reach the callback URL**
|
||||
`UNRAID_MCP_BASE_URL` must be the address your browser uses to reach the server — not `localhost` or `0.0.0.0`. Use the LAN IP, Tailscale IP, or a domain name.
|
||||
|
||||
**OAuth configured but server not starting**
|
||||
Check `logs/unraid-mcp.log` or `docker compose logs unraid-mcp` for startup errors.
|
||||
|
||||
---
|
||||
|
||||
## API Key Authentication (Alternative / Combined)
|
||||
|
||||
For machine-to-machine access (scripts, CI, other agents) without a browser-based OAuth flow, set `UNRAID_MCP_API_KEY`:
|
||||
|
||||
```bash
|
||||
# In ~/.unraid-mcp/.env
|
||||
UNRAID_MCP_API_KEY=your-secret-token
|
||||
```
|
||||
|
||||
Clients present it as a standard bearer token:
|
||||
|
||||
```
|
||||
Authorization: Bearer your-secret-token
|
||||
```
|
||||
|
||||
**Combining with Google OAuth**: set both `GOOGLE_CLIENT_ID` and `UNRAID_MCP_API_KEY`. The server activates `MultiAuth` and accepts either method — Google OAuth for interactive clients, API key for headless clients.
|
||||
|
||||
**Reusing the Unraid API key**: you can set `UNRAID_MCP_API_KEY` to the same value as `UNRAID_API_KEY` for simplicity. The two vars are kept separate so each concern has its own name.
|
||||
|
||||
**Standalone API key** (no Google OAuth): set only `UNRAID_MCP_API_KEY`. The server validates bearer tokens directly with no OAuth redirect flow.
|
||||
|
||||
---
|
||||
|
||||
## Security Notes
|
||||
|
||||
- OAuth protects the MCP HTTP interface — the Unraid GraphQL API itself still uses `UNRAID_API_KEY`
|
||||
- OAuth state files at `~/.local/share/fastmcp/oauth-proxy/` should be on a private filesystem; do not expose them
|
||||
- Restrict Google OAuth to specific accounts via the Google Cloud Console **OAuth consent screen** → **Test users** if you don't want to publish the app
|
||||
- `UNRAID_MCP_JWT_SIGNING_KEY` is a credential — store it in `~/.unraid-mcp/.env` (mode 600), never in source control
|
||||
@@ -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":"<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":"<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":["<plugin-name>"],"confirm":true}' --output json
|
||||
```
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 <tool> <json-args>
|
||||
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
|
||||
@@ -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
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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"}'
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"}}
|
||||
|
||||
@@ -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: <UNRAID_MCP_BASE_URL>/auth/callback
|
||||
GOOGLE_CLIENT_ID = os.getenv("GOOGLE_CLIENT_ID", "")
|
||||
GOOGLE_CLIENT_SECRET = os.getenv("GOOGLE_CLIENT_SECRET", "")
|
||||
UNRAID_MCP_BASE_URL = os.getenv("UNRAID_MCP_BASE_URL", "")
|
||||
|
||||
# JWT signing key for FastMCP OAuth tokens.
|
||||
# MUST be set to a stable secret so tokens survive server restarts.
|
||||
# Generate once: python3 -c "import secrets; print(secrets.token_hex(32))"
|
||||
# Never change this value — all existing tokens will be invalidated.
|
||||
UNRAID_MCP_JWT_SIGNING_KEY = os.getenv("UNRAID_MCP_JWT_SIGNING_KEY", "")
|
||||
|
||||
|
||||
def is_google_auth_configured() -> bool:
|
||||
"""Return True when all required Google OAuth vars are present."""
|
||||
return bool(GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET and UNRAID_MCP_BASE_URL)
|
||||
|
||||
|
||||
# API Key Authentication (Optional)
|
||||
# ----------------------------------
|
||||
# A static bearer token clients can use instead of (or alongside) Google OAuth.
|
||||
# Can be set to the same value as UNRAID_API_KEY for simplicity, or a separate
|
||||
# dedicated secret for MCP access.
|
||||
UNRAID_MCP_API_KEY = os.getenv("UNRAID_MCP_API_KEY", "")
|
||||
|
||||
|
||||
def is_api_key_auth_configured() -> bool:
|
||||
"""Return True when UNRAID_MCP_API_KEY is set."""
|
||||
return bool(UNRAID_MCP_API_KEY)
|
||||
|
||||
|
||||
# Logging Configuration
|
||||
LOG_LEVEL_STR = os.getenv("UNRAID_MCP_LOG_LEVEL", "INFO").upper()
|
||||
LOG_FILE_NAME = os.getenv("UNRAID_MCP_LOG_FILE", "unraid-mcp.log")
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 <UNRAID_MCP_API_KEY>
|
||||
|
||||
This allows machine-to-machine access (e.g. CI, scripts, other agents)
|
||||
without going through the Google OAuth browser flow.
|
||||
"""
|
||||
|
||||
def __init__(self, api_key: str) -> None:
|
||||
super().__init__()
|
||||
self._api_key = api_key
|
||||
|
||||
async def verify_token(self, token: str) -> AccessToken | None:
|
||||
if self._api_key and hmac.compare_digest(token.encode(), self._api_key.encode()):
|
||||
return AccessToken(
|
||||
token=token,
|
||||
client_id="api-key-client",
|
||||
scopes=[],
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _build_google_auth() -> "GoogleProvider | None":
|
||||
"""Build GoogleProvider when OAuth env vars are configured, else return None.
|
||||
|
||||
Returns None (no auth) when GOOGLE_CLIENT_ID or GOOGLE_CLIENT_SECRET are absent,
|
||||
preserving backward compatibility for existing unprotected setups.
|
||||
"""
|
||||
from .config.settings import (
|
||||
GOOGLE_CLIENT_ID,
|
||||
GOOGLE_CLIENT_SECRET,
|
||||
UNRAID_MCP_BASE_URL,
|
||||
UNRAID_MCP_JWT_SIGNING_KEY,
|
||||
UNRAID_MCP_TRANSPORT,
|
||||
is_google_auth_configured,
|
||||
)
|
||||
|
||||
if not is_google_auth_configured():
|
||||
return None
|
||||
|
||||
if UNRAID_MCP_TRANSPORT == "stdio":
|
||||
logger.warning(
|
||||
"Google OAuth is configured but UNRAID_MCP_TRANSPORT=stdio. "
|
||||
"OAuth requires HTTP transport (streamable-http or sse). "
|
||||
"Auth will be applied but may not work as expected."
|
||||
)
|
||||
|
||||
kwargs: dict[str, Any] = {
|
||||
"client_id": GOOGLE_CLIENT_ID,
|
||||
"client_secret": GOOGLE_CLIENT_SECRET,
|
||||
"base_url": UNRAID_MCP_BASE_URL,
|
||||
# Prefer short-lived access tokens without refresh-token rotation churn.
|
||||
# This reduces reconnect instability in MCP clients that re-auth frequently.
|
||||
"extra_authorize_params": {"access_type": "online", "prompt": "consent"},
|
||||
# Skip the FastMCP consent page — goes directly to Google.
|
||||
# The consent page has a CSRF double-load race: two concurrent GET requests
|
||||
# each regenerate the CSRF token, the second overwrites the first in the
|
||||
# transaction store, and the POST fails with "Invalid or expired consent token".
|
||||
"require_authorization_consent": False,
|
||||
}
|
||||
if UNRAID_MCP_JWT_SIGNING_KEY:
|
||||
kwargs["jwt_signing_key"] = UNRAID_MCP_JWT_SIGNING_KEY
|
||||
else:
|
||||
logger.warning(
|
||||
"UNRAID_MCP_JWT_SIGNING_KEY is not set. FastMCP will derive a key automatically, "
|
||||
"but tokens may be invalidated on server restart. "
|
||||
"Set UNRAID_MCP_JWT_SIGNING_KEY to a stable secret."
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Google OAuth enabled — base_url={UNRAID_MCP_BASE_URL}, "
|
||||
f"redirect_uri={UNRAID_MCP_BASE_URL}/auth/callback"
|
||||
)
|
||||
return GoogleProvider(**kwargs)
|
||||
|
||||
|
||||
def _build_auth() -> "GoogleProvider | ApiKeyVerifier | MultiAuth | None":
|
||||
"""Build the active auth stack from environment configuration.
|
||||
|
||||
Returns:
|
||||
- MultiAuth(server=GoogleProvider, verifiers=[ApiKeyVerifier])
|
||||
when both GOOGLE_CLIENT_ID and UNRAID_MCP_API_KEY are set.
|
||||
- GoogleProvider alone when only Google OAuth vars are set.
|
||||
- ApiKeyVerifier alone when only UNRAID_MCP_API_KEY is set.
|
||||
- None when no auth vars are configured (open server).
|
||||
"""
|
||||
from .config.settings import UNRAID_MCP_API_KEY, is_api_key_auth_configured
|
||||
|
||||
google = _build_google_auth()
|
||||
api_key = ApiKeyVerifier(UNRAID_MCP_API_KEY) if is_api_key_auth_configured() else None
|
||||
|
||||
if google and api_key:
|
||||
logger.info("Auth: Google OAuth + API key both enabled (MultiAuth)")
|
||||
return MultiAuth(server=google, verifiers=[api_key])
|
||||
if api_key:
|
||||
logger.info("Auth: API key authentication enabled")
|
||||
return api_key
|
||||
return google # GoogleProvider or None
|
||||
|
||||
|
||||
# Build auth stack — GoogleProvider, ApiKeyVerifier, MultiAuth, or None.
|
||||
_auth = _build_auth()
|
||||
|
||||
# Initialize FastMCP instance
|
||||
# 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":
|
||||
mcp.run(
|
||||
transport="streamable-http", host=UNRAID_MCP_HOST, port=UNRAID_MCP_PORT, path="/mcp"
|
||||
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=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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
# 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'])"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user