forked from HomeLab/unraid-mcp
refactor: remove Docker and HTTP transport support, fix hypothesis cache directory
This commit is contained in:
@@ -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":
|
||||
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:
|
||||
|
||||
@@ -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
|
||||
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'])"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user