forked from HomeLab/unraid-mcp
Remove unused MCP resources and update documentation
- Remove array_status, system_info, notifications_overview, and parity_status resources - Keep only logs_stream resource (unraid://logs/stream) which is working properly - Update README.md with current resource documentation and modern docker compose syntax - Fix import path issues that were causing subscription errors - Update environment configuration examples - Clean up subscription manager to only include working log streaming 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
1
unraid_mcp/core/__init__.py
Normal file
1
unraid_mcp/core/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Core infrastructure components for Unraid MCP Server."""
|
||||
147
unraid_mcp/core/client.py
Normal file
147
unraid_mcp/core/client.py
Normal file
@@ -0,0 +1,147 @@
|
||||
"""GraphQL client for Unraid API communication.
|
||||
|
||||
This module provides the HTTP client interface for making GraphQL requests
|
||||
to the Unraid API with proper timeout handling and error management.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from ..config.logging import logger
|
||||
from ..config.settings import TIMEOUT_CONFIG, UNRAID_API_KEY, UNRAID_API_URL, UNRAID_VERIFY_SSL
|
||||
from ..core.exceptions import ToolError
|
||||
|
||||
# HTTP timeout configuration
|
||||
DEFAULT_TIMEOUT = httpx.Timeout(10.0, read=30.0, connect=5.0)
|
||||
DISK_TIMEOUT = httpx.Timeout(10.0, read=TIMEOUT_CONFIG['disk_operations'], connect=5.0)
|
||||
|
||||
|
||||
def is_idempotent_error(error_message: str, operation: str) -> bool:
|
||||
"""Check if a GraphQL error represents an idempotent operation that should be treated as success.
|
||||
|
||||
Args:
|
||||
error_message: The error message from GraphQL API
|
||||
operation: The operation being performed (e.g., 'start', 'stop')
|
||||
|
||||
Returns:
|
||||
True if this is an idempotent error that should be treated as success
|
||||
"""
|
||||
error_lower = error_message.lower()
|
||||
|
||||
# Docker container operation patterns
|
||||
if operation == 'start':
|
||||
return (
|
||||
'already started' in error_lower or
|
||||
'container already running' in error_lower or
|
||||
'http code 304' in error_lower
|
||||
)
|
||||
elif operation == 'stop':
|
||||
return (
|
||||
'already stopped' in error_lower or
|
||||
'container already stopped' in error_lower or
|
||||
'container not running' in error_lower or
|
||||
'http code 304' in error_lower
|
||||
)
|
||||
|
||||
return False
|
||||
|
||||
|
||||
async def make_graphql_request(
|
||||
query: str,
|
||||
variables: dict[str, Any] | None = None,
|
||||
custom_timeout: httpx.Timeout | None = None,
|
||||
operation_context: dict[str, str] | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""Make GraphQL requests to the Unraid API.
|
||||
|
||||
Args:
|
||||
query: GraphQL query string
|
||||
variables: Optional query variables
|
||||
custom_timeout: Optional custom timeout configuration
|
||||
operation_context: Optional context for operation-specific error handling
|
||||
Should contain 'operation' key (e.g., 'start', 'stop')
|
||||
|
||||
Returns:
|
||||
Dict containing the GraphQL response data
|
||||
|
||||
Raises:
|
||||
ToolError: For HTTP errors, network errors, or non-idempotent GraphQL errors
|
||||
"""
|
||||
if not UNRAID_API_URL:
|
||||
raise ToolError("UNRAID_API_URL not configured")
|
||||
|
||||
if not UNRAID_API_KEY:
|
||||
raise ToolError("UNRAID_API_KEY not configured")
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"X-API-Key": UNRAID_API_KEY,
|
||||
"User-Agent": "UnraidMCPServer/0.1.0" # Custom user-agent
|
||||
}
|
||||
|
||||
payload = {"query": query}
|
||||
if variables:
|
||||
payload["variables"] = variables
|
||||
|
||||
logger.debug(f"Making GraphQL request to {UNRAID_API_URL}:")
|
||||
logger.debug(f"Query: {query[:200]}{'...' if len(query) > 200 else ''}") # Log truncated query
|
||||
if variables:
|
||||
logger.debug(f"Variables: {variables}")
|
||||
|
||||
current_timeout = custom_timeout if custom_timeout is not None else DEFAULT_TIMEOUT
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=current_timeout, verify=UNRAID_VERIFY_SSL) as client:
|
||||
response = await client.post(UNRAID_API_URL, json=payload, headers=headers)
|
||||
response.raise_for_status() # Raise an exception for HTTP error codes 4xx/5xx
|
||||
|
||||
response_data = response.json()
|
||||
if "errors" in response_data and response_data["errors"]:
|
||||
error_details = "; ".join([err.get("message", str(err)) for err in response_data["errors"]])
|
||||
|
||||
# Check if this is an idempotent error that should be treated as success
|
||||
if operation_context and operation_context.get('operation'):
|
||||
operation = operation_context['operation']
|
||||
if is_idempotent_error(error_details, operation):
|
||||
logger.warning(f"Idempotent operation '{operation}' - treating as success: {error_details}")
|
||||
# Return a success response with the current state information
|
||||
return {
|
||||
"idempotent_success": True,
|
||||
"operation": operation,
|
||||
"message": error_details,
|
||||
"original_errors": response_data["errors"]
|
||||
}
|
||||
|
||||
logger.error(f"GraphQL API returned errors: {response_data['errors']}")
|
||||
# Use ToolError for GraphQL errors to provide better feedback to LLM
|
||||
raise ToolError(f"GraphQL API error: {error_details}")
|
||||
|
||||
logger.debug("GraphQL request successful.")
|
||||
return response_data.get("data", {}) # Return only the data part
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.error(f"HTTP error occurred: {e.response.status_code} - {e.response.text}")
|
||||
raise ToolError(f"HTTP error {e.response.status_code}: {e.response.text}")
|
||||
except httpx.RequestError as e:
|
||||
logger.error(f"Request error occurred: {e}")
|
||||
raise ToolError(f"Network connection error: {str(e)}")
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Failed to decode JSON response: {e}")
|
||||
raise ToolError(f"Invalid JSON response from Unraid API: {str(e)}")
|
||||
|
||||
|
||||
def get_timeout_for_operation(operation_type: str = "default") -> httpx.Timeout:
|
||||
"""Get appropriate timeout configuration for different operation types.
|
||||
|
||||
Args:
|
||||
operation_type: Type of operation ('default', 'disk_operations')
|
||||
|
||||
Returns:
|
||||
httpx.Timeout configuration appropriate for the operation
|
||||
"""
|
||||
if operation_type == "disk_operations":
|
||||
return DISK_TIMEOUT
|
||||
else:
|
||||
return DEFAULT_TIMEOUT
|
||||
48
unraid_mcp/core/exceptions.py
Normal file
48
unraid_mcp/core/exceptions.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""Custom exceptions for Unraid MCP Server.
|
||||
|
||||
This module defines custom exception classes for consistent error handling
|
||||
throughout the application, with proper integration to FastMCP's error system.
|
||||
"""
|
||||
|
||||
from fastmcp.exceptions import ToolError as FastMCPToolError
|
||||
|
||||
|
||||
class ToolError(FastMCPToolError):
|
||||
"""User-facing error that MCP clients can handle.
|
||||
|
||||
This is the main exception type used throughout the application for
|
||||
errors that should be presented to the user/LLM in a friendly way.
|
||||
|
||||
Inherits from FastMCP's ToolError to ensure proper MCP protocol handling.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class ConfigurationError(ToolError):
|
||||
"""Raised when there are configuration-related errors."""
|
||||
pass
|
||||
|
||||
|
||||
class UnraidAPIError(ToolError):
|
||||
"""Raised when the Unraid API returns an error or is unreachable."""
|
||||
pass
|
||||
|
||||
|
||||
class SubscriptionError(ToolError):
|
||||
"""Raised when there are WebSocket subscription-related errors."""
|
||||
pass
|
||||
|
||||
|
||||
class ValidationError(ToolError):
|
||||
"""Raised when input validation fails."""
|
||||
pass
|
||||
|
||||
|
||||
class IdempotentOperationError(ToolError):
|
||||
"""Raised when an operation is idempotent (already in desired state).
|
||||
|
||||
This is used internally to signal that an operation was already complete,
|
||||
which should typically be converted to a success response rather than
|
||||
propagated as an error to the user.
|
||||
"""
|
||||
pass
|
||||
43
unraid_mcp/core/types.py
Normal file
43
unraid_mcp/core/types.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""Shared data types for Unraid MCP Server.
|
||||
|
||||
This module defines data classes and type definitions used across
|
||||
multiple modules for consistent data handling.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional, Union
|
||||
|
||||
|
||||
@dataclass
|
||||
class SubscriptionData:
|
||||
"""Container for subscription data with metadata."""
|
||||
data: Dict[str, Any]
|
||||
last_updated: datetime
|
||||
subscription_type: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class SystemHealth:
|
||||
"""Container for system health status information."""
|
||||
is_healthy: bool
|
||||
issues: list[str]
|
||||
warnings: list[str]
|
||||
last_checked: datetime
|
||||
component_status: Dict[str, str]
|
||||
|
||||
|
||||
@dataclass
|
||||
class APIResponse:
|
||||
"""Container for standardized API response data."""
|
||||
success: bool
|
||||
data: Optional[Dict[str, Any]] = None
|
||||
error: Optional[str] = None
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
# Type aliases for common data structures
|
||||
ConfigValue = Union[str, int, bool, float, None]
|
||||
ConfigDict = Dict[str, ConfigValue]
|
||||
GraphQLVariables = Dict[str, Any]
|
||||
HealthStatus = Dict[str, Union[str, bool, int, list]]
|
||||
Reference in New Issue
Block a user