mirror of
https://github.com/jmagar/unraid-mcp.git
synced 2026-03-01 16:04:24 -08:00
chore: enhance project metadata, tooling, and documentation
**Project Configuration:** - Enhance pyproject.toml with comprehensive metadata, keywords, and classifiers - Add LICENSE file (MIT) for proper open-source distribution - Add PUBLISHING.md with comprehensive publishing guidelines - Update .gitignore to exclude tool artifacts (.cache, .pytest_cache, .ruff_cache, .ty_cache) - Ignore documentation working directories (.docs, .full-review, docs/plans, docs/sessions) **Documentation:** - Add extensive Unraid API research documentation - API source code analysis and resolver mapping - Competitive analysis and feature gap assessment - Release notes analysis (7.0.0, 7.1.0, 7.2.0) - Connect platform overview and remote access documentation - Document known API patterns, limitations, and edge cases **Testing & Code Quality:** - Expand test coverage across all tool modules - Add destructive action confirmation tests - Improve test assertions and error case validation - Refine type annotations for better static analysis **Tool Improvements:** - Enhance error handling consistency across all tools - Improve type safety with explicit type annotations - Refine GraphQL query construction patterns - Better handling of optional parameters and edge cases This commit prepares the project for v0.2.0 release with improved metadata, comprehensive documentation, and enhanced code quality. Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -55,7 +55,6 @@ class OverwriteFileHandler(logging.FileHandler):
|
||||
# Close current stream
|
||||
if self.stream:
|
||||
self.stream.close()
|
||||
self.stream = None
|
||||
|
||||
# Remove the old file and start fresh
|
||||
if os.path.exists(self.baseFilename):
|
||||
|
||||
@@ -20,6 +20,19 @@ from ..config.settings import (
|
||||
)
|
||||
from ..core.exceptions import ToolError
|
||||
|
||||
# Sensitive keys to redact from debug logs
|
||||
_SENSITIVE_KEYS = {"password", "key", "secret", "token", "apikey"}
|
||||
|
||||
|
||||
def _redact_sensitive(obj: Any) -> Any:
|
||||
"""Recursively redact sensitive values from nested dicts/lists."""
|
||||
if isinstance(obj, dict):
|
||||
return {k: ("***" if k.lower() in _SENSITIVE_KEYS else _redact_sensitive(v)) for k, v in obj.items()}
|
||||
if isinstance(obj, list):
|
||||
return [_redact_sensitive(item) for item in obj]
|
||||
return obj
|
||||
|
||||
|
||||
# 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)
|
||||
@@ -142,12 +155,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:
|
||||
_SENSITIVE_KEYS = {"password", "key", "secret", "token", "apiKey"}
|
||||
redacted = {
|
||||
k: ("***" if k.lower() in _SENSITIVE_KEYS else v)
|
||||
for k, v in (variables.get("input", variables) if isinstance(variables.get("input"), dict) else variables).items()
|
||||
}
|
||||
logger.debug(f"Variables: {redacted}")
|
||||
logger.debug(f"Variables: {_redact_sensitive(variables)}")
|
||||
|
||||
try:
|
||||
# Get the shared HTTP client with connection pooling
|
||||
|
||||
@@ -107,7 +107,7 @@ DOCKER_ACTIONS = Literal[
|
||||
]
|
||||
|
||||
# Docker container IDs: 64 hex chars + optional suffix (e.g., ":local")
|
||||
_DOCKER_ID_PATTERN = re.compile(r"^[a-f0-9]{64}(:[a-z0-9]+)?$", re.IGNORECASE)
|
||||
_DOCKER_ID_PATTERN = re.compile(r"^[a-f0-9]{64}(:[a-z0-9]+)?$")
|
||||
|
||||
|
||||
def find_container_by_identifier(
|
||||
@@ -126,7 +126,7 @@ def find_container_by_identifier(
|
||||
id_lower = identifier.lower()
|
||||
for c in containers:
|
||||
for name in c.get("names", []):
|
||||
if id_lower in name.lower() or name.lower() in id_lower:
|
||||
if id_lower in name.lower():
|
||||
logger.info(f"Fuzzy match: '{identifier}' -> '{name}'")
|
||||
return c
|
||||
|
||||
@@ -273,7 +273,10 @@ def register_docker_tool(mcp: FastMCP) -> None:
|
||||
MUTATIONS["start"], {"id": actual_id},
|
||||
operation_context={"operation": "start"},
|
||||
)
|
||||
result = start_data.get("docker", {}).get("start", {})
|
||||
if start_data.get("idempotent_success"):
|
||||
result = {}
|
||||
else:
|
||||
result = start_data.get("docker", {}).get("start", {})
|
||||
response: dict[str, Any] = {
|
||||
"success": True, "action": "restart", "container": result,
|
||||
}
|
||||
@@ -312,7 +315,7 @@ def register_docker_tool(mcp: FastMCP) -> None:
|
||||
"container": result,
|
||||
}
|
||||
|
||||
return {}
|
||||
raise ToolError(f"Unhandled action '{action}' — this is a bug")
|
||||
|
||||
except ToolError:
|
||||
raise
|
||||
|
||||
@@ -65,7 +65,7 @@ def register_health_tool(mcp: FastMCP) -> None:
|
||||
if action == "diagnose":
|
||||
return await _diagnose_subscriptions()
|
||||
|
||||
return {}
|
||||
raise ToolError(f"Unhandled action '{action}' — this is a bug")
|
||||
|
||||
except ToolError:
|
||||
raise
|
||||
|
||||
@@ -59,7 +59,7 @@ QUERIES: dict[str, str] = {
|
||||
query GetRegistrationInfo {
|
||||
registration {
|
||||
id type
|
||||
keyFile { location contents }
|
||||
keyFile { location }
|
||||
state expiration updateExpiration
|
||||
}
|
||||
}
|
||||
@@ -366,7 +366,8 @@ def register_info_tool(mcp: FastMCP) -> None:
|
||||
if action == "settings":
|
||||
settings = data.get("settings", {})
|
||||
if settings and settings.get("unified"):
|
||||
return dict(settings["unified"].get("values", {}))
|
||||
values = settings["unified"].get("values", {})
|
||||
return dict(values) if isinstance(values, dict) else {"raw": values}
|
||||
return {}
|
||||
|
||||
if action == "server":
|
||||
@@ -389,7 +390,7 @@ def register_info_tool(mcp: FastMCP) -> None:
|
||||
if action == "ups_config":
|
||||
return dict(data.get("upsConfiguration", {}))
|
||||
|
||||
return data
|
||||
raise ToolError(f"Unhandled action '{action}' — this is a bug")
|
||||
|
||||
except ToolError:
|
||||
raise
|
||||
|
||||
@@ -135,7 +135,7 @@ def register_keys_tool(mcp: FastMCP) -> None:
|
||||
"message": f"API key '{key_id}' deleted",
|
||||
}
|
||||
|
||||
return {}
|
||||
raise ToolError(f"Unhandled action '{action}' — this is a bug")
|
||||
|
||||
except ToolError:
|
||||
raise
|
||||
|
||||
@@ -157,7 +157,7 @@ def register_notifications_tool(mcp: FastMCP) -> None:
|
||||
"title": title,
|
||||
"subject": subject,
|
||||
"description": description,
|
||||
"importance": importance.upper() if importance else "INFO",
|
||||
"importance": importance.upper(),
|
||||
}
|
||||
data = await make_graphql_request(
|
||||
MUTATIONS["create"], {"input": input_data}
|
||||
@@ -194,7 +194,7 @@ def register_notifications_tool(mcp: FastMCP) -> None:
|
||||
data = await make_graphql_request(MUTATIONS["archive_all"], variables)
|
||||
return {"success": True, "action": "archive_all", "data": data}
|
||||
|
||||
return {}
|
||||
raise ToolError(f"Unhandled action '{action}' — this is a bug")
|
||||
|
||||
except ToolError:
|
||||
raise
|
||||
|
||||
@@ -121,7 +121,7 @@ def register_rclone_tool(mcp: FastMCP) -> None:
|
||||
"message": f"Remote '{name}' deleted successfully",
|
||||
}
|
||||
|
||||
return {}
|
||||
raise ToolError(f"Unhandled action '{action}' — this is a bug")
|
||||
|
||||
except ToolError:
|
||||
raise
|
||||
|
||||
@@ -95,8 +95,15 @@ def register_storage_tool(mcp: FastMCP) -> None:
|
||||
if action == "disk_details" and not disk_id:
|
||||
raise ToolError("disk_id is required for 'disk_details' action")
|
||||
|
||||
if action == "logs" and not log_path:
|
||||
raise ToolError("log_path is required for 'logs' action")
|
||||
if action == "logs":
|
||||
if not log_path:
|
||||
raise ToolError("log_path is required for 'logs' action")
|
||||
_ALLOWED_LOG_PREFIXES = ("/var/log/", "/boot/logs/", "/mnt/")
|
||||
if not any(log_path.startswith(p) for p in _ALLOWED_LOG_PREFIXES):
|
||||
raise ToolError(
|
||||
f"log_path must start with one of: {', '.join(_ALLOWED_LOG_PREFIXES)}. "
|
||||
f"Use log_files action to discover valid paths."
|
||||
)
|
||||
|
||||
query = QUERIES[action]
|
||||
variables: dict[str, Any] | None = None
|
||||
@@ -148,7 +155,7 @@ def register_storage_tool(mcp: FastMCP) -> None:
|
||||
if action == "logs":
|
||||
return dict(data.get("logFile", {}))
|
||||
|
||||
return data
|
||||
raise ToolError(f"Unhandled action '{action}' — this is a bug")
|
||||
|
||||
except ToolError:
|
||||
raise
|
||||
|
||||
@@ -30,7 +30,7 @@ QUERIES: dict[str, str] = {
|
||||
""",
|
||||
"cloud": """
|
||||
query GetCloud {
|
||||
cloud { status apiKey error }
|
||||
cloud { status error }
|
||||
}
|
||||
""",
|
||||
"remote_access": """
|
||||
@@ -152,7 +152,7 @@ def register_users_tool(mcp: FastMCP) -> None:
|
||||
origins = data.get("allowedOrigins", [])
|
||||
return {"origins": list(origins) if isinstance(origins, list) else []}
|
||||
|
||||
return {}
|
||||
raise ToolError(f"Unhandled action '{action}' — this is a bug")
|
||||
|
||||
except ToolError:
|
||||
raise
|
||||
|
||||
@@ -94,12 +94,12 @@ def register_vm_tool(mcp: FastMCP) -> None:
|
||||
if action not in all_actions:
|
||||
raise ToolError(f"Invalid action '{action}'. Must be one of: {sorted(all_actions)}")
|
||||
|
||||
if action in DESTRUCTIVE_ACTIONS and not confirm:
|
||||
raise ToolError(f"Action '{action}' is destructive. Set confirm=True to proceed.")
|
||||
|
||||
if action != "list" and not vm_id:
|
||||
raise ToolError(f"vm_id is required for '{action}' action")
|
||||
|
||||
if action in DESTRUCTIVE_ACTIONS and not confirm:
|
||||
raise ToolError(f"Action '{action}' is destructive. Set confirm=True to proceed.")
|
||||
|
||||
try:
|
||||
logger.info(f"Executing unraid_vm action={action}")
|
||||
|
||||
@@ -143,7 +143,7 @@ def register_vm_tool(mcp: FastMCP) -> None:
|
||||
}
|
||||
raise ToolError(f"Failed to {action} VM or unexpected response")
|
||||
|
||||
return {}
|
||||
raise ToolError(f"Unhandled action '{action}' — this is a bug")
|
||||
|
||||
except ToolError:
|
||||
raise
|
||||
|
||||
Reference in New Issue
Block a user