refactor: remove Docker and HTTP transport support, fix hypothesis cache directory

This commit is contained in:
Jacob Magar
2026-03-24 19:22:27 -04:00
parent e68d4a80e4
commit e548f6e6c9
39 changed files with 369 additions and 1757 deletions

View File

@@ -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(),
}

View File

@@ -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)

View File

@@ -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":
if UNRAID_MCP_TRANSPORT in ("streamable-http", "sse"):
if UNRAID_MCP_TRANSPORT == "sse":
logger.warning(
"SSE transport is deprecated. Consider switching to 'streamable-http'."
)
mcp.run(
transport="streamable-http", host=UNRAID_MCP_HOST, port=UNRAID_MCP_PORT, path="/mcp"
transport=UNRAID_MCP_TRANSPORT,
host=UNRAID_MCP_HOST,
port=UNRAID_MCP_PORT,
path="/mcp",
)
elif UNRAID_MCP_TRANSPORT == "sse":
logger.warning("SSE transport is deprecated. Consider switching to 'streamable-http'.")
mcp.run(transport="sse", host=UNRAID_MCP_HOST, port=UNRAID_MCP_PORT, path="/mcp")
elif UNRAID_MCP_TRANSPORT == "stdio":
mcp.run()
else:

View File

@@ -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",

View File

@@ -792,15 +792,22 @@ def _find_container(
if strict:
return None
id_lower = identifier.lower()
for c in containers:
for name in c.get("names", []):
if name.lower().startswith(id_lower):
return c
for c in containers:
for name in c.get("names", []):
if id_lower in name.lower():
return c
return None
# Collect prefix matches first, then fall back to substring matches.
prefix_matches = [
c for c in containers if any(n.lower().startswith(id_lower) for n in c.get("names", []))
]
candidates = prefix_matches or [
c for c in containers if any(id_lower in n.lower() for n in c.get("names", []))
]
if not candidates:
return None
if len(candidates) == 1:
return candidates[0]
names = [n for c in candidates for n in c.get("names", [])]
raise ToolError(
f"Container identifier '{identifier}' is ambiguous — matches: {', '.join(names[:10])}. "
"Use a more specific name or the full container ID."
)
async def _resolve_container_id(container_id: str, *, strict: bool = False) -> str:
@@ -1258,6 +1265,8 @@ async def _handle_key(
input_data["name"] = name
if roles is not None:
input_data["roles"] = roles
if permissions is not None:
input_data["permissions"] = permissions
data = await make_graphql_request(_KEY_MUTATIONS["update"], {"input": input_data})
updated_key = (data.get("apiKey") or {}).get("update")
if not updated_key:
@@ -1277,7 +1286,7 @@ async def _handle_key(
if subaction in ("add_role", "remove_role"):
if not key_id:
raise ToolError(f"key_id is required for key/{subaction}")
if not roles or len(roles) == 0:
if not roles:
raise ToolError(
f"roles is required for key/{subaction} (pass as roles=['ROLE_NAME'])"
)