mirror of
https://github.com/jmagar/unraid-mcp.git
synced 2026-03-23 04:29:17 -07:00
Threads 1, 2, 3 — test hygiene:
- Move elicit_and_configure/elicit_reset_confirmation to module-level imports
in unraid.py so tests can patch at unraid_mcp.tools.unraid.* (thread 2)
- Add return type annotations to _make_tool() in test_customization.py (thread 1)
- Replace unused _mock_ensure_started fixture params with @usefixtures (thread 3)
Thread 4 — remove dead 'connect' subaction from _SYSTEM_QUERIES; the subaction
was always rejected with a ToolError, creating an inconsistent contract.
Thread 5 — centralize two inline "query { online }" strings by reusing
_SYSTEM_QUERIES["online"]; add _DOCKER_QUERIES["_resolve"] for container-name
resolution instead of an inline query literal.
Threads 14, 15, 16, 17, 18 — test improvements:
- test-tools.sh: reword header to "broad non-destructive smoke coverage" (t14)
- test-tools.sh: add _json_payload() helper using jq --arg for safe JSON
construction; replace all printf-based payloads (thread 15)
- test_input_validation.py: add return type annotations to _make_tool and all
nested _run_test coroutines (thread 16)
- test_query_validation.py: extract _all_domain_dicts() shared helper to
eliminate the duplicate 22-item registry (thread 17)
- test_query_validation.py: tighten regression threshold from 50 → 90 (thread 18)
157 lines
5.7 KiB
Python
157 lines
5.7 KiB
Python
"""Modular Unraid MCP Server.
|
|
|
|
This is the main server implementation using the modular architecture with
|
|
separate modules for configuration, core functionality, subscriptions, and tools.
|
|
"""
|
|
|
|
import sys
|
|
|
|
from fastmcp import FastMCP
|
|
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.settings import (
|
|
UNRAID_MCP_HOST,
|
|
UNRAID_MCP_PORT,
|
|
UNRAID_MCP_TRANSPORT,
|
|
UNRAID_VERIFY_SSL,
|
|
VERSION,
|
|
validate_required_config,
|
|
)
|
|
from .subscriptions.diagnostics import register_diagnostic_tools
|
|
from .subscriptions.resources import register_subscription_resources
|
|
from .tools.unraid import register_unraid_tool
|
|
|
|
|
|
# Middleware chain order matters — each layer wraps everything inside it:
|
|
# logging → error_handling → rate_limiter → response_limiter → cache → tool
|
|
|
|
# 1. Log every tools/call and resources/read: method, duration, errors.
|
|
# Outermost so it captures errors after they've been converted by error_handling.
|
|
_logging_middleware = LoggingMiddleware(
|
|
logger=logger,
|
|
methods=["tools/call", "resources/read"],
|
|
)
|
|
|
|
# 2. Catch any unhandled exceptions and convert to proper MCP errors.
|
|
# Tracks error_counts per (exception_type:method) for health diagnose.
|
|
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)
|
|
|
|
# 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(
|
|
call_tool_settings=CallToolSettings(
|
|
ttl=30,
|
|
included_tools=["unraid"],
|
|
),
|
|
# Disable caching for list/resource/prompt — those are cheap.
|
|
list_tools_settings={"enabled": False},
|
|
list_resources_settings={"enabled": False},
|
|
list_prompts_settings={"enabled": False},
|
|
read_resource_settings={"enabled": False},
|
|
get_prompt_settings={"enabled": False},
|
|
)
|
|
|
|
# Initialize FastMCP instance
|
|
mcp = FastMCP(
|
|
name="Unraid MCP Server",
|
|
instructions="Provides tools to interact with an Unraid server's GraphQL API.",
|
|
version=VERSION,
|
|
middleware=[
|
|
_logging_middleware,
|
|
error_middleware,
|
|
_rate_limiter,
|
|
_response_limiter,
|
|
cache_middleware,
|
|
],
|
|
)
|
|
|
|
# Note: SubscriptionManager singleton is defined in subscriptions/manager.py
|
|
# and imported by resources.py - no duplicate instance needed here
|
|
|
|
|
|
def register_all_modules() -> None:
|
|
"""Register all tools and resources with the MCP instance."""
|
|
try:
|
|
# Register subscription resources and diagnostic tools
|
|
register_subscription_resources(mcp)
|
|
register_diagnostic_tools(mcp)
|
|
logger.info("Subscription resources and diagnostic tools registered")
|
|
|
|
# Register the consolidated unraid tool
|
|
register_unraid_tool(mcp)
|
|
logger.info("unraid tool registered successfully - Server ready!")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to register modules: {e}", exc_info=True)
|
|
raise
|
|
|
|
|
|
def run_server() -> None:
|
|
"""Run the MCP server with the configured transport."""
|
|
# Validate required configuration before anything else
|
|
is_valid, missing = validate_required_config()
|
|
if not is_valid:
|
|
logger.warning(
|
|
f"Missing configuration: {', '.join(missing)}. "
|
|
"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:
|
|
logger.warning(
|
|
"SSL VERIFICATION DISABLED (UNRAID_VERIFY_SSL=false). "
|
|
"Connections to Unraid API are vulnerable to man-in-the-middle attacks. "
|
|
"Only use this in trusted networks or for development."
|
|
)
|
|
|
|
# Register all modules
|
|
register_all_modules()
|
|
|
|
logger.info(
|
|
f"Starting Unraid MCP Server on {UNRAID_MCP_HOST}:{UNRAID_MCP_PORT} using {UNRAID_MCP_TRANSPORT} transport..."
|
|
)
|
|
|
|
try:
|
|
if UNRAID_MCP_TRANSPORT == "streamable-http":
|
|
mcp.run(
|
|
transport="streamable-http", 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:
|
|
logger.error(
|
|
f"Unsupported MCP_TRANSPORT: {UNRAID_MCP_TRANSPORT}. Choose 'streamable-http', 'sse', or 'stdio'."
|
|
)
|
|
sys.exit(1)
|
|
except Exception as e:
|
|
logger.critical(f"Failed to start Unraid MCP server: {e}", exc_info=True)
|
|
sys.exit(1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
run_server()
|