Files

285 lines
10 KiB
Python

"""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",),
),
)