forked from HomeLab/unraid-mcp
fix: apply all PR review agent findings (silent failures, type safety, test gaps)
Addresses issues found by 4 parallel review agents (code-reviewer,
silent-failure-hunter, type-design-analyzer, pr-test-analyzer).
Source fixes:
- core/utils.py: add public safe_display_url() (moved from tools/health.py)
- core/client.py: rename _redact_sensitive → redact_sensitive (public API)
- core/types.py: add SubscriptionData.__post_init__ for tz-aware datetime
enforcement; remove 6 unused type aliases (SystemHealth, APIResponse, etc.)
- subscriptions/manager.py: add exc_info=True to both except-Exception blocks;
add except ValueError break-on-config-error before retry loop; import
redact_sensitive by new public name
- subscriptions/resources.py: re-raise in autostart_subscriptions() so
ensure_subscriptions_started() doesn't permanently set _subscriptions_started
- subscriptions/diagnostics.py: except ToolError: raise before broad except;
use safe_display_url() instead of raw URL slice
- tools/health.py: move _safe_display_url to core/utils; add exc_info=True;
raise ToolError (not return dict) on ImportError
- tools/info.py: use get_args(INFO_ACTIONS) instead of INFO_ACTIONS.__args__
- tools/{array,docker,keys,notifications,rclone,storage,virtualization}.py:
add Literal-vs-ALL_ACTIONS sync check at import time
Test fixes:
- test_health.py: import safe_display_url from core.utils; update
test_diagnose_import_error_internal to expect ToolError (not error dict)
- test_storage.py: add 3 safe_get tests for zero/False/empty-string values
- test_subscription_manager.py: add TestCapLogContentSingleMassiveLine (2 tests)
- test_client.py: rename _redact_sensitive → redact_sensitive; add tests for
new sensitive keys and is_cacheable explicit-keyword form
This commit is contained in:
@@ -7,6 +7,7 @@ to the Unraid API with proper timeout handling and error management.
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from typing import Any, Final
|
||||
|
||||
@@ -47,14 +48,14 @@ def _is_sensitive_key(key: str) -> bool:
|
||||
return any(s in key_lower for s in _SENSITIVE_KEYS)
|
||||
|
||||
|
||||
def _redact_sensitive(obj: Any) -> Any:
|
||||
def redact_sensitive(obj: Any) -> Any:
|
||||
"""Recursively redact sensitive values from nested dicts/lists."""
|
||||
if isinstance(obj, dict):
|
||||
return {
|
||||
k: ("***" if _is_sensitive_key(k) else _redact_sensitive(v)) for k, v in obj.items()
|
||||
k: ("***" if _is_sensitive_key(k) else redact_sensitive(v)) for k, v in obj.items()
|
||||
}
|
||||
if isinstance(obj, list):
|
||||
return [_redact_sensitive(item) for item in obj]
|
||||
return [redact_sensitive(item) for item in obj]
|
||||
return obj
|
||||
|
||||
|
||||
@@ -139,6 +140,7 @@ _CACHEABLE_QUERY_PREFIXES = frozenset(
|
||||
)
|
||||
|
||||
_CACHE_TTL_SECONDS = 60.0
|
||||
_OPERATION_NAME_PATTERN = re.compile(r"^(?:query\s+)?([_A-Za-z][_0-9A-Za-z]*)\b")
|
||||
|
||||
|
||||
class _QueryCache:
|
||||
@@ -160,9 +162,13 @@ class _QueryCache:
|
||||
@staticmethod
|
||||
def is_cacheable(query: str) -> bool:
|
||||
"""Check if a query is eligible for caching based on its operation name."""
|
||||
if query.lstrip().startswith("mutation"):
|
||||
normalized = query.lstrip()
|
||||
if normalized.startswith("mutation"):
|
||||
return False
|
||||
return any(prefix in query for prefix in _CACHEABLE_QUERY_PREFIXES)
|
||||
match = _OPERATION_NAME_PATTERN.match(normalized)
|
||||
if not match:
|
||||
return False
|
||||
return match.group(1) in _CACHEABLE_QUERY_PREFIXES
|
||||
|
||||
def get(self, query: str, variables: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
"""Return cached result if present and not expired, else None."""
|
||||
@@ -324,7 +330,7 @@ async def make_graphql_request(
|
||||
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: {_redact_sensitive(variables)}")
|
||||
logger.debug(f"Variables: {redact_sensitive(variables)}")
|
||||
|
||||
try:
|
||||
# Rate limit: consume a token before making the request
|
||||
|
||||
@@ -45,13 +45,12 @@ def tool_error_handler(
|
||||
except ToolError:
|
||||
raise
|
||||
except TimeoutError as e:
|
||||
logger.error(
|
||||
f"Timeout in unraid_{tool_name} action={action}: request exceeded time limit",
|
||||
exc_info=True,
|
||||
)
|
||||
logger.exception(f"Timeout in unraid_{tool_name} action={action}: request exceeded time limit")
|
||||
raise ToolError(
|
||||
f"Request timed out executing {tool_name}/{action}. The Unraid API did not respond in time."
|
||||
) from e
|
||||
except Exception as e:
|
||||
logger.error(f"Error in unraid_{tool_name} action={action}: {e}", exc_info=True)
|
||||
raise ToolError(f"Failed to execute {tool_name}/{action}: {e!s}") from e
|
||||
logger.exception(f"Error in unraid_{tool_name} action={action}")
|
||||
raise ToolError(
|
||||
f"Failed to execute {tool_name}/{action}. Check server logs for details."
|
||||
) from e
|
||||
|
||||
@@ -20,33 +20,10 @@ class SubscriptionData:
|
||||
last_updated: datetime # Must be timezone-aware (UTC)
|
||||
subscription_type: str
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SystemHealth:
|
||||
"""Container for system health status information.
|
||||
|
||||
Note: last_checked must be timezone-aware (use datetime.now(UTC)).
|
||||
"""
|
||||
|
||||
is_healthy: bool
|
||||
issues: list[str]
|
||||
warnings: list[str]
|
||||
last_checked: datetime # Must be timezone-aware (UTC)
|
||||
component_status: dict[str, str]
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class APIResponse:
|
||||
"""Container for standardized API response data."""
|
||||
|
||||
success: bool
|
||||
data: dict[str, Any] | None = None
|
||||
error: str | None = None
|
||||
metadata: dict[str, Any] | None = None
|
||||
|
||||
|
||||
# Type aliases for common data structures
|
||||
ConfigValue = str | int | bool | float | None
|
||||
ConfigDict = dict[str, ConfigValue]
|
||||
GraphQLVariables = dict[str, Any]
|
||||
HealthStatus = dict[str, str | bool | int | list[Any]]
|
||||
def __post_init__(self) -> None:
|
||||
if self.last_updated.tzinfo is None:
|
||||
raise ValueError(
|
||||
"last_updated must be timezone-aware; use datetime.now(UTC)"
|
||||
)
|
||||
if not self.subscription_type.strip():
|
||||
raise ValueError("subscription_type must be a non-empty string")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Shared utility functions for Unraid MCP tools."""
|
||||
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
def safe_get(data: dict[str, Any], *keys: str, default: Any = None) -> Any:
|
||||
@@ -45,6 +46,25 @@ def format_bytes(bytes_value: int | None) -> str:
|
||||
return f"{value:.2f} EB"
|
||||
|
||||
|
||||
def safe_display_url(url: str | None) -> str | None:
|
||||
"""Return a redacted URL showing only scheme + host + port.
|
||||
|
||||
Strips path, query parameters, credentials, and fragments to avoid
|
||||
leaking internal network topology or embedded secrets (CWE-200).
|
||||
"""
|
||||
if not url:
|
||||
return None
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
host = parsed.hostname or "unknown"
|
||||
if parsed.port:
|
||||
return f"{parsed.scheme}://{host}:{parsed.port}"
|
||||
return f"{parsed.scheme}://{host}"
|
||||
except ValueError:
|
||||
# urlparse raises ValueError for invalid URLs (e.g. contains control chars)
|
||||
return "<unparseable>"
|
||||
|
||||
|
||||
def format_kb(k: Any) -> str:
|
||||
"""Format kilobyte values into human-readable sizes.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user