Initial curated ITWorx_MCP_Hub source from 368fba691ea8d23403b4d56b53b5ec670d7c5020
This commit is contained in:
commit
cb5be898ea
663 files changed
+82674
No files matched your search
@@ -0,0 +1,12 @@
|
||||
"""Read-only Unraid connector backed exclusively by the Docker read broker."""
|
||||
|
||||
from .fake import FakeBrokerClient
|
||||
from .server import UNRAID_RESOURCES, UNRAID_TOOLS, RegisteredContainer, UnraidConnector
|
||||
|
||||
__all__ = [
|
||||
"FakeBrokerClient",
|
||||
"RegisteredContainer",
|
||||
"UNRAID_RESOURCES",
|
||||
"UNRAID_TOOLS",
|
||||
"UnraidConnector",
|
||||
]
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Typed HTTP client for the internal Docker read broker."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
from typing import Any, Protocol
|
||||
|
||||
import httpx
|
||||
|
||||
from packages.connector_kit import ConnectorContext
|
||||
from packages.hub_runtime import issue_broker_service_token
|
||||
|
||||
|
||||
class BrokerClientError(RuntimeError):
|
||||
def __init__(self, code: str, message: str, status: int = 502) -> None:
|
||||
self.code = code
|
||||
self.status = status
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class BrokerTokenProvider(Protocol):
|
||||
def get_token(self, context: ConnectorContext) -> str: ...
|
||||
|
||||
|
||||
class FileBrokerTokenProvider:
|
||||
def __init__(self, path: str = "/run/secrets/docker_broker_service_token") -> None:
|
||||
self.path = Path(path)
|
||||
|
||||
def get_token(self, context: ConnectorContext) -> str:
|
||||
try:
|
||||
secret = self.path.read_text(encoding="utf-8").strip()
|
||||
except OSError as exc:
|
||||
raise BrokerClientError(
|
||||
"BROKER_TOKEN_UNAVAILABLE", "broker token is unavailable", 503
|
||||
) from exc
|
||||
if len(secret) < 32:
|
||||
raise BrokerClientError("BROKER_TOKEN_INVALID", "broker signing key is invalid", 503)
|
||||
return issue_broker_service_token(
|
||||
secret,
|
||||
context.project_slug,
|
||||
context.environment,
|
||||
ttl_seconds=60,
|
||||
)
|
||||
|
||||
|
||||
class HttpBrokerClient:
|
||||
"""Only named broker operations exist; arbitrary paths are not accepted."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
token_provider: BrokerTokenProvider,
|
||||
*,
|
||||
transport: httpx.BaseTransport | None = None,
|
||||
) -> None:
|
||||
if not base_url.startswith("http://") or any(char in base_url for char in "?#"):
|
||||
raise ValueError("broker base URL must be an internal HTTP origin without query data")
|
||||
self._client = httpx.Client(base_url=base_url.rstrip("/"), timeout=10, transport=transport)
|
||||
self._token_provider = token_provider
|
||||
|
||||
def close(self) -> None:
|
||||
self._client.close()
|
||||
|
||||
def _get(
|
||||
self, context: ConnectorContext, path: str, params: Mapping[str, str | int] | None = None
|
||||
) -> dict[str, Any]:
|
||||
token = self._token_provider.get_token(context)
|
||||
try:
|
||||
response = self._client.get(
|
||||
path, params=params, headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
except (httpx.TimeoutException, httpx.NetworkError) as exc:
|
||||
raise BrokerClientError(
|
||||
"BROKER_UNAVAILABLE", "Docker read broker is unavailable", 503
|
||||
) from exc
|
||||
if response.status_code >= 400:
|
||||
try:
|
||||
detail = response.json()
|
||||
except ValueError:
|
||||
detail = {}
|
||||
raise BrokerClientError(
|
||||
str(detail.get("code", "BROKER_REQUEST_FAILED")),
|
||||
"Docker read broker denied the request",
|
||||
response.status_code,
|
||||
)
|
||||
try:
|
||||
value = response.json()
|
||||
except ValueError as exc:
|
||||
raise BrokerClientError(
|
||||
"BROKER_INVALID_RESPONSE", "Docker read broker returned invalid JSON"
|
||||
) from exc
|
||||
if not isinstance(value, dict):
|
||||
raise BrokerClientError(
|
||||
"BROKER_INVALID_RESPONSE", "Docker read broker returned an unexpected response"
|
||||
)
|
||||
return value
|
||||
|
||||
def host(self, context: ConnectorContext) -> dict[str, Any]:
|
||||
return self._get(context, "/v1/host")
|
||||
|
||||
def storage(self, context: ConnectorContext) -> dict[str, Any]:
|
||||
return self._get(context, "/v1/storage")
|
||||
|
||||
def containers(self, context: ConnectorContext, state: str | None = None) -> dict[str, Any]:
|
||||
return self._get(context, "/v1/containers", {"state": state} if state else None)
|
||||
|
||||
def inspect(self, context: ConnectorContext, registered_id: str) -> dict[str, Any]:
|
||||
return self._get(context, f"/v1/containers/{registered_id}")
|
||||
|
||||
def health(self, context: ConnectorContext, registered_id: str) -> dict[str, Any]:
|
||||
return self._get(context, f"/v1/containers/{registered_id}/health")
|
||||
|
||||
def logs(
|
||||
self,
|
||||
context: ConnectorContext,
|
||||
registered_id: str,
|
||||
*,
|
||||
tail: int,
|
||||
since_seconds: int | None,
|
||||
contains: str | None,
|
||||
) -> dict[str, Any]:
|
||||
params: dict[str, str | int] = {"tail": tail}
|
||||
if since_seconds is not None:
|
||||
params["sinceSeconds"] = since_seconds
|
||||
if contains:
|
||||
params["contains"] = contains
|
||||
return self._get(context, f"/v1/containers/{registered_id}/logs", params)
|
||||
|
||||
def networks(self, context: ConnectorContext) -> dict[str, Any]:
|
||||
return self._get(context, "/v1/networks")
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Deterministic broker client fake for Unraid connector tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from packages.connector_kit import ConnectorContext
|
||||
|
||||
|
||||
class FakeBrokerClient:
|
||||
def __init__(self, *, fail: bool = False) -> None:
|
||||
self.fail = fail
|
||||
self.calls: list[str] = []
|
||||
|
||||
def _check(self, operation: str) -> None:
|
||||
self.calls.append(operation)
|
||||
if self.fail:
|
||||
raise RuntimeError("broker unavailable")
|
||||
|
||||
def host(self, context: ConnectorContext) -> dict[str, Any]:
|
||||
self._check("host")
|
||||
return {
|
||||
"hostRef": "unraid",
|
||||
"hostname": "unraid-test",
|
||||
"uptimeSeconds": 3600,
|
||||
"cpuPercent": 12.5,
|
||||
"memoryUsedBytes": 100,
|
||||
"memoryTotalBytes": 1000,
|
||||
"dockerVersion": "27.0.0",
|
||||
}
|
||||
|
||||
def storage(self, context: ConnectorContext) -> dict[str, Any]:
|
||||
self._check("storage")
|
||||
return {
|
||||
"items": [
|
||||
{"name": "appdata", "driver": "local", "usageBytes": 1024, "capacityBytes": 4096}
|
||||
]
|
||||
}
|
||||
|
||||
def containers(self, context: ConnectorContext, state: str | None = None) -> dict[str, Any]:
|
||||
self._check("containers")
|
||||
return {
|
||||
"items": [
|
||||
{
|
||||
"containerRef": "ludarium-web",
|
||||
"name": "ludarium-web",
|
||||
"image": "web:1",
|
||||
"state": state or "running",
|
||||
"health": "healthy",
|
||||
"startedAt": "2026-08-01T10:00:00Z",
|
||||
"restartCount": 2,
|
||||
"ports": [],
|
||||
"networks": ["hub-connectors"],
|
||||
"labels": {"com.docker.compose.project": "ludarium"},
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
def inspect(self, context: ConnectorContext, registered_id: str) -> dict[str, Any]:
|
||||
self._check("inspect")
|
||||
return dict(self.containers(context)["items"][0]) | {"containerRef": registered_id}
|
||||
|
||||
def health(self, context: ConnectorContext, registered_id: str) -> dict[str, Any]:
|
||||
self._check("health")
|
||||
return {"status": "healthy", "running": True, "restartCount": 2}
|
||||
|
||||
def logs(
|
||||
self,
|
||||
context: ConnectorContext,
|
||||
registered_id: str,
|
||||
*,
|
||||
tail: int,
|
||||
since_seconds: int | None,
|
||||
contains: str | None,
|
||||
) -> dict[str, Any]:
|
||||
self._check("logs")
|
||||
return {
|
||||
"containerRef": registered_id,
|
||||
"lines": ["ready"],
|
||||
"truncated": False,
|
||||
"redactionsApplied": 0,
|
||||
}
|
||||
|
||||
def networks(self, context: ConnectorContext) -> dict[str, Any]:
|
||||
self._check("networks")
|
||||
return {
|
||||
"items": [
|
||||
{
|
||||
"name": "hub-connectors",
|
||||
"networkRef": "network-1",
|
||||
"driver": "bridge",
|
||||
"scope": "local",
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
"""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",),
|
||||
),
|
||||
)
|
||||
Reference in new issue
Block a user