"""Project-scoped Unraid MCP connector; no Docker socket dependency.""" from __future__ import annotations from collections.abc import Callable from datetime import UTC, datetime from typing import Any from uuid import UUID from packages.connector_kit import ( ConnectorContext, PageRequest, ResourcePublication, ToolPublication, decode_cursor, encode_cursor, ) from packages.hub_policy import Governance, governed_tool from .client import BrokerClientError class RegisteredContainer: def __init__(self, registered_id: str, project_id: UUID, environment: str) -> None: self.registered_id = registered_id self.project_id = project_id self.environment = environment class UnraidConnector: connector_type = "unraid" version = "1.0.0" def __init__( self, client: Any, registrations: tuple[RegisteredContainer, ...], governance: Governance | None = None, ) -> None: self.client = client self.governance = governance self._registrations = {item.registered_id: item for item in registrations} def _resolve(self, context: ConnectorContext, registered_id: str) -> RegisteredContainer: item = self._registrations.get(registered_id) if ( item is None or item.project_id != context.project_id or item.environment != context.environment ): raise BrokerClientError( "UNRAID_CONTAINER_NOT_REGISTERED", "container is not registered for this project", 403, ) return item @staticmethod def _envelope( context: ConnectorContext, data: Any, *, truncated: bool = False, next_cursor: str | None = None, ) -> dict[str, Any]: return { "data": data, "meta": { "project": context.project_slug, "environment": context.environment, "source": "unraid:docker-read-broker", "truncated": truncated, "nextCursor": next_cursor, "generatedAt": datetime.now(UTC).isoformat(), }, "warnings": [], } @governed_tool("unraid.host.status") def host_status(self, context: ConnectorContext) -> dict[str, Any]: return self._envelope(context, {"hostRef": "unraid", **self.client.host(context)}) @governed_tool("unraid.storage.list") def storage_list( self, context: ConnectorContext, *, page: PageRequest | None = None ) -> dict[str, Any]: return self._page_items( context, self.client.storage(context).get("items", []), page, "storage" ) @governed_tool("unraid.container.list") def container_list( self, context: ConnectorContext, *, state: str | None = None, page: PageRequest | None = None, ) -> dict[str, Any]: result = self.client.containers(context, state) return self._page_items(context, result.get("items", []), page, "containers") @governed_tool("unraid.container.inspect") def container_inspect(self, context: ConnectorContext, container_ref: str) -> dict[str, Any]: self._resolve(context, container_ref) return self._envelope(context, self.client.inspect(context, container_ref)) @governed_tool("unraid.container.health") def container_health(self, context: ConnectorContext, container_ref: str) -> dict[str, Any]: self._resolve(context, container_ref) return self._envelope(context, self.client.health(context, container_ref)) @governed_tool("unraid.container.logs") def container_logs( self, context: ConnectorContext, container_ref: str, *, tail: int = 200, since_seconds: int | None = None, contains: str | None = None, ) -> dict[str, Any]: self._resolve(context, container_ref) if ( not 1 <= tail <= 500 or (since_seconds is not None and not 1 <= since_seconds <= 86400) or (contains is not None and len(contains) > 200) ): raise ValueError("log request is outside the connector budget") return self._envelope( context, self.client.logs( context, container_ref, tail=tail, since_seconds=since_seconds, contains=contains ), ) @governed_tool("unraid.network.list") def network_list( self, context: ConnectorContext, *, page: PageRequest | None = None ) -> dict[str, Any]: return self._page_items( context, self.client.networks(context).get("items", []), page, "networks" ) @governed_tool("unraid.project.summary") def project_summary(self, context: ConnectorContext) -> dict[str, Any]: expected = sorted( item.registered_id for item in self._registrations.values() if item.project_id == context.project_id and item.environment == context.environment ) raw = self.client.containers(context, None).get("items", []) values = raw if isinstance(raw, list) else [] containers = [ item for item in values if isinstance(item, dict) and str(item.get("containerRef", "")) in expected ] observed = {str(item.get("containerRef", "")) for item in containers} running = sum(item.get("state") == "running" for item in containers) healthy = sum(item.get("health") == "healthy" for item in containers) degraded = [ str(item.get("containerRef", "")) for item in containers if item.get("state") != "running" or item.get("health") not in {"healthy", "none"} ] missing = [item for item in expected if item not in observed] return self._envelope( context, { "status": "healthy" if not degraded and not missing else "degraded", "expectedContainers": len(expected), "observedContainers": len(containers), "runningContainers": running, "healthyContainers": healthy, "missingContainerRefs": missing, "degradedContainerRefs": degraded, "containers": containers, }, ) @governed_tool("unraid.resource.get") def resource_get(self, context: ConnectorContext, container_ref: str) -> dict[str, Any]: return self.container_inspect(context, container_ref) def _page_items( self, context: ConnectorContext, items: Any, page: PageRequest | None, kind: str ) -> dict[str, Any]: request = page or PageRequest(limit=25) values = list(items) if isinstance(items, list) else [] cursor = decode_cursor(request.cursor, str(context.project_id), kind, str(request.limit)) selected = values[cursor.offset : cursor.offset + request.limit] next_cursor = None if cursor.offset + len(selected) < len(values): next_cursor = encode_cursor( cursor.offset + len(selected), str(context.project_id), kind, str(request.limit) ) data = {"items": selected, "nextCursor": next_cursor, "truncated": next_cursor is not None} return self._envelope( context, data, truncated=next_cursor is not None, next_cursor=next_cursor ) def tools(self) -> tuple[ToolPublication, ...]: return UNRAID_TOOLS def resources(self) -> tuple[ResourcePublication, ...]: return UNRAID_RESOURCES def invoke(self, tool: str, context: ConnectorContext, **arguments: Any) -> dict[str, Any]: methods: dict[str, Callable[..., dict[str, Any]]] = { "unraid.host.status": self.host_status, "unraid.storage.list": self.storage_list, "unraid.container.list": self.container_list, "unraid.container.inspect": self.container_inspect, "unraid.container.health": self.container_health, "unraid.container.logs": self.container_logs, "unraid.network.list": self.network_list, "unraid.project.summary": self.project_summary, } method = methods.get(tool) if method is None: raise ValueError("unknown unraid tool") return method(context, **arguments) def health(self) -> dict[str, Any]: return {"status": "healthy", "component": "unraid-connector", "broker": "required"} def readiness(self) -> dict[str, Any]: return {"status": "ready", "component": "unraid-connector", "broker": "required"} _PAGE_INPUT = { "type": "object", "additionalProperties": False, "properties": { "limit": {"type": "integer", "minimum": 1, "maximum": 100}, "cursor": {"type": ["string", "null"], "maxLength": 500}, }, "required": [], } _CONTAINER_INPUT = { "type": "object", "additionalProperties": False, "properties": {"containerRef": {"type": "string", "pattern": "^[a-z0-9][a-z0-9-]{0,127}$"}}, "required": ["containerRef"], } UNRAID_TOOLS = tuple( ToolPublication( canonical_name=name, version="1.0.0", connector_type="unraid", risk_class="R1", input_schema=input_schema, output_schema={"type": "object"}, required_scopes=("project:{project}:read",), ) for name, input_schema in ( ( "unraid.host.status", {"type": "object", "additionalProperties": False, "properties": {}, "required": []}, ), ( "unraid.project.summary", {"type": "object", "additionalProperties": False, "properties": {}, "required": []}, ), ("unraid.storage.list", _PAGE_INPUT), ("unraid.container.list", _PAGE_INPUT), ("unraid.container.inspect", _CONTAINER_INPUT), ("unraid.container.health", _CONTAINER_INPUT), ("unraid.container.logs", _CONTAINER_INPUT), ("unraid.network.list", _PAGE_INPUT), ) ) UNRAID_RESOURCES = ( ResourcePublication( uri_template="itworx-unraid://{project}/{environment}/container/{containerRef}", connector_type="unraid", risk_class="R1", mime_types=("application/json",), max_bytes=131072, required_scopes=("project:{project}:read",), ), )