mirror of
https://github.com/jmagar/unraid-mcp.git
synced 2026-03-23 04:29:17 -07:00
Compare commits
14 Commits
refactor/c
...
feat/googl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d59f8c22a8 | ||
|
|
cc24f1ec62 | ||
|
|
6f7a58a0f9 | ||
|
|
440245108a | ||
|
|
9754261402 | ||
|
|
9e9915b2fa | ||
|
|
2ab61be2df | ||
|
|
b319cf4932 | ||
|
|
0f46cb9713 | ||
|
|
1248ccd53e | ||
|
|
4a1ffcfd51 | ||
|
|
f69aa94826 | ||
|
|
5187cf730f | ||
|
|
896fc8db1b |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "unraid",
|
||||
"description": "Query and monitor Unraid servers via GraphQL API - array status, disk health, containers, VMs, system monitoring",
|
||||
"version": "1.0.1",
|
||||
"version": "1.1.0",
|
||||
"author": {
|
||||
"name": "jmagar",
|
||||
"email": "jmagar@users.noreply.github.com"
|
||||
|
||||
39
.env.example
39
.env.example
@@ -35,3 +35,42 @@ UNRAID_MAX_RECONNECT_ATTEMPTS=10
|
||||
# Optional: Custom log file path for subscription auto-start diagnostics
|
||||
# Defaults to standard log if not specified
|
||||
# UNRAID_AUTOSTART_LOG_PATH=/custom/path/to/autostart.log
|
||||
|
||||
# Google OAuth Protection (Optional)
|
||||
# -----------------------------------
|
||||
# Protects the MCP HTTP server — clients must authenticate with Google before calling tools.
|
||||
# Requires streamable-http or sse transport (not stdio).
|
||||
#
|
||||
# Setup:
|
||||
# 1. Google Cloud Console → APIs & Services → Credentials
|
||||
# 2. Create OAuth 2.0 Client ID (Web application)
|
||||
# 3. Authorized redirect URIs: <UNRAID_MCP_BASE_URL>/auth/callback
|
||||
# 4. Copy Client ID and Client Secret below
|
||||
#
|
||||
# UNRAID_MCP_BASE_URL: Public URL clients use to reach THIS server (for redirect URIs).
|
||||
# Examples:
|
||||
# http://10.1.0.2:6970 (LAN)
|
||||
# http://100.x.x.x:6970 (Tailscale)
|
||||
# https://mcp.yourdomain.com (reverse proxy)
|
||||
#
|
||||
# UNRAID_MCP_JWT_SIGNING_KEY: Stable secret for signing FastMCP JWT tokens.
|
||||
# Generate once: python3 -c "import secrets; print(secrets.token_hex(32))"
|
||||
# NEVER change after first use — all client sessions will be invalidated.
|
||||
#
|
||||
# Leave GOOGLE_CLIENT_ID empty to disable OAuth (server runs unprotected).
|
||||
# GOOGLE_CLIENT_ID=
|
||||
# GOOGLE_CLIENT_SECRET=
|
||||
# UNRAID_MCP_BASE_URL=http://10.1.0.2:6970
|
||||
# UNRAID_MCP_JWT_SIGNING_KEY=<generate with command above>
|
||||
|
||||
# API Key Authentication (Optional)
|
||||
# -----------------------------------
|
||||
# Alternative to Google OAuth — clients present this key as a bearer token:
|
||||
# Authorization: Bearer <UNRAID_MCP_API_KEY>
|
||||
#
|
||||
# Can be the same value as UNRAID_API_KEY (reuse your Unraid key), or a
|
||||
# separate dedicated secret. Set both GOOGLE_CLIENT_ID and UNRAID_MCP_API_KEY
|
||||
# to accept either auth method (MultiAuth).
|
||||
#
|
||||
# Leave empty to disable API key auth.
|
||||
# UNRAID_MCP_API_KEY=
|
||||
30
CLAUDE.md
30
CLAUDE.md
@@ -61,6 +61,36 @@ docker compose down
|
||||
- `UNRAID_MCP_PORT`: Server port (default: 6970)
|
||||
- `UNRAID_MCP_HOST`: Server host (default: 0.0.0.0)
|
||||
|
||||
### 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
|
||||
|
||||
69
README.md
69
README.md
@@ -14,7 +14,7 @@
|
||||
- 🔄 **Real-time Data**: WebSocket subscriptions for live metrics, logs, array state, and more
|
||||
- 📊 **Health Monitoring**: Comprehensive system diagnostics and status
|
||||
- 🐳 **Docker Ready**: Full containerization support with Docker Compose
|
||||
- 🔒 **Secure**: Proper SSL/TLS configuration and API key management
|
||||
- 🔒 **Secure**: Optional Google OAuth 2.0 authentication + SSL/TLS + API key management
|
||||
- 📝 **Rich Logging**: Structured logging with rotation and multiple levels
|
||||
|
||||
---
|
||||
@@ -25,6 +25,7 @@
|
||||
- [Quick Start](#-quick-start)
|
||||
- [Installation](#-installation)
|
||||
- [Configuration](#-configuration)
|
||||
- [Google OAuth](#-google-oauth-optional)
|
||||
- [Available Tools & Resources](#-available-tools--resources)
|
||||
- [Development](#-development)
|
||||
- [Architecture](#-architecture)
|
||||
@@ -229,7 +230,7 @@ UNRAID_VERIFY_SSL=true # true, false, or path to CA bundle
|
||||
|
||||
# Subscription Configuration
|
||||
UNRAID_AUTO_START_SUBSCRIPTIONS=true # Auto-start WebSocket subscriptions on startup (default: true)
|
||||
UNRAID_MAX_RECONNECT_ATTEMPTS=5 # Max WebSocket reconnection attempts (default: 5)
|
||||
UNRAID_MAX_RECONNECT_ATTEMPTS=10 # Max WebSocket reconnection attempts (default: 10)
|
||||
|
||||
# Optional: Log Stream Configuration
|
||||
# UNRAID_AUTOSTART_LOG_PATH=/var/log/syslog # Path for log streaming resource (unraid://logs/stream)
|
||||
@@ -245,6 +246,46 @@ UNRAID_MAX_RECONNECT_ATTEMPTS=5 # Max WebSocket reconnection attempts (def
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Authentication (Optional)
|
||||
|
||||
Two independent auth methods — use either or both.
|
||||
|
||||
### Google OAuth
|
||||
|
||||
Protect the HTTP server with Google OAuth 2.0 — clients must complete a Google login before any tool call is executed.
|
||||
|
||||
```bash
|
||||
# Add to ~/.unraid-mcp/.env
|
||||
GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com
|
||||
GOOGLE_CLIENT_SECRET=GOCSPX-your-secret
|
||||
UNRAID_MCP_BASE_URL=http://10.1.0.2:6970 # public URL of this server
|
||||
UNRAID_MCP_JWT_SIGNING_KEY=<64-char-hex> # prevents token invalidation on restart
|
||||
```
|
||||
|
||||
**Quick setup:**
|
||||
1. [Google Cloud Console](https://console.cloud.google.com/) → Credentials → OAuth 2.0 Client ID (Web application)
|
||||
2. Authorized redirect URI: `<UNRAID_MCP_BASE_URL>/auth/callback`
|
||||
3. Copy Client ID + Secret into `~/.unraid-mcp/.env`
|
||||
4. Generate a signing key: `python3 -c "import secrets; print(secrets.token_hex(32))"`
|
||||
5. Restart the server
|
||||
|
||||
### API Key (Bearer Token)
|
||||
|
||||
Simpler option for headless/machine access — no browser flow required:
|
||||
|
||||
```bash
|
||||
# Add to ~/.unraid-mcp/.env
|
||||
UNRAID_MCP_API_KEY=your-secret-token # can be same value as UNRAID_API_KEY
|
||||
```
|
||||
|
||||
Clients present it as `Authorization: Bearer <UNRAID_MCP_API_KEY>`. Set both `GOOGLE_CLIENT_ID` and `UNRAID_MCP_API_KEY` to accept either method simultaneously.
|
||||
|
||||
Omit both to run without authentication (default — open server).
|
||||
|
||||
**Full guide:** [`docs/AUTHENTICATION.md`](docs/AUTHENTICATION.md)
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Available Tools & Resources
|
||||
|
||||
The single `unraid` tool uses `action` (domain) + `subaction` (operation) routing to expose all operations via one MCP tool, minimizing context window usage. Destructive actions require `confirm=True`.
|
||||
@@ -299,7 +340,7 @@ The server exposes two classes of MCP resources backed by persistent WebSocket c
|
||||
**`unraid://logs/stream`** — Live log file tail (path controlled by `UNRAID_AUTOSTART_LOG_PATH`)
|
||||
|
||||
> **Note**: Resources return cached data from persistent WebSocket subscriptions. A `{"status": "connecting"}` placeholder is returned while the subscription initializes — retry in a moment.
|
||||
|
||||
>
|
||||
> **`log_tail` and `notification_feed`** are accessible as tool subactions (`unraid(action="live", subaction="log_tail")`) but are not registered as MCP resources — they use transient one-shot subscriptions and require parameters.
|
||||
|
||||
---
|
||||
@@ -399,6 +440,28 @@ uv run unraid-mcp-server
|
||||
|
||||
# Or run via module directly
|
||||
uv run -m unraid_mcp.main
|
||||
|
||||
# Hot-reload dev server (restarts on file changes)
|
||||
fastmcp run fastmcp.http.json --reload
|
||||
|
||||
# Run via named config files
|
||||
fastmcp run fastmcp.http.json # streamable-http on :6970
|
||||
fastmcp run fastmcp.stdio.json # stdio transport
|
||||
```
|
||||
|
||||
### Ad-hoc Tool Testing (fastmcp CLI)
|
||||
```bash
|
||||
# Introspect the running server
|
||||
fastmcp list http://localhost:6970/mcp
|
||||
fastmcp list http://localhost:6970/mcp --input-schema
|
||||
|
||||
# Call a tool directly (HTTP)
|
||||
fastmcp call http://localhost:6970/mcp unraid action=health subaction=check
|
||||
fastmcp call http://localhost:6970/mcp unraid action=docker subaction=list
|
||||
|
||||
# Call without a running server (stdio config)
|
||||
fastmcp list fastmcp.stdio.json
|
||||
fastmcp call fastmcp.stdio.json unraid action=health subaction=check
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
188
docs/AUTHENTICATION.md
Normal file
188
docs/AUTHENTICATION.md
Normal file
@@ -0,0 +1,188 @@
|
||||
# Google OAuth Setup Guide
|
||||
|
||||
This document explains how to protect the Unraid MCP HTTP server with Google OAuth 2.0 authentication using FastMCP's built-in `GoogleProvider`.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
By default the MCP server is **open** — any client on the network can call tools. Setting three environment variables enables Google OAuth 2.1 authentication: clients must complete a Google login flow before the server will execute any tool.
|
||||
|
||||
OAuth state (issued tokens, refresh tokens) is persisted to an encrypted file store at `~/.local/share/fastmcp/oauth-proxy/`, so sessions survive server restarts when `UNRAID_MCP_JWT_SIGNING_KEY` is set.
|
||||
|
||||
> **Transport requirement**: OAuth only works with HTTP transports (`streamable-http` or `sse`). It has no effect on `stdio` — the server logs a warning if you configure both.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Google account with access to [Google Cloud Console](https://console.cloud.google.com/)
|
||||
- MCP server reachable at a known URL from your browser (LAN IP, Tailscale IP, or public domain)
|
||||
- `UNRAID_MCP_TRANSPORT=streamable-http` (the default)
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Create a Google OAuth Client
|
||||
|
||||
1. Open [Google Cloud Console](https://console.cloud.google.com/) → **APIs & Services** → **Credentials**
|
||||
2. Click **Create Credentials** → **OAuth 2.0 Client ID**
|
||||
3. Application type: **Web application**
|
||||
4. Name: anything (e.g. `Unraid MCP`)
|
||||
5. **Authorized redirect URIs** — add exactly:
|
||||
```
|
||||
http://<your-server-ip>:6970/auth/callback
|
||||
```
|
||||
Replace `<your-server-ip>` with the IP/hostname your browser uses to reach the MCP server (e.g. `10.1.0.2`, `100.x.x.x` for Tailscale, or a domain name).
|
||||
6. Click **Create** — copy the **Client ID** and **Client Secret**
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Configure Environment Variables
|
||||
|
||||
Add these to `~/.unraid-mcp/.env` (the canonical credential file for all runtimes):
|
||||
|
||||
```bash
|
||||
# Google OAuth (optional — enables authentication)
|
||||
GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com
|
||||
GOOGLE_CLIENT_SECRET=GOCSPX-your-client-secret
|
||||
|
||||
# Public base URL of this MCP server (must match the redirect URI above)
|
||||
UNRAID_MCP_BASE_URL=http://10.1.0.2:6970
|
||||
|
||||
# Stable JWT signing key — prevents token invalidation on server restart
|
||||
# Generate one: python3 -c "import secrets; print(secrets.token_hex(32))"
|
||||
UNRAID_MCP_JWT_SIGNING_KEY=your-64-char-hex-string
|
||||
```
|
||||
|
||||
**All four variables at once** (copy-paste template):
|
||||
|
||||
```bash
|
||||
cat >> ~/.unraid-mcp/.env <<'EOF'
|
||||
|
||||
# Google OAuth
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
UNRAID_MCP_BASE_URL=http://10.1.0.2:6970
|
||||
UNRAID_MCP_JWT_SIGNING_KEY=
|
||||
EOF
|
||||
```
|
||||
|
||||
Then fill in the blanks.
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Generate a Stable JWT Signing Key
|
||||
|
||||
Without `UNRAID_MCP_JWT_SIGNING_KEY`, FastMCP derives a key on startup. Any server restart invalidates all existing tokens and forces every client to re-authenticate.
|
||||
|
||||
Generate a stable key once:
|
||||
|
||||
```bash
|
||||
python3 -c "import secrets; print(secrets.token_hex(32))"
|
||||
```
|
||||
|
||||
Paste the output into `UNRAID_MCP_JWT_SIGNING_KEY`. This value never needs to change unless you intentionally want to invalidate all sessions.
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Restart the Server
|
||||
|
||||
```bash
|
||||
# Docker Compose
|
||||
docker compose restart unraid-mcp
|
||||
|
||||
# Direct / uv
|
||||
uv run unraid-mcp-server
|
||||
```
|
||||
|
||||
On startup you should see:
|
||||
|
||||
```
|
||||
INFO [SERVER] Google OAuth enabled — base_url=http://10.1.0.2:6970, redirect_uri=http://10.1.0.2:6970/auth/callback
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## How Authentication Works
|
||||
|
||||
1. An MCP client connects to `http://<server>:6970/mcp`
|
||||
2. The server responds with a `401 Unauthorized` and an OAuth authorization URL
|
||||
3. The client opens the URL in a browser; the user logs in with Google
|
||||
4. Google redirects to `<UNRAID_MCP_BASE_URL>/auth/callback` with an authorization code
|
||||
5. FastMCP exchanges the code for tokens, issues a signed JWT, and returns it to the client
|
||||
6. The client includes the JWT in subsequent requests — the server validates it without hitting Google again
|
||||
7. Tokens persist to `~/.local/share/fastmcp/oauth-proxy/` — sessions survive server restarts
|
||||
|
||||
---
|
||||
|
||||
## Environment Variable Reference
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|----------|----------|---------|-------------|
|
||||
| `GOOGLE_CLIENT_ID` | For OAuth | `""` | OAuth 2.0 Client ID from Google Cloud Console |
|
||||
| `GOOGLE_CLIENT_SECRET` | For OAuth | `""` | OAuth 2.0 Client Secret from Google Cloud Console |
|
||||
| `UNRAID_MCP_BASE_URL` | For OAuth | `""` | Public base URL of this server — must match the authorized redirect URI |
|
||||
| `UNRAID_MCP_JWT_SIGNING_KEY` | Recommended | auto-derived | Stable 32+ char secret for JWT signing — prevents token invalidation on restart |
|
||||
|
||||
OAuth is activated only when **all three** of `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, and `UNRAID_MCP_BASE_URL` are non-empty. Omit any one to run without authentication.
|
||||
|
||||
---
|
||||
|
||||
## Disabling OAuth
|
||||
|
||||
Remove (or empty) `GOOGLE_CLIENT_ID` from `~/.unraid-mcp/.env` and restart. The server reverts to unauthenticated mode and logs:
|
||||
|
||||
```
|
||||
WARNING [SERVER] No authentication configured — MCP server is open to all clients on the network.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**`redirect_uri_mismatch` from Google**
|
||||
The redirect URI in Google Cloud Console must exactly match `<UNRAID_MCP_BASE_URL>/auth/callback` — same scheme, host, port, and path. Trailing slashes matter.
|
||||
|
||||
**Tokens invalidated after restart**
|
||||
Set `UNRAID_MCP_JWT_SIGNING_KEY` to a stable secret (see Step 3). Without it, FastMCP generates a new key on every start.
|
||||
|
||||
**`stdio` transport warning**
|
||||
OAuth requires an HTTP transport. Set `UNRAID_MCP_TRANSPORT=streamable-http` (the default) or `sse`.
|
||||
|
||||
**Client cannot reach the callback URL**
|
||||
`UNRAID_MCP_BASE_URL` must be the address your browser uses to reach the server — not `localhost` or `0.0.0.0`. Use the LAN IP, Tailscale IP, or a domain name.
|
||||
|
||||
**OAuth configured but server not starting**
|
||||
Check `logs/unraid-mcp.log` or `docker compose logs unraid-mcp` for startup errors.
|
||||
|
||||
---
|
||||
|
||||
## API Key Authentication (Alternative / Combined)
|
||||
|
||||
For machine-to-machine access (scripts, CI, other agents) without a browser-based OAuth flow, set `UNRAID_MCP_API_KEY`:
|
||||
|
||||
```bash
|
||||
# In ~/.unraid-mcp/.env
|
||||
UNRAID_MCP_API_KEY=your-secret-token
|
||||
```
|
||||
|
||||
Clients present it as a standard bearer token:
|
||||
|
||||
```
|
||||
Authorization: Bearer your-secret-token
|
||||
```
|
||||
|
||||
**Combining with Google OAuth**: set both `GOOGLE_CLIENT_ID` and `UNRAID_MCP_API_KEY`. The server activates `MultiAuth` and accepts either method — Google OAuth for interactive clients, API key for headless clients.
|
||||
|
||||
**Reusing the Unraid API key**: you can set `UNRAID_MCP_API_KEY` to the same value as `UNRAID_API_KEY` for simplicity. The two vars are kept separate so each concern has its own name.
|
||||
|
||||
**Standalone API key** (no Google OAuth): set only `UNRAID_MCP_API_KEY`. The server validates bearer tokens directly with no OAuth redirect flow.
|
||||
|
||||
---
|
||||
|
||||
## Security Notes
|
||||
|
||||
- OAuth protects the MCP HTTP interface — the Unraid GraphQL API itself still uses `UNRAID_API_KEY`
|
||||
- OAuth state files at `~/.local/share/fastmcp/oauth-proxy/` should be on a private filesystem; do not expose them
|
||||
- Restrict Google OAuth to specific accounts via the Google Cloud Console **OAuth consent screen** → **Test users** if you don't want to publish the app
|
||||
- `UNRAID_MCP_JWT_SIGNING_KEY` is a credential — store it in `~/.unraid-mcp/.env` (mode 600), never in source control
|
||||
23
fastmcp.http.json
Normal file
23
fastmcp.http.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json",
|
||||
"source": {
|
||||
"path": "unraid_mcp/server.py",
|
||||
"entrypoint": "mcp"
|
||||
},
|
||||
"environment": {
|
||||
"type": "uv",
|
||||
"python": "3.12",
|
||||
"editable": ["."]
|
||||
},
|
||||
"deployment": {
|
||||
"transport": "http",
|
||||
"host": "0.0.0.0",
|
||||
"port": 6970,
|
||||
"path": "/mcp",
|
||||
"log_level": "INFO",
|
||||
"env": {
|
||||
"UNRAID_API_URL": "${UNRAID_API_URL}",
|
||||
"UNRAID_API_KEY": "${UNRAID_API_KEY}"
|
||||
}
|
||||
}
|
||||
}
|
||||
20
fastmcp.stdio.json
Normal file
20
fastmcp.stdio.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json",
|
||||
"source": {
|
||||
"path": "unraid_mcp/server.py",
|
||||
"entrypoint": "mcp"
|
||||
},
|
||||
"environment": {
|
||||
"type": "uv",
|
||||
"python": "3.12",
|
||||
"editable": ["."]
|
||||
},
|
||||
"deployment": {
|
||||
"transport": "stdio",
|
||||
"log_level": "INFO",
|
||||
"env": {
|
||||
"UNRAID_API_URL": "${UNRAID_API_URL}",
|
||||
"UNRAID_API_KEY": "${UNRAID_API_KEY}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ build-backend = "hatchling.build"
|
||||
# ============================================================================
|
||||
[project]
|
||||
name = "unraid-mcp"
|
||||
version = "1.0.1"
|
||||
version = "1.1.1"
|
||||
description = "MCP Server for Unraid API - provides tools to interact with an Unraid server's GraphQL API"
|
||||
readme = "README.md"
|
||||
license = {file = "LICENSE"}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Unraid API - Complete Reference Guide
|
||||
|
||||
> **⚠️ DEVELOPER REFERENCE ONLY** — This file documents the raw GraphQL API schema for development and maintenance purposes (adding new queries/mutations). Do NOT use these curl/GraphQL examples for MCP tool usage. Use `unraid(action=..., subaction=...)` calls instead. See `SKILL.md` for the correct calling convention.
|
||||
> **⚠️ DEVELOPER REFERENCE ONLY** — This file documents the raw GraphQL API schema for development and maintenance purposes (adding new queries/mutations). Do NOT use these curl/GraphQL examples for MCP tool usage. Use `unraid(action=..., subaction=...)` calls instead. See [`SKILL.md`](../SKILL.md) for the correct calling convention.
|
||||
|
||||
**Tested on:** Unraid 7.2 x86_64
|
||||
**Date:** 2026-01-21
|
||||
|
||||
@@ -30,9 +30,8 @@ unraid(action="array", subaction="stop_array", confirm=True) # ⚠️ Stop
|
||||
|
||||
```python
|
||||
unraid(action="disk", subaction="log_files") # List available logs
|
||||
unraid(action="disk", subaction="logs", log_path="syslog", tail_lines=50) # Read syslog
|
||||
unraid(action="disk", subaction="logs", log_path="/var/log/syslog") # Full path also works
|
||||
unraid(action="live", subaction="log_tail", log_path="/var/log/syslog") # Live tail
|
||||
unraid(action="disk", subaction="logs", log_path="/var/log/syslog", tail_lines=50) # Read syslog
|
||||
unraid(action="live", subaction="log_tail", path="/var/log/syslog") # Live tail
|
||||
```
|
||||
|
||||
### Docker Containers
|
||||
@@ -64,7 +63,7 @@ unraid(action="notification", subaction="overview")
|
||||
unraid(action="notification", subaction="list", list_type="UNREAD", limit=10)
|
||||
unraid(action="notification", subaction="archive", notification_id="<id>")
|
||||
unraid(action="notification", subaction="create", title="Test", subject="Subject",
|
||||
description="Body", importance="normal")
|
||||
description="Body", importance="INFO")
|
||||
```
|
||||
|
||||
### API Keys
|
||||
|
||||
@@ -26,15 +26,15 @@ This writes `UNRAID_API_URL` and `UNRAID_API_KEY` to `~/.unraid-mcp/.env`. Re-ru
|
||||
unraid(action="health", subaction="test_connection")
|
||||
```
|
||||
|
||||
2. Full diagnostic report:
|
||||
1. Full diagnostic report:
|
||||
|
||||
```python
|
||||
unraid(action="health", subaction="diagnose")
|
||||
```
|
||||
|
||||
3. Check that `UNRAID_API_URL` in `~/.unraid-mcp/.env` points to the correct Unraid GraphQL endpoint.
|
||||
1. Check that `UNRAID_API_URL` in `~/.unraid-mcp/.env` points to the correct Unraid GraphQL endpoint.
|
||||
|
||||
4. Verify the API key has the required roles. Get a new key: **Unraid UI → Settings → Management Access → API Keys → Create** (select "Viewer" role for read-only, or appropriate roles for mutations).
|
||||
1. Verify the API key has the required roles. Get a new key: **Unraid UI → Settings → Management Access → API Keys → Create** (select "Viewer" role for read-only, or appropriate roles for mutations).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -816,6 +816,15 @@ class TestAutoStart:
|
||||
|
||||
async def test_auto_start_only_starts_marked_subscriptions(self) -> None:
|
||||
mgr = SubscriptionManager()
|
||||
# Clear default SNAPSHOT_ACTIONS configs; add one with auto_start=False
|
||||
# to verify that unmarked subscriptions are never started.
|
||||
mgr.subscription_configs.clear()
|
||||
mgr.subscription_configs["no_auto_sub"] = {
|
||||
"query": "subscription { test }",
|
||||
"resource": "unraid://test",
|
||||
"description": "Unmarked sub",
|
||||
"auto_start": False,
|
||||
}
|
||||
with patch.object(mgr, "start_subscription", new_callable=AsyncMock) as mock_start:
|
||||
await mgr.auto_start_all_subscriptions()
|
||||
mock_start.assert_not_called()
|
||||
@@ -837,6 +846,7 @@ class TestAutoStart:
|
||||
|
||||
async def test_auto_start_calls_start_for_marked(self) -> None:
|
||||
mgr = SubscriptionManager()
|
||||
mgr.subscription_configs.clear()
|
||||
mgr.subscription_configs["auto_sub"] = {
|
||||
"query": "subscription { auto }",
|
||||
"resource": "unraid://auto",
|
||||
|
||||
@@ -134,6 +134,11 @@ check_prerequisites() {
|
||||
missing=true
|
||||
fi
|
||||
|
||||
if ! command -v jq &>/dev/null; then
|
||||
log_error "jq not found in PATH. Install it and re-run."
|
||||
missing=true
|
||||
fi
|
||||
|
||||
if [[ ! -f "${PROJECT_DIR}/pyproject.toml" ]]; then
|
||||
log_error "pyproject.toml not found at ${PROJECT_DIR}. Wrong directory?"
|
||||
missing=true
|
||||
@@ -181,10 +186,12 @@ smoke_test_server() {
|
||||
import sys, json
|
||||
try:
|
||||
d = json.load(sys.stdin)
|
||||
if 'status' in d or 'success' in d or 'error' in d:
|
||||
if 'error' in d:
|
||||
print('error: tool returned error key — ' + str(d.get('error', '')))
|
||||
elif 'status' in d or 'success' in d:
|
||||
print('ok')
|
||||
else:
|
||||
print('missing: no status/success/error key in response')
|
||||
print('missing: no status/success key in response')
|
||||
except Exception as e:
|
||||
print('parse_error: ' + str(e))
|
||||
" 2>/dev/null
|
||||
@@ -253,6 +260,31 @@ run_test() {
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Always validate JSON is parseable and not an error payload
|
||||
local json_check
|
||||
json_check="$(
|
||||
printf '%s' "${output}" | python3 -c "
|
||||
import sys, json
|
||||
try:
|
||||
d = json.load(sys.stdin)
|
||||
if isinstance(d, dict) and ('error' in d or d.get('kind') == 'error'):
|
||||
print('error: ' + str(d.get('error', d.get('message', 'unknown error'))))
|
||||
else:
|
||||
print('ok')
|
||||
except Exception as e:
|
||||
print('invalid_json: ' + str(e))
|
||||
" 2>/dev/null
|
||||
)" || json_check="parse_error"
|
||||
|
||||
if [[ "${json_check}" != "ok" ]]; then
|
||||
printf "${C_RED}[FAIL]${C_RESET} %-55s ${C_DIM}%dms${C_RESET}\n" \
|
||||
"${label}" "${elapsed_ms}" | tee -a "${LOG_FILE}"
|
||||
printf ' response validation failed: %s\n' "${json_check}" | tee -a "${LOG_FILE}"
|
||||
FAIL_COUNT=$(( FAIL_COUNT + 1 ))
|
||||
FAIL_NAMES+=("${label}")
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Validate optional key presence
|
||||
if [[ -n "${expected_key}" ]]; then
|
||||
local key_check
|
||||
|
||||
@@ -36,7 +36,7 @@ def _all_domain_dicts(unraid_mod: object) -> list[tuple[str, dict[str, str]]]:
|
||||
"""
|
||||
import types
|
||||
|
||||
m = unraid_mod # type: ignore[assignment]
|
||||
m = unraid_mod
|
||||
if not isinstance(m, types.ModuleType):
|
||||
import importlib
|
||||
|
||||
@@ -417,7 +417,6 @@ class TestDockerQueries:
|
||||
"details",
|
||||
"networks",
|
||||
"network_details",
|
||||
"_resolve",
|
||||
}
|
||||
assert set(QUERIES.keys()) == expected
|
||||
|
||||
|
||||
155
tests/test_api_key_auth.py
Normal file
155
tests/test_api_key_auth.py
Normal file
@@ -0,0 +1,155 @@
|
||||
"""Tests for ApiKeyVerifier and _build_auth() in server.py."""
|
||||
|
||||
import importlib
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from unraid_mcp.server import ApiKeyVerifier, _build_auth
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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 = 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 = 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 = 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 = 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 = _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 = _build_auth()
|
||||
assert isinstance(result, 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 = _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 = _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], 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 = _build_auth()
|
||||
|
||||
verifier = result.verifiers[0]
|
||||
assert verifier._api_key == "super-secret-token"
|
||||
112
tests/test_auth_builder.py
Normal file
112
tests/test_auth_builder.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""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."""
|
||||
monkeypatch.delenv("GOOGLE_CLIENT_ID", raising=False)
|
||||
monkeypatch.delenv("GOOGLE_CLIENT_SECRET", raising=False)
|
||||
monkeypatch.delenv("UNRAID_MCP_BASE_URL", raising=False)
|
||||
|
||||
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",
|
||||
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.pop(var, None)
|
||||
|
||||
import importlib
|
||||
|
||||
import unraid_mcp.config.settings as s
|
||||
|
||||
importlib.reload(s)
|
||||
|
||||
import unraid_mcp.server as srv
|
||||
|
||||
importlib.reload(srv)
|
||||
|
||||
# FastMCP stores auth on ._auth_provider or .auth
|
||||
auth = getattr(srv.mcp, "_auth_provider", None) or getattr(srv.mcp, "auth", None)
|
||||
assert auth is None
|
||||
91
tests/test_auth_settings.py
Normal file
91
tests/test_auth_settings.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""Tests for Google OAuth settings loading."""
|
||||
|
||||
import importlib
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _reload_settings(monkeypatch, overrides: dict) -> Any:
|
||||
"""Reload settings module with given env vars set."""
|
||||
for k, v in overrides.items():
|
||||
monkeypatch.setenv(k, v)
|
||||
import unraid_mcp.config.settings as mod
|
||||
|
||||
importlib.reload(mod)
|
||||
return mod
|
||||
|
||||
|
||||
def test_google_auth_defaults_to_empty(monkeypatch):
|
||||
"""Google auth vars default to empty string when not set."""
|
||||
# Use setenv("", "") rather than delenv so dotenv reload can't re-inject values
|
||||
# from ~/.unraid-mcp/.env (load_dotenv won't override existing env vars).
|
||||
mod = _reload_settings(
|
||||
monkeypatch,
|
||||
{
|
||||
"GOOGLE_CLIENT_ID": "",
|
||||
"GOOGLE_CLIENT_SECRET": "",
|
||||
"UNRAID_MCP_BASE_URL": "",
|
||||
"UNRAID_MCP_JWT_SIGNING_KEY": "",
|
||||
},
|
||||
)
|
||||
assert mod.GOOGLE_CLIENT_ID == ""
|
||||
assert mod.GOOGLE_CLIENT_SECRET == ""
|
||||
assert mod.UNRAID_MCP_BASE_URL == ""
|
||||
assert mod.UNRAID_MCP_JWT_SIGNING_KEY == ""
|
||||
|
||||
|
||||
def test_google_auth_reads_env_vars(monkeypatch):
|
||||
"""Google auth vars are read from environment."""
|
||||
mod = _reload_settings(
|
||||
monkeypatch,
|
||||
{
|
||||
"GOOGLE_CLIENT_ID": "test-client-id.apps.googleusercontent.com",
|
||||
"GOOGLE_CLIENT_SECRET": "GOCSPX-test-secret",
|
||||
"UNRAID_MCP_BASE_URL": "http://10.1.0.2:6970",
|
||||
"UNRAID_MCP_JWT_SIGNING_KEY": "a" * 32,
|
||||
},
|
||||
)
|
||||
assert mod.GOOGLE_CLIENT_ID == "test-client-id.apps.googleusercontent.com"
|
||||
assert mod.GOOGLE_CLIENT_SECRET == "GOCSPX-test-secret"
|
||||
assert mod.UNRAID_MCP_BASE_URL == "http://10.1.0.2:6970"
|
||||
assert mod.UNRAID_MCP_JWT_SIGNING_KEY == "a" * 32
|
||||
|
||||
|
||||
def test_google_auth_enabled_requires_both_vars(monkeypatch):
|
||||
"""is_google_auth_configured() requires both client_id and client_secret."""
|
||||
# Only client_id — not configured
|
||||
mod = _reload_settings(
|
||||
monkeypatch,
|
||||
{
|
||||
"GOOGLE_CLIENT_ID": "test-id",
|
||||
"GOOGLE_CLIENT_SECRET": "",
|
||||
"UNRAID_MCP_BASE_URL": "http://10.1.0.2:6970",
|
||||
},
|
||||
)
|
||||
monkeypatch.delenv("GOOGLE_CLIENT_SECRET", raising=False)
|
||||
importlib.reload(mod)
|
||||
assert not mod.is_google_auth_configured()
|
||||
|
||||
# Both set — configured
|
||||
mod2 = _reload_settings(
|
||||
monkeypatch,
|
||||
{
|
||||
"GOOGLE_CLIENT_ID": "test-id",
|
||||
"GOOGLE_CLIENT_SECRET": "test-secret",
|
||||
"UNRAID_MCP_BASE_URL": "http://10.1.0.2:6970",
|
||||
},
|
||||
)
|
||||
assert mod2.is_google_auth_configured()
|
||||
|
||||
|
||||
def test_google_auth_requires_base_url(monkeypatch):
|
||||
"""is_google_auth_configured() is False when base_url is missing."""
|
||||
mod = _reload_settings(
|
||||
monkeypatch,
|
||||
{
|
||||
"GOOGLE_CLIENT_ID": "test-id",
|
||||
"GOOGLE_CLIENT_SECRET": "test-secret",
|
||||
},
|
||||
)
|
||||
monkeypatch.delenv("UNRAID_MCP_BASE_URL", raising=False)
|
||||
importlib.reload(mod)
|
||||
assert not mod.is_google_auth_configured()
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -141,8 +141,8 @@ class TestHealthActions:
|
||||
"unraid_mcp.subscriptions.utils._analyze_subscription_status",
|
||||
return_value=(0, []),
|
||||
),
|
||||
patch("unraid_mcp.server.cache_middleware", mock_cache),
|
||||
patch("unraid_mcp.server.error_middleware", mock_error),
|
||||
patch("unraid_mcp.server._cache_middleware", mock_cache),
|
||||
patch("unraid_mcp.server._error_middleware", mock_error),
|
||||
):
|
||||
result = await tool_fn(action="health", subaction="diagnose")
|
||||
assert "subscriptions" in result
|
||||
|
||||
@@ -36,6 +36,8 @@ class TestLiveResourcesUseManagerCache:
|
||||
with patch("unraid_mcp.subscriptions.resources.subscription_manager") as mock_mgr:
|
||||
mock_mgr.get_resource_data = AsyncMock(return_value=cached)
|
||||
mcp = _make_resources()
|
||||
# Accessing FastMCP internals intentionally for unit test isolation.
|
||||
# This may break on FastMCP upgrades — consider a make_resource_fn() helper if it does.
|
||||
resource = mcp.providers[0]._components[f"resource:unraid://live/{action}@"]
|
||||
result = await resource.fn()
|
||||
assert json.loads(result) == cached
|
||||
@@ -49,6 +51,8 @@ class TestLiveResourcesUseManagerCache:
|
||||
mock_mgr.get_resource_data = AsyncMock(return_value=None)
|
||||
mock_mgr.last_error = {}
|
||||
mcp = _make_resources()
|
||||
# Accessing FastMCP internals intentionally for unit test isolation.
|
||||
# This may break on FastMCP upgrades — consider a make_resource_fn() helper if it does.
|
||||
resource = mcp.providers[0]._components[f"resource:unraid://live/{action}@"]
|
||||
result = await resource.fn()
|
||||
parsed = json.loads(result)
|
||||
@@ -61,6 +65,8 @@ class TestLiveResourcesUseManagerCache:
|
||||
mock_mgr.get_resource_data = AsyncMock(return_value=None)
|
||||
mock_mgr.last_error = {action: "WebSocket auth failed"}
|
||||
mcp = _make_resources()
|
||||
# Accessing FastMCP internals intentionally for unit test isolation.
|
||||
# This may break on FastMCP upgrades — consider a make_resource_fn() helper if it does.
|
||||
resource = mcp.providers[0]._components[f"resource:unraid://live/{action}@"]
|
||||
result = await resource.fn()
|
||||
parsed = json.loads(result)
|
||||
@@ -96,6 +102,8 @@ class TestLogsStreamResource:
|
||||
mock_mgr.get_resource_data = AsyncMock(return_value=None)
|
||||
mcp = _make_resources()
|
||||
local_provider = mcp.providers[0]
|
||||
# Accessing FastMCP internals intentionally for unit test isolation.
|
||||
# This may break on FastMCP upgrades — consider a make_resource_fn() helper if it does.
|
||||
resource = local_provider._components["resource:unraid://logs/stream@"]
|
||||
result = await resource.fn()
|
||||
parsed = json.loads(result)
|
||||
@@ -108,6 +116,8 @@ class TestLogsStreamResource:
|
||||
mock_mgr.get_resource_data = AsyncMock(return_value={})
|
||||
mcp = _make_resources()
|
||||
local_provider = mcp.providers[0]
|
||||
# Accessing FastMCP internals intentionally for unit test isolation.
|
||||
# This may break on FastMCP upgrades — consider a make_resource_fn() helper if it does.
|
||||
resource = local_provider._components["resource:unraid://logs/stream@"]
|
||||
result = await resource.fn()
|
||||
assert json.loads(result) == {}
|
||||
@@ -131,6 +141,8 @@ class TestAutoStartDisabledFallback:
|
||||
mock_mgr.last_error = {}
|
||||
mock_mgr.auto_start_enabled = False
|
||||
mcp = _make_resources()
|
||||
# Accessing FastMCP internals intentionally for unit test isolation.
|
||||
# This may break on FastMCP upgrades — consider a make_resource_fn() helper if it does.
|
||||
resource = mcp.providers[0]._components[f"resource:unraid://live/{action}@"]
|
||||
result = await resource.fn()
|
||||
assert json.loads(result) == fallback_data
|
||||
@@ -150,6 +162,8 @@ class TestAutoStartDisabledFallback:
|
||||
mock_mgr.last_error = {}
|
||||
mock_mgr.auto_start_enabled = False
|
||||
mcp = _make_resources()
|
||||
# Accessing FastMCP internals intentionally for unit test isolation.
|
||||
# This may break on FastMCP upgrades — consider a make_resource_fn() helper if it does.
|
||||
resource = mcp.providers[0]._components[f"resource:unraid://live/{action}@"]
|
||||
result = await resource.fn()
|
||||
assert json.loads(result)["status"] == "connecting"
|
||||
|
||||
@@ -76,6 +76,41 @@ elif raw_verify_ssl in ["true", "1", "yes"]:
|
||||
else: # Path to CA bundle
|
||||
UNRAID_VERIFY_SSL = raw_verify_ssl
|
||||
|
||||
# Google OAuth Configuration (Optional)
|
||||
# -------------------------------------
|
||||
# When set, the MCP HTTP server requires Google login before tool calls.
|
||||
# UNRAID_MCP_BASE_URL must match the public URL clients use to reach this server.
|
||||
# Google Cloud Console → Credentials → Authorized redirect URIs:
|
||||
# Add: <UNRAID_MCP_BASE_URL>/auth/callback
|
||||
GOOGLE_CLIENT_ID = os.getenv("GOOGLE_CLIENT_ID", "")
|
||||
GOOGLE_CLIENT_SECRET = os.getenv("GOOGLE_CLIENT_SECRET", "")
|
||||
UNRAID_MCP_BASE_URL = os.getenv("UNRAID_MCP_BASE_URL", "")
|
||||
|
||||
# JWT signing key for FastMCP OAuth tokens.
|
||||
# MUST be set to a stable secret so tokens survive server restarts.
|
||||
# Generate once: python3 -c "import secrets; print(secrets.token_hex(32))"
|
||||
# Never change this value — all existing tokens will be invalidated.
|
||||
UNRAID_MCP_JWT_SIGNING_KEY = os.getenv("UNRAID_MCP_JWT_SIGNING_KEY", "")
|
||||
|
||||
|
||||
def is_google_auth_configured() -> bool:
|
||||
"""Return True when all required Google OAuth vars are present."""
|
||||
return bool(GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET and UNRAID_MCP_BASE_URL)
|
||||
|
||||
|
||||
# API Key Authentication (Optional)
|
||||
# ----------------------------------
|
||||
# A static bearer token clients can use instead of (or alongside) Google OAuth.
|
||||
# Can be set to the same value as UNRAID_API_KEY for simplicity, or a separate
|
||||
# dedicated secret for MCP access.
|
||||
UNRAID_MCP_API_KEY = os.getenv("UNRAID_MCP_API_KEY", "")
|
||||
|
||||
|
||||
def is_api_key_auth_configured() -> bool:
|
||||
"""Return True when UNRAID_MCP_API_KEY is set."""
|
||||
return bool(UNRAID_MCP_API_KEY)
|
||||
|
||||
|
||||
# Logging Configuration
|
||||
LOG_LEVEL_STR = os.getenv("UNRAID_MCP_LOG_LEVEL", "INFO").upper()
|
||||
LOG_FILE_NAME = os.getenv("UNRAID_MCP_LOG_FILE", "unraid-mcp.log")
|
||||
@@ -155,6 +190,10 @@ def get_config_summary() -> dict[str, Any]:
|
||||
"log_file": str(LOG_FILE_PATH),
|
||||
"config_valid": is_valid,
|
||||
"missing_config": missing if not is_valid else None,
|
||||
"google_auth_enabled": is_google_auth_configured(),
|
||||
"google_auth_base_url": UNRAID_MCP_BASE_URL if is_google_auth_configured() else None,
|
||||
"jwt_signing_key_configured": bool(UNRAID_MCP_JWT_SIGNING_KEY),
|
||||
"api_key_auth_enabled": is_api_key_auth_configured(),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -5,8 +5,11 @@ separate modules for configuration, core functionality, subscriptions, and tools
|
||||
"""
|
||||
|
||||
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
|
||||
@@ -39,26 +42,32 @@ _logging_middleware = LoggingMiddleware(
|
||||
|
||||
# 2. Catch any unhandled exceptions and convert to proper MCP errors.
|
||||
# Tracks error_counts per (exception_type:method) for health diagnose.
|
||||
error_middleware = ErrorHandlingMiddleware(
|
||||
_error_middleware = ErrorHandlingMiddleware(
|
||||
logger=logger,
|
||||
include_traceback=True,
|
||||
)
|
||||
|
||||
# 3. Unraid API rate limit: 100 requests per 10 seconds.
|
||||
# Use a sliding window that stays comfortably under that cap.
|
||||
_rate_limiter = SlidingWindowRateLimitingMiddleware(max_requests=90, window_minutes=1)
|
||||
# SlidingWindowRateLimitingMiddleware only accepts window_minutes (int), so express
|
||||
# the 10-second budget as a 1-minute equivalent: 540 req/60 s to stay comfortably
|
||||
# under the 600 req/min ceiling.
|
||||
_rate_limiter = SlidingWindowRateLimitingMiddleware(max_requests=540, window_minutes=1)
|
||||
|
||||
# 4. Cap tool responses at 512 KB to protect the client context window.
|
||||
# Oversized responses are truncated with a clear suffix rather than erroring.
|
||||
_response_limiter = ResponseLimitingMiddleware(max_size=512_000)
|
||||
|
||||
# 5. Cache tool calls in-memory (MemoryStore default — no extra deps).
|
||||
# Short 30 s TTL absorbs burst duplicate requests while keeping data fresh.
|
||||
# Destructive calls won't hit the cache in practice (unique confirm=True + IDs).
|
||||
cache_middleware = ResponseCachingMiddleware(
|
||||
# 5. Cache middleware — all call_tool caching is disabled for the `unraid` tool.
|
||||
# CallToolSettings supports excluded_tools/included_tools by tool name only; there
|
||||
# is no per-argument or per-subaction exclusion mechanism. The cache key is
|
||||
# "{tool_name}:{arguments_str}", so a cached stop("nginx") result would be served
|
||||
# back on a retry within the TTL window even though the container is already stopped.
|
||||
# Mutation subactions (start, stop, restart, reboot, etc.) must never be cached.
|
||||
# Because the consolidated `unraid` tool mixes reads and mutations under one name,
|
||||
# the only safe option is to disable caching for the entire tool.
|
||||
_cache_middleware = ResponseCachingMiddleware(
|
||||
call_tool_settings=CallToolSettings(
|
||||
ttl=30,
|
||||
included_tools=["unraid"],
|
||||
enabled=False,
|
||||
),
|
||||
# Disable caching for list/resource/prompt — those are cheap.
|
||||
list_tools_settings={"enabled": False},
|
||||
@@ -68,23 +77,126 @@ cache_middleware = ResponseCachingMiddleware(
|
||||
get_prompt_settings={"enabled": False},
|
||||
)
|
||||
|
||||
|
||||
class ApiKeyVerifier(TokenVerifier):
|
||||
"""Bearer token verifier that validates against a static API key.
|
||||
|
||||
Clients present the key as a standard OAuth bearer token:
|
||||
Authorization: Bearer <UNRAID_MCP_API_KEY>
|
||||
|
||||
This allows machine-to-machine access (e.g. CI, scripts, other agents)
|
||||
without going through the Google OAuth browser flow.
|
||||
"""
|
||||
|
||||
def __init__(self, api_key: str) -> None:
|
||||
super().__init__()
|
||||
self._api_key = api_key
|
||||
|
||||
async def verify_token(self, token: str) -> AccessToken | None:
|
||||
if self._api_key and token == self._api_key:
|
||||
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,
|
||||
}
|
||||
if UNRAID_MCP_JWT_SIGNING_KEY:
|
||||
kwargs["jwt_signing_key"] = UNRAID_MCP_JWT_SIGNING_KEY
|
||||
else:
|
||||
logger.warning(
|
||||
"UNRAID_MCP_JWT_SIGNING_KEY is not set. FastMCP will derive a key automatically, "
|
||||
"but tokens may be invalidated on server restart. "
|
||||
"Set UNRAID_MCP_JWT_SIGNING_KEY to a stable secret."
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Google OAuth enabled — base_url={UNRAID_MCP_BASE_URL}, "
|
||||
f"redirect_uri={UNRAID_MCP_BASE_URL}/auth/callback"
|
||||
)
|
||||
return GoogleProvider(**kwargs)
|
||||
|
||||
|
||||
def _build_auth() -> "GoogleProvider | ApiKeyVerifier | MultiAuth | None":
|
||||
"""Build the active auth stack from environment configuration.
|
||||
|
||||
Returns:
|
||||
- MultiAuth(server=GoogleProvider, verifiers=[ApiKeyVerifier])
|
||||
when both GOOGLE_CLIENT_ID and UNRAID_MCP_API_KEY are set.
|
||||
- GoogleProvider alone when only Google OAuth vars are set.
|
||||
- ApiKeyVerifier alone when only UNRAID_MCP_API_KEY is set.
|
||||
- None when no auth vars are configured (open server).
|
||||
"""
|
||||
from .config.settings import UNRAID_MCP_API_KEY, is_api_key_auth_configured
|
||||
|
||||
google = _build_google_auth()
|
||||
api_key = ApiKeyVerifier(UNRAID_MCP_API_KEY) if is_api_key_auth_configured() else None
|
||||
|
||||
if google and api_key:
|
||||
logger.info("Auth: Google OAuth + API key both enabled (MultiAuth)")
|
||||
return MultiAuth(server=google, verifiers=[api_key])
|
||||
if api_key:
|
||||
logger.info("Auth: API key authentication enabled")
|
||||
return api_key
|
||||
return google # GoogleProvider or None
|
||||
|
||||
|
||||
# Build auth stack — GoogleProvider, ApiKeyVerifier, MultiAuth, or None.
|
||||
_auth = _build_auth()
|
||||
|
||||
# Initialize FastMCP instance
|
||||
mcp = FastMCP(
|
||||
name="Unraid MCP Server",
|
||||
instructions="Provides tools to interact with an Unraid server's GraphQL API.",
|
||||
version=VERSION,
|
||||
auth=_auth,
|
||||
middleware=[
|
||||
_logging_middleware,
|
||||
error_middleware,
|
||||
_error_middleware,
|
||||
_rate_limiter,
|
||||
_response_limiter,
|
||||
cache_middleware,
|
||||
_cache_middleware,
|
||||
],
|
||||
)
|
||||
|
||||
# Note: SubscriptionManager singleton is defined in subscriptions/manager.py
|
||||
# and imported by resources.py - no duplicate instance needed here
|
||||
|
||||
# Register all modules at import time so `fastmcp run server.py --reload` can
|
||||
# discover the fully-configured `mcp` object without going through run_server().
|
||||
# run_server() no longer calls this — tools are registered exactly once here.
|
||||
|
||||
|
||||
def register_all_modules() -> None:
|
||||
"""Register all tools and resources with the MCP instance."""
|
||||
@@ -103,6 +215,9 @@ def register_all_modules() -> None:
|
||||
raise
|
||||
|
||||
|
||||
register_all_modules()
|
||||
|
||||
|
||||
def run_server() -> None:
|
||||
"""Run the MCP server with the configured transport."""
|
||||
# Validate required configuration before anything else
|
||||
@@ -125,8 +240,26 @@ def run_server() -> None:
|
||||
"Only use this in trusted networks or for development."
|
||||
)
|
||||
|
||||
# Register all modules
|
||||
register_all_modules()
|
||||
if _auth is not None:
|
||||
from .config.settings import is_google_auth_configured
|
||||
|
||||
if is_google_auth_configured():
|
||||
from .config.settings import UNRAID_MCP_BASE_URL
|
||||
|
||||
logger.info(
|
||||
"Google OAuth ENABLED — clients must authenticate before calling tools. "
|
||||
f"Redirect URI: {UNRAID_MCP_BASE_URL}/auth/callback"
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"API key authentication ENABLED — present UNRAID_MCP_API_KEY as bearer token."
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"No authentication configured — MCP server is open to all clients on the network. "
|
||||
"Set GOOGLE_CLIENT_ID + GOOGLE_CLIENT_SECRET + UNRAID_MCP_BASE_URL to enable Google OAuth, "
|
||||
"or set UNRAID_MCP_API_KEY to enable bearer token authentication."
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Starting Unraid MCP Server on {UNRAID_MCP_HOST}:{UNRAID_MCP_PORT} using {UNRAID_MCP_TRANSPORT} transport..."
|
||||
|
||||
@@ -285,6 +285,16 @@ async def _handle_system(subaction: str, device_id: str | None) -> dict[str, Any
|
||||
# ===========================================================================
|
||||
|
||||
_HEALTH_SUBACTIONS: set[str] = {"check", "test_connection", "diagnose", "setup"}
|
||||
_HEALTH_QUERIES: dict[str, str] = {
|
||||
"comprehensive_health": (
|
||||
"query ComprehensiveHealthCheck {"
|
||||
" info { machineId time versions { core { unraid } } os { uptime } }"
|
||||
" array { state }"
|
||||
" notifications { overview { unread { alert warning total } } }"
|
||||
" docker { containers(skipCache: true) { id state status } }"
|
||||
" }"
|
||||
),
|
||||
}
|
||||
_SEVERITY = {"healthy": 0, "warning": 1, "degraded": 2, "unhealthy": 3}
|
||||
|
||||
|
||||
@@ -346,7 +356,8 @@ async def _handle_health(subaction: str, ctx: Context | None) -> dict[str, Any]
|
||||
return await _comprehensive_health_check()
|
||||
|
||||
if subaction == "diagnose":
|
||||
from ..server import cache_middleware, error_middleware
|
||||
from ..server import _cache_middleware as cache_middleware
|
||||
from ..server import _error_middleware as error_middleware
|
||||
from ..subscriptions.manager import subscription_manager
|
||||
from ..subscriptions.resources import ensure_subscriptions_started
|
||||
|
||||
@@ -373,7 +384,7 @@ async def _handle_health(subaction: str, ctx: Context | None) -> dict[str, Any]
|
||||
"call_tool": {
|
||||
"hits": cache_stats.call_tool.get.hit,
|
||||
"misses": cache_stats.call_tool.get.miss,
|
||||
"puts": cache_stats.call_tool.put.total,
|
||||
"puts": cache_stats.call_tool.put.count,
|
||||
}
|
||||
if cache_stats.call_tool
|
||||
else {"hits": 0, "misses": 0, "puts": 0},
|
||||
@@ -403,15 +414,7 @@ async def _comprehensive_health_check() -> dict[str, Any]:
|
||||
health_severity = max(health_severity, _SEVERITY.get(level, 0))
|
||||
|
||||
try:
|
||||
query = """
|
||||
query ComprehensiveHealthCheck {
|
||||
info { machineId time versions { core { unraid } } os { uptime } }
|
||||
array { state }
|
||||
notifications { overview { unread { alert warning total } } }
|
||||
docker { containers(skipCache: true) { id state status } }
|
||||
}
|
||||
"""
|
||||
data = await make_graphql_request(query)
|
||||
data = await make_graphql_request(_HEALTH_QUERIES["comprehensive_health"])
|
||||
api_latency = round((time.time() - start_time) * 1000, 2)
|
||||
|
||||
health_info: dict[str, Any] = {
|
||||
@@ -738,9 +741,13 @@ _DOCKER_QUERIES: dict[str, str] = {
|
||||
"details": "query GetContainerDetails { docker { containers(skipCache: false) { id names image imageId command created ports { ip privatePort publicPort type } sizeRootFs labels state status hostConfig { networkMode } networkSettings mounts autoStart } } }",
|
||||
"networks": "query GetDockerNetworks { docker { networks { id name driver scope } } }",
|
||||
"network_details": "query GetDockerNetwork { docker { networks { id name driver scope enableIPv6 internal attachable containers options labels } } }",
|
||||
"_resolve": "query ResolveContainerID { docker { containers(skipCache: true) { id names } } }",
|
||||
}
|
||||
|
||||
# Internal query used only for container ID resolution — not a public subaction.
|
||||
_DOCKER_RESOLVE_QUERY = (
|
||||
"query ResolveContainerID { docker { containers(skipCache: true) { id names } } }"
|
||||
)
|
||||
|
||||
_DOCKER_MUTATIONS: dict[str, str] = {
|
||||
"start": "mutation StartContainer($id: PrefixedID!) { docker { start(id: $id) { id names state status } } }",
|
||||
"stop": "mutation StopContainer($id: PrefixedID!) { docker { stop(id: $id) { id names state status } } }",
|
||||
@@ -775,7 +782,7 @@ def _find_container(
|
||||
async def _resolve_container_id(container_id: str, *, strict: bool = False) -> str:
|
||||
if _DOCKER_ID_PATTERN.match(container_id):
|
||||
return container_id
|
||||
data = await make_graphql_request(_DOCKER_QUERIES["_resolve"])
|
||||
data = await make_graphql_request(_DOCKER_RESOLVE_QUERY)
|
||||
containers = safe_get(data, "docker", "containers", default=[])
|
||||
if _DOCKER_SHORT_ID_PATTERN.match(container_id):
|
||||
id_lower = container_id.lower()
|
||||
@@ -1640,7 +1647,7 @@ async def _handle_live(
|
||||
if subaction == "log_tail":
|
||||
if not path:
|
||||
raise ToolError("path is required for live/log_tail")
|
||||
normalized = os.path.realpath(path) # noqa: ASYNC240
|
||||
normalized = await asyncio.to_thread(os.path.realpath, path)
|
||||
if not any(normalized.startswith(p) for p in _LIVE_ALLOWED_LOG_PREFIXES):
|
||||
raise ToolError(f"path must start with one of: {', '.join(_LIVE_ALLOWED_LOG_PREFIXES)}")
|
||||
path = normalized
|
||||
@@ -1722,7 +1729,7 @@ UNRAID_ACTIONS = Literal[
|
||||
def register_unraid_tool(mcp: FastMCP) -> None:
|
||||
"""Register the single `unraid` tool with the FastMCP instance."""
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(timeout=120)
|
||||
async def unraid(
|
||||
action: UNRAID_ACTIONS,
|
||||
subaction: str,
|
||||
|
||||
Reference in New Issue
Block a user