Initial curated Unraid-AppOps-Gateway source from 3bd6f6fee310125bfbb4d4650389a817f498724d
This commit is contained in:
commit
1dfbca08a1
528 files changed
+131054
No files matched your search
@@ -0,0 +1,839 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from collections import defaultdict
|
||||
from datetime import UTC, datetime
|
||||
from functools import wraps
|
||||
from pathlib import Path
|
||||
from threading import Lock
|
||||
from time import perf_counter
|
||||
from typing import Any
|
||||
|
||||
from mcp.server import MCPServer
|
||||
from mcp.server.transport_security import TransportSecuritySettings
|
||||
from mcp.types import ToolAnnotations
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from .api.routes_actions import action_registry as build_action_registry
|
||||
from .api.routes_system import system_summary as build_system_summary
|
||||
from .connectors.gitea_actions import GiteaActionsConnector
|
||||
from .core.config import settings
|
||||
from .core.redaction import redact_message, redact_value
|
||||
from .core.time import utc_now
|
||||
from .core.version import APP_VERSION
|
||||
from .db.models import AppSetting, AuditEvent
|
||||
from .db.session import engine
|
||||
from .schemas.deployment import DeploymentPlanV1
|
||||
from .schemas.enrollment import EnrollmentPlanV1
|
||||
from .services import knowledge_service, mcp_operations, safety_center_service
|
||||
from .services.action_execution import mcp_execution_supported
|
||||
from .services.action_registry import ACTION_RISK, ACTIONS, action_capability
|
||||
from .services.app_registry import list_active_apps
|
||||
from .services.audit_logger import log_event
|
||||
from .services.deployment_broker import DeploymentBrokerClient
|
||||
from .services.deployment_control_plane import (
|
||||
DeploymentControlError,
|
||||
DeploymentControlPlane,
|
||||
)
|
||||
from .services.enrollment_plane import EnrollmentError, EnrollmentPlane
|
||||
from .services.gitea_project_auto_registration import GiteaProjectAutoRegistration
|
||||
from .services.inventory_discovery import list_inventory_containers
|
||||
from .services.policy_engine import evaluate_policy
|
||||
from .services.privatemuse_artifact_installer import ArtifactInstallError
|
||||
from .services.privatemuse_artifact_installer import install as install_artifact_file
|
||||
from .services.privatemuse_artifact_installer import preflight as artifact_preflight
|
||||
from .services.runtime_service import runtime_info
|
||||
|
||||
_SESSION_RUNNING = False
|
||||
_SESSION_VERIFIED_AT: str | None = None
|
||||
_TOOL_METRICS: dict[str, dict[str, float | int | str | None]] = defaultdict(
|
||||
lambda: {
|
||||
"calls": 0,
|
||||
"errors": 0,
|
||||
"totalMs": 0.0,
|
||||
"maxMs": 0.0,
|
||||
"lastCalledAt": None,
|
||||
}
|
||||
)
|
||||
_TOOL_METRICS_LOCK = Lock()
|
||||
_TOOL_METRICS_SETTING = "observability:mcp_tool_metrics"
|
||||
_CANARY_STATUS: dict[str, Any] = {
|
||||
"status": "never",
|
||||
"lastCheckedAt": None,
|
||||
"durationMs": None,
|
||||
}
|
||||
_MCP_EXECUTION_LOCK = Lock()
|
||||
_ARTIFACT_INSTALL_LOCK = Lock()
|
||||
_SELF_UPGRADE_LOCK = Lock()
|
||||
|
||||
|
||||
def _artifact_roots() -> tuple[Path, Path]:
|
||||
return (Path(os.environ.get("APPOPS_PRIVATEMUSE_COMFYUI_MODEL_ROOT", "/mnt/privatemuse-comfyui-models")),
|
||||
Path(os.environ.get("APPOPS_PRIVATEMUSE_ARTIFACT_RECEIPT_ROOT", "/data/privatemuse-artifact-receipts")))
|
||||
|
||||
_READ_ONLY_TOOL = ToolAnnotations(
|
||||
read_only_hint=True,
|
||||
destructive_hint=False,
|
||||
idempotent_hint=True,
|
||||
open_world_hint=False,
|
||||
)
|
||||
_OPERATION_RUN_TOOL = ToolAnnotations(
|
||||
read_only_hint=False,
|
||||
destructive_hint=True,
|
||||
idempotent_hint=True,
|
||||
open_world_hint=False,
|
||||
)
|
||||
_LEGACY_APPROVAL_EXECUTION_TOOL = ToolAnnotations(
|
||||
read_only_hint=False,
|
||||
destructive_hint=True,
|
||||
idempotent_hint=True,
|
||||
open_world_hint=False,
|
||||
)
|
||||
_CANCEL_TOOL = ToolAnnotations(
|
||||
read_only_hint=False,
|
||||
destructive_hint=False,
|
||||
idempotent_hint=True,
|
||||
open_world_hint=False,
|
||||
)
|
||||
|
||||
|
||||
def _tracked(tool_name: str):
|
||||
def decorate(function):
|
||||
@wraps(function)
|
||||
def wrapped(*args, **kwargs):
|
||||
started = perf_counter()
|
||||
failed = False
|
||||
try:
|
||||
return function(*args, **kwargs)
|
||||
except Exception:
|
||||
failed = True
|
||||
raise
|
||||
finally:
|
||||
duration_ms = (perf_counter() - started) * 1000
|
||||
with _TOOL_METRICS_LOCK:
|
||||
metrics = _TOOL_METRICS[tool_name]
|
||||
metrics["calls"] = int(metrics["calls"]) + 1
|
||||
metrics["errors"] = int(metrics["errors"]) + int(failed)
|
||||
metrics["totalMs"] = float(metrics["totalMs"]) + duration_ms
|
||||
metrics["maxMs"] = max(float(metrics["maxMs"]), duration_ms)
|
||||
metrics["lastCalledAt"] = datetime.now(UTC).isoformat()
|
||||
|
||||
return wrapped
|
||||
|
||||
return decorate
|
||||
|
||||
|
||||
def set_session_running(value: bool) -> None:
|
||||
global _SESSION_RUNNING, _SESSION_VERIFIED_AT
|
||||
_SESSION_RUNNING = value
|
||||
_SESSION_VERIFIED_AT = datetime.now(UTC).isoformat() if value else None
|
||||
|
||||
|
||||
def runtime_status() -> dict[str, Any]:
|
||||
with _TOOL_METRICS_LOCK:
|
||||
tool_metrics = {
|
||||
name: {
|
||||
"calls": int(values["calls"]),
|
||||
"errors": int(values["errors"]),
|
||||
"averageMs": round(
|
||||
float(values["totalMs"]) / max(1, int(values["calls"])), 2
|
||||
),
|
||||
"maxMs": round(float(values["maxMs"]), 2),
|
||||
"lastCalledAt": values["lastCalledAt"],
|
||||
}
|
||||
for name, values in _TOOL_METRICS.items()
|
||||
}
|
||||
return {
|
||||
"state": "listening"
|
||||
if settings.mcp_enabled and _SESSION_RUNNING
|
||||
else "configured"
|
||||
if settings.mcp_enabled
|
||||
else "disabled",
|
||||
"lastVerifiedAt": _SESSION_VERIFIED_AT,
|
||||
"toolCount": 25,
|
||||
"toolMetrics": tool_metrics,
|
||||
"canary": dict(_CANARY_STATUS),
|
||||
}
|
||||
|
||||
|
||||
def restore_tool_metrics(session: Session) -> int:
|
||||
row = session.get(AppSetting, _TOOL_METRICS_SETTING)
|
||||
payload = (row.value_json or {}).get("tools", {}) if row else {}
|
||||
restored = 0
|
||||
with _TOOL_METRICS_LOCK:
|
||||
if _TOOL_METRICS:
|
||||
return 0
|
||||
for name, values in payload.items():
|
||||
if not isinstance(values, dict):
|
||||
continue
|
||||
_TOOL_METRICS[str(name)[:120]] = {
|
||||
"calls": max(0, int(values.get("calls", 0))),
|
||||
"errors": max(0, int(values.get("errors", 0))),
|
||||
"totalMs": max(0.0, float(values.get("totalMs", 0.0))),
|
||||
"maxMs": max(0.0, float(values.get("maxMs", 0.0))),
|
||||
"lastCalledAt": values.get("lastCalledAt"),
|
||||
}
|
||||
restored += 1
|
||||
return restored
|
||||
|
||||
|
||||
def persist_tool_metrics(session: Session) -> int:
|
||||
with _TOOL_METRICS_LOCK:
|
||||
tools = {name: dict(values) for name, values in _TOOL_METRICS.items()}
|
||||
row = session.get(AppSetting, _TOOL_METRICS_SETTING) or AppSetting(
|
||||
key=_TOOL_METRICS_SETTING
|
||||
)
|
||||
row.value_json = {"tools": tools, "persistedAt": datetime.now(UTC).isoformat()}
|
||||
row.is_secret = False
|
||||
row.updated_at = utc_now()
|
||||
session.add(row)
|
||||
session.commit()
|
||||
return len(tools)
|
||||
|
||||
|
||||
def run_read_only_canary() -> dict[str, Any]:
|
||||
started = perf_counter()
|
||||
try:
|
||||
result = list_apps(limit=1)
|
||||
status = "ok" if isinstance(result, dict) and "items" in result else "failed"
|
||||
except Exception: # noqa: BLE001 - a canary must degrade safely on any read failure
|
||||
status = "failed"
|
||||
_CANARY_STATUS.update(
|
||||
{
|
||||
"status": status,
|
||||
"lastCheckedAt": datetime.now(UTC).isoformat(),
|
||||
"durationMs": round((perf_counter() - started) * 1000, 2),
|
||||
}
|
||||
)
|
||||
return dict(_CANARY_STATUS)
|
||||
|
||||
|
||||
appops_mcp = MCPServer(
|
||||
"Unraid AppOps Gateway",
|
||||
version=APP_VERSION,
|
||||
instructions=(
|
||||
"Private, governed AppOps context. Use read-only tools for inspection. "
|
||||
"Authenticated MCP mutations are evaluated against central AppOps policy and execute immediately "
|
||||
"through an allowlisted typed executor when possible. MCP never asks the operator for a human approval: "
|
||||
"blocked actions stay blocked and actions without a typed executor fail closed. Never invent actions, "
|
||||
"targets, policy state or execution results. Legacy approval status/execute tools exist only for approval "
|
||||
"records created before this no-human-approval MCP contract."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@appops_mcp.tool(name="gitea.actions.runners.status", annotations=_READ_ONLY_TOOL)
|
||||
@_tracked("gitea.actions.runners.status")
|
||||
def gitea_actions_runners_status() -> dict[str, Any]:
|
||||
"""Inspect Gitea Actions runners and queue age; this never restarts a runner."""
|
||||
|
||||
return _json(GiteaActionsConnector().snapshot())
|
||||
|
||||
|
||||
def _json(value: Any) -> Any:
|
||||
if hasattr(value, "model_dump"):
|
||||
return _json(value.model_dump(mode="json"))
|
||||
if isinstance(value, list):
|
||||
return [_json(item) for item in value]
|
||||
if isinstance(value, dict):
|
||||
return redact_value({str(key): _json(item) for key, item in value.items()})
|
||||
return redact_value(value)
|
||||
|
||||
|
||||
@appops_mcp.tool(name="gitea.projects.catalog", annotations=_READ_ONLY_TOOL)
|
||||
@_tracked("gitea.projects.catalog")
|
||||
def gitea_projects_catalog() -> dict[str, Any]:
|
||||
"""List the persisted owned Gitea project catalog and deployment registration state."""
|
||||
|
||||
with Session(engine) as session:
|
||||
return _json(GiteaProjectAutoRegistration.catalog(session))
|
||||
|
||||
|
||||
@appops_mcp.tool(name="gitea.projects.reconcile", annotations=_OPERATION_RUN_TOOL)
|
||||
@_tracked("gitea.projects.reconcile")
|
||||
def gitea_projects_reconcile() -> dict[str, Any]:
|
||||
"""Reconcile all owned Gitea projects through the bounded registration plane."""
|
||||
|
||||
if not _MCP_EXECUTION_LOCK.acquire(blocking=False):
|
||||
raise RuntimeError("server_operation_capacity_busy")
|
||||
try:
|
||||
with Session(engine) as session:
|
||||
return _json(GiteaProjectAutoRegistration().sync(session, force=True))
|
||||
finally:
|
||||
_MCP_EXECUTION_LOCK.release()
|
||||
|
||||
|
||||
@appops_mcp.tool(annotations=_READ_ONLY_TOOL)
|
||||
@_tracked("get_system_summary")
|
||||
def get_system_summary() -> dict[str, Any]:
|
||||
"""Get the current AppOps, Unraid and Portainer summary without changing state."""
|
||||
with Session(engine) as session:
|
||||
return _json(build_system_summary(session))
|
||||
|
||||
|
||||
@appops_mcp.tool(annotations=_READ_ONLY_TOOL)
|
||||
@_tracked("list_apps")
|
||||
def list_apps(limit: int = 100) -> dict[str, Any]:
|
||||
"""List managed applications and their current policy-safe metadata."""
|
||||
with Session(engine) as session:
|
||||
items = list_active_apps(session)[: max(1, min(limit, 250))]
|
||||
return {"items": [_json(item) for item in items], "count": len(items)}
|
||||
|
||||
|
||||
@appops_mcp.tool(annotations=_READ_ONLY_TOOL)
|
||||
@_tracked("list_containers")
|
||||
def list_containers(limit: int = 100) -> dict[str, Any]:
|
||||
"""List the latest inventory container view; no Docker socket or shell access is used."""
|
||||
with Session(engine) as session:
|
||||
items = list_inventory_containers(session, include_ignored=False)[
|
||||
: max(1, min(limit, 250))
|
||||
]
|
||||
return {"items": _json(items), "count": len(items)}
|
||||
|
||||
|
||||
@appops_mcp.tool(annotations=_READ_ONLY_TOOL)
|
||||
@_tracked("get_safety_findings")
|
||||
def get_safety_findings() -> dict[str, Any]:
|
||||
"""Get current safety summary and redacted findings."""
|
||||
with Session(engine) as session:
|
||||
return {
|
||||
"summary": _json(safety_center_service.get_summary(session)),
|
||||
"findings": _json(safety_center_service.get_findings(session)),
|
||||
}
|
||||
|
||||
|
||||
def _mcp_action_registry(session: Session) -> dict[str, Any]:
|
||||
"""Add the MCP execution posture without changing manual/voice registry semantics."""
|
||||
|
||||
registry = build_action_registry(session)
|
||||
direct = 0
|
||||
blocked_mutations = 0
|
||||
read_only = 0
|
||||
items: list[dict[str, Any]] = []
|
||||
for raw in registry.get("items", []):
|
||||
item = dict(raw)
|
||||
action = str(item.get("id") or "")
|
||||
target = str(item.get("target") or "")
|
||||
capability = str(item.get("capability") or "operator")
|
||||
mcp_policy = evaluate_policy(
|
||||
session, action=action, target=target, source="automation"
|
||||
)
|
||||
original_decision = str(mcp_policy.get("decision") or "blocked")
|
||||
|
||||
if capability == "read_only":
|
||||
mcp_mode = "read_only"
|
||||
mcp_supported = False
|
||||
mcp_decision = original_decision
|
||||
read_only += 1
|
||||
elif original_decision == "blocked":
|
||||
mcp_mode = "blocked"
|
||||
mcp_supported = False
|
||||
mcp_decision = "blocked"
|
||||
blocked_mutations += 1
|
||||
elif mcp_execution_supported(action):
|
||||
mcp_mode = "direct_typed"
|
||||
mcp_supported = True
|
||||
mcp_decision = "allowed"
|
||||
direct += 1
|
||||
else:
|
||||
mcp_mode = "blocked_no_typed_executor"
|
||||
mcp_supported = False
|
||||
mcp_decision = "blocked"
|
||||
blocked_mutations += 1
|
||||
|
||||
items.append(
|
||||
{
|
||||
**item,
|
||||
"manualPolicyDecision": item.get("policyDecision"),
|
||||
"manualExecutionMode": item.get("executionMode"),
|
||||
"mcpOriginalPolicyDecision": original_decision,
|
||||
"mcpPolicyDecision": mcp_decision,
|
||||
"mcpExecutionMode": mcp_mode,
|
||||
"mcpExecutionSupported": mcp_supported,
|
||||
"mcpApprovalRequired": False,
|
||||
}
|
||||
)
|
||||
|
||||
summary = {
|
||||
**dict(registry.get("summary", {})),
|
||||
"mcpDirectExecutable": direct,
|
||||
"mcpBlockedMutations": blocked_mutations,
|
||||
"mcpReadOnly": read_only,
|
||||
"mcpApprovalRequired": 0,
|
||||
}
|
||||
return {"summary": summary, "items": items}
|
||||
|
||||
|
||||
@appops_mcp.tool(annotations=_READ_ONLY_TOOL)
|
||||
@_tracked("get_action_registry")
|
||||
def get_action_registry() -> dict[str, Any]:
|
||||
"""List typed AppOps actions plus the no-human-approval MCP execution posture."""
|
||||
with Session(engine) as session:
|
||||
return _json(_mcp_action_registry(session))
|
||||
|
||||
|
||||
def _mcp_preview_mode(action: str, decision: str) -> tuple[str, bool]:
|
||||
capability = action_capability(action)
|
||||
if capability == "read_only":
|
||||
return "read_only", False
|
||||
if decision == "blocked":
|
||||
return "blocked", False
|
||||
if mcp_execution_supported(action):
|
||||
return "direct_typed", True
|
||||
return "blocked_no_typed_executor", False
|
||||
|
||||
|
||||
@appops_mcp.tool(annotations=_READ_ONLY_TOOL)
|
||||
@_tracked("preview_action")
|
||||
def preview_action(action: str, target: str) -> dict[str, Any]:
|
||||
"""Preview policy for an action. This never executes anything or creates an approval."""
|
||||
safe_action = action[:160]
|
||||
with Session(engine) as session:
|
||||
decision = evaluate_policy(
|
||||
session, action=safe_action, target=target[:240], source="automation"
|
||||
)
|
||||
mcp_mode, mcp_supported = _mcp_preview_mode(
|
||||
safe_action, str(decision["decision"])
|
||||
)
|
||||
return {
|
||||
"action": safe_action,
|
||||
"target": target[:240],
|
||||
"knownAction": safe_action in ACTIONS,
|
||||
"risk": decision.get("risk", ACTION_RISK.get(safe_action, "critical")),
|
||||
"policyDecision": decision["decision"],
|
||||
"reason": redact_message(str(decision["reason"])),
|
||||
"executed": False,
|
||||
"mcpApprovalRequired": False,
|
||||
"mcpExecutionSupported": mcp_supported,
|
||||
"mcpExecutionMode": mcp_mode,
|
||||
}
|
||||
|
||||
|
||||
@appops_mcp.tool(annotations=_READ_ONLY_TOOL)
|
||||
@_tracked("search_audit")
|
||||
def search_audit(query: str = "", limit: int = 30) -> dict[str, Any]:
|
||||
"""Search recent redacted audit metadata."""
|
||||
needle = query.casefold().strip()[:200]
|
||||
with Session(engine) as session:
|
||||
rows = session.exec(
|
||||
select(AuditEvent).order_by(AuditEvent.timestamp.desc()).limit(500)
|
||||
).all()
|
||||
matches = []
|
||||
for row in rows:
|
||||
value = f"{row.action_id} {row.target} {row.source} {row.result} {row.reason or ''}".casefold()
|
||||
if needle and needle not in value:
|
||||
continue
|
||||
matches.append(
|
||||
{
|
||||
"id": row.id,
|
||||
"timestamp": row.timestamp.isoformat(),
|
||||
"actionId": row.action_id,
|
||||
"target": row.target,
|
||||
"source": row.source,
|
||||
"risk": row.risk,
|
||||
"policyDecision": row.policy_decision,
|
||||
"result": row.result,
|
||||
"reason": redact_message(str(row.reason or "")),
|
||||
}
|
||||
)
|
||||
if len(matches) >= max(1, min(limit, 100)):
|
||||
break
|
||||
return _json({"items": matches, "count": len(matches)})
|
||||
|
||||
|
||||
@appops_mcp.tool(annotations=_READ_ONLY_TOOL)
|
||||
@_tracked("query_knowledge")
|
||||
def query_knowledge(question: str, limit: int = 6) -> dict[str, Any]:
|
||||
"""Retrieve grounded AppOps documentation and live context with citations."""
|
||||
with Session(engine) as session:
|
||||
return _json(
|
||||
knowledge_service.query(session, question, limit=max(1, min(limit, 10)))
|
||||
)
|
||||
|
||||
|
||||
@appops_mcp.tool(name="server.operation.request", annotations=_OPERATION_RUN_TOOL)
|
||||
@_tracked("server.operation.request")
|
||||
def request_operation(
|
||||
action: str,
|
||||
target: str,
|
||||
reason: str,
|
||||
idempotencyKey: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Run an exact governed server operation immediately when policy permits; MCP never creates a human approval."""
|
||||
if not _MCP_EXECUTION_LOCK.acquire(blocking=False):
|
||||
raise RuntimeError("server_operation_capacity_busy")
|
||||
try:
|
||||
with Session(engine) as session:
|
||||
return _json(
|
||||
mcp_operations.request_operation(
|
||||
session,
|
||||
action=action,
|
||||
target=target,
|
||||
reason=reason,
|
||||
idempotency_key=idempotencyKey,
|
||||
)
|
||||
)
|
||||
finally:
|
||||
_MCP_EXECUTION_LOCK.release()
|
||||
|
||||
|
||||
@appops_mcp.tool(name="privatemuse.artifact.install.preflight", annotations=_READ_ONLY_TOOL)
|
||||
@_tracked("privatemuse.artifact.install.preflight")
|
||||
def privatemuse_artifact_install_preflight(
|
||||
artifact_type: str, source_url: str, sha256: str, filename: str,
|
||||
source_id: str, license: str, reason: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Check one pinned ComfyUI artifact against the fixed persistent model mount."""
|
||||
payload = {"artifact_type": artifact_type, "source_url": source_url, "sha256": sha256,
|
||||
"filename": filename, "source_id": source_id, "license": license}
|
||||
if not reason.strip() or len(reason) > 1000:
|
||||
raise ArtifactInstallError("a bounded reason is required")
|
||||
model_root, _ = _artifact_roots()
|
||||
return _json({**artifact_preflight(payload, model_root=model_root), "executed": False})
|
||||
|
||||
|
||||
@appops_mcp.tool(name="privatemuse.artifact.install", annotations=_OPERATION_RUN_TOOL)
|
||||
@_tracked("privatemuse.artifact.install")
|
||||
def privatemuse_artifact_install(
|
||||
artifact_type: str, source_url: str, sha256: str, filename: str,
|
||||
source_id: str, license: str, reason: str, expected_preflight_fingerprint: str,
|
||||
idempotency_key: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Install one immutable SHA-pinned artifact into its server-selected ComfyUI directory."""
|
||||
if not _ARTIFACT_INSTALL_LOCK.acquire(blocking=False):
|
||||
raise ArtifactInstallError("artifact installation already active")
|
||||
try:
|
||||
if not reason.strip() or len(reason) > 1000 or not idempotency_key.strip() or len(idempotency_key) > 200:
|
||||
raise ArtifactInstallError("bounded reason and idempotency key are required")
|
||||
payload = {"artifact_type": artifact_type, "source_url": source_url, "sha256": sha256,
|
||||
"filename": filename, "source_id": source_id, "license": license}
|
||||
model_root, receipt_root = _artifact_roots()
|
||||
key_hash = hashlib.sha256(idempotency_key.encode()).hexdigest()
|
||||
request_fingerprint = hashlib.sha256(json.dumps(payload, sort_keys=True).encode()).hexdigest()
|
||||
request_dir = receipt_root / "mcp-requests"
|
||||
request_path = request_dir / f"{key_hash}.json"
|
||||
if request_path.exists():
|
||||
previous = json.loads(request_path.read_text(encoding="utf-8"))
|
||||
if previous.get("request_fingerprint") != request_fingerprint:
|
||||
raise ArtifactInstallError("idempotency key already used for different artifact")
|
||||
return _json(previous["result"])
|
||||
checked = artifact_preflight(payload, model_root=model_root)
|
||||
if checked["fingerprint"] != expected_preflight_fingerprint:
|
||||
raise ArtifactInstallError("preflight changed; run preflight again")
|
||||
result = install_artifact_file(payload, model_root=model_root, receipt_root=receipt_root)
|
||||
with Session(engine) as session:
|
||||
audit = log_event(session, source="action", identity="mcp", action_id="privatemuse.comfyui.install_artifact",
|
||||
target="privatemuse/comfyui", risk="high", policy_decision="typed_contract_allowed",
|
||||
reason=reason, result="success", after_state={"status": result["status"], "sha256": result["sha256"], "bytes": result["bytes"]},
|
||||
redacted_metadata={"filename": filename, "source_id": source_id})
|
||||
result["auditId"] = audit.id
|
||||
request_dir.mkdir(parents=True, exist_ok=True)
|
||||
temp_path = request_dir / f".{key_hash}.{os.getpid()}.tmp"
|
||||
with temp_path.open("w", encoding="utf-8") as handle:
|
||||
json.dump({"request_fingerprint": request_fingerprint, "result": result}, handle, sort_keys=True)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(temp_path, request_path)
|
||||
return _json(result)
|
||||
finally:
|
||||
_ARTIFACT_INSTALL_LOCK.release()
|
||||
|
||||
|
||||
@appops_mcp.tool(name="server.operation.status", annotations=_READ_ONLY_TOOL)
|
||||
@_tracked("server.operation.status")
|
||||
def operation_status(approvalId: str) -> dict[str, Any]:
|
||||
"""Legacy: read an approval record created before MCP no-human-approval mode."""
|
||||
with Session(engine) as session:
|
||||
return _json(mcp_operations.operation_status(session, approvalId))
|
||||
|
||||
|
||||
@appops_mcp.tool(name="appops.deployment.status", annotations=_READ_ONLY_TOOL)
|
||||
@_tracked("appops.deployment.status")
|
||||
def appops_deployment_status() -> dict[str, Any]:
|
||||
"""Report the exact OCI source revision baked into this AppOps image."""
|
||||
with Session(engine) as session:
|
||||
info = runtime_info(session)
|
||||
return _json({"repository": info["sourceRepository"], "revision": info["sourceRevision"],
|
||||
"sourceTree": info["sourceTree"], "health": "ok" if info["dbStatus"] == "ok" and info["liveReady"] else "degraded"})
|
||||
|
||||
|
||||
@appops_mcp.tool(name="appops.self_upgrade.preflight", annotations=_READ_ONLY_TOOL)
|
||||
@_tracked("appops.self_upgrade.preflight")
|
||||
def appops_self_upgrade_preflight(
|
||||
repository: str, revision: str, reason: str,
|
||||
expected_current_revision: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Check a fixed protected-main AppOps revision in the private controller."""
|
||||
if repository != "jens/unraid-appops-gateway":
|
||||
raise ValueError("repository must be jens/unraid-appops-gateway")
|
||||
return _json(DeploymentBrokerClient().appops_self_upgrade_preflight(
|
||||
revision, reason, expected_current_revision,
|
||||
))
|
||||
|
||||
|
||||
@appops_mcp.tool(name="appops.self_upgrade.apply", annotations=_OPERATION_RUN_TOOL)
|
||||
@_tracked("appops.self_upgrade.apply")
|
||||
def appops_self_upgrade_apply(
|
||||
repository: str, revision: str, reason: str,
|
||||
expected_preflight_fingerprint: str, idempotency_key: str,
|
||||
expected_current_revision: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Queue one exact revision through the controller's validated single-instance executor."""
|
||||
if repository != "jens/unraid-appops-gateway":
|
||||
raise ValueError("repository must be jens/unraid-appops-gateway")
|
||||
if not _SELF_UPGRADE_LOCK.acquire(blocking=False):
|
||||
raise RuntimeError("appops self-upgrade already active")
|
||||
try:
|
||||
with Session(engine) as session:
|
||||
audit = log_event(
|
||||
session, source="action", identity="mcp", action_id="appops.self_upgrade",
|
||||
target="Unraid-AppOps-Gateway", risk="high",
|
||||
policy_decision="typed_contract_requested",
|
||||
reason="Typed self-upgrade reason SHA-256 " + hashlib.sha256(reason.encode("utf-8")).hexdigest(),
|
||||
result="requested",
|
||||
after_state={"revision": revision},
|
||||
redacted_metadata={"repository": repository},
|
||||
)
|
||||
result = DeploymentBrokerClient().appops_self_upgrade_apply(
|
||||
revision, reason, expected_current_revision,
|
||||
expected_preflight_fingerprint, idempotency_key,
|
||||
)
|
||||
result["auditId"] = audit.id
|
||||
return _json(result)
|
||||
finally:
|
||||
_SELF_UPGRADE_LOCK.release()
|
||||
|
||||
|
||||
@appops_mcp.tool(name="appops.self_upgrade.status", annotations=_READ_ONLY_TOOL)
|
||||
@_tracked("appops.self_upgrade.status")
|
||||
def appops_self_upgrade_status(operation_id: str) -> dict[str, Any]:
|
||||
"""Read the controller's durable progress and exact deployment receipt."""
|
||||
return _json(DeploymentBrokerClient().appops_self_upgrade_status(operation_id))
|
||||
|
||||
|
||||
@appops_mcp.tool(name="server.operation.execute", annotations=_LEGACY_APPROVAL_EXECUTION_TOOL)
|
||||
@_tracked("server.operation.execute")
|
||||
def execute_operation(
|
||||
approvalId: str, expectedActionFingerprint: str
|
||||
) -> dict[str, Any]:
|
||||
"""Legacy: execute an already-approved pre-existing approval record; new MCP calls never create one."""
|
||||
if not _MCP_EXECUTION_LOCK.acquire(blocking=False):
|
||||
raise RuntimeError("server_operation_capacity_busy")
|
||||
try:
|
||||
with Session(engine) as session:
|
||||
return _json(
|
||||
mcp_operations.execute_operation(
|
||||
session,
|
||||
approval_id=approvalId,
|
||||
expected_action_fingerprint=expectedActionFingerprint,
|
||||
)
|
||||
)
|
||||
finally:
|
||||
_MCP_EXECUTION_LOCK.release()
|
||||
|
||||
|
||||
@appops_mcp.tool(name="server.job.status", annotations=_READ_ONLY_TOOL)
|
||||
@_tracked("server.job.status")
|
||||
def job_status(jobId: str) -> dict[str, Any]:
|
||||
"""Read bounded progress and the redacted result of one server-side AppOps job."""
|
||||
with Session(engine) as session:
|
||||
return _json(mcp_operations.job_status(session, jobId))
|
||||
|
||||
|
||||
@appops_mcp.tool(name="server.job.cancel", annotations=_CANCEL_TOOL)
|
||||
@_tracked("server.job.cancel")
|
||||
def cancel_job(jobId: str) -> dict[str, Any]:
|
||||
"""Cancel a queued or running AppOps job record; completed jobs remain immutable."""
|
||||
with Session(engine) as session:
|
||||
return _json(mcp_operations.cancel_operation_job(session, jobId))
|
||||
|
||||
|
||||
@appops_mcp.tool(name="deployment.preflight", annotations=_READ_ONLY_TOOL)
|
||||
@_tracked("deployment.preflight")
|
||||
def deployment_preflight(plan: dict[str, Any], idempotencyKey: str) -> dict[str, Any]:
|
||||
"""Validate one strict DeploymentPlanV1 without touching application runtime state."""
|
||||
try:
|
||||
typed_plan = DeploymentPlanV1.model_validate(plan)
|
||||
with Session(engine) as session:
|
||||
return _json(
|
||||
DeploymentControlPlane().execute(
|
||||
session,
|
||||
plan=typed_plan,
|
||||
idempotency_key=idempotencyKey,
|
||||
operation="preflight",
|
||||
)
|
||||
)
|
||||
except (ValueError, DeploymentControlError) as exc:
|
||||
return {"status": "invalid", "executed": False, "reason": redact_message(str(exc))}
|
||||
|
||||
|
||||
@appops_mcp.tool(name="deployment.capabilities", annotations=_READ_ONLY_TOOL)
|
||||
@_tracked("deployment.capabilities")
|
||||
def deployment_capabilities() -> dict[str, Any]:
|
||||
"""Read machine-actionable enrollment and deployment readiness without mutations."""
|
||||
with Session(engine) as session:
|
||||
return _json(EnrollmentPlane().capabilities(session))
|
||||
|
||||
|
||||
@appops_mcp.tool(name="enrollment.preflight", annotations=_READ_ONLY_TOOL)
|
||||
@_tracked("enrollment.preflight")
|
||||
def enrollment_preflight(plan: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Read-only takeover-safe preflight for one strict EnrollmentPlanV1."""
|
||||
try:
|
||||
typed_plan = EnrollmentPlanV1.model_validate(plan)
|
||||
with Session(engine) as session:
|
||||
return _json(EnrollmentPlane().preflight(session, typed_plan))
|
||||
except (ValueError, EnrollmentError) as exc:
|
||||
return {"status": "invalid", "executed": False, "reason": redact_message(str(exc))}
|
||||
|
||||
|
||||
@appops_mcp.tool(name="target.enroll", annotations=_OPERATION_RUN_TOOL)
|
||||
@_tracked("target.enroll")
|
||||
def target_enroll(plan: dict[str, Any], idempotencyKey: str) -> dict[str, Any]:
|
||||
"""Enroll exactly one clean-profile application identity; no arbitrary target is accepted."""
|
||||
if not _MCP_EXECUTION_LOCK.acquire(blocking=False):
|
||||
raise RuntimeError("server_operation_capacity_busy")
|
||||
try:
|
||||
typed_plan = EnrollmentPlanV1.model_validate(plan)
|
||||
with Session(engine) as session:
|
||||
return _json(EnrollmentPlane().enroll(session, typed_plan, idempotency_key=idempotencyKey))
|
||||
except (ValueError, EnrollmentError) as exc:
|
||||
return {"status": "invalid", "executed": False, "reason": redact_message(str(exc))}
|
||||
finally:
|
||||
_MCP_EXECUTION_LOCK.release()
|
||||
|
||||
|
||||
@appops_mcp.tool(name="enrollment.status", annotations=_READ_ONLY_TOOL)
|
||||
@_tracked("enrollment.status")
|
||||
def enrollment_status(enrollmentId: str) -> dict[str, Any]:
|
||||
"""Read a redacted durable enrollment state and its restart-safe job evidence."""
|
||||
with Session(engine) as session:
|
||||
try:
|
||||
return _json(EnrollmentPlane().get_enrollment(session, enrollmentId))
|
||||
except EnrollmentError as exc:
|
||||
return {"status": "not_found", "reason": redact_message(str(exc))}
|
||||
|
||||
|
||||
@appops_mcp.tool(name="deployment.apply", annotations=_OPERATION_RUN_TOOL)
|
||||
@_tracked("deployment.apply")
|
||||
def deployment_apply(plan: dict[str, Any], idempotencyKey: str) -> dict[str, Any]:
|
||||
"""Submit one preflighted typed deployment to the private controller."""
|
||||
if not _MCP_EXECUTION_LOCK.acquire(blocking=False):
|
||||
raise RuntimeError("server_operation_capacity_busy")
|
||||
try:
|
||||
typed_plan = DeploymentPlanV1.model_validate(plan)
|
||||
with Session(engine) as session:
|
||||
return _json(
|
||||
DeploymentControlPlane().execute(
|
||||
session,
|
||||
plan=typed_plan,
|
||||
idempotency_key=idempotencyKey,
|
||||
operation="apply",
|
||||
)
|
||||
)
|
||||
except (ValueError, DeploymentControlError) as exc:
|
||||
return {"status": "invalid", "executed": False, "reason": redact_message(str(exc))}
|
||||
finally:
|
||||
_MCP_EXECUTION_LOCK.release()
|
||||
|
||||
|
||||
@appops_mcp.tool(name="deployment.status", annotations=_READ_ONLY_TOOL)
|
||||
@_tracked("deployment.status")
|
||||
def deployment_status(jobId: str) -> dict[str, Any]:
|
||||
"""Read durable deployment state and refresh a bounded broker request."""
|
||||
with Session(engine) as session:
|
||||
try:
|
||||
return _json(DeploymentControlPlane().refresh(session, jobId))
|
||||
except DeploymentControlError as exc:
|
||||
return {"status": "not_found", "reason": redact_message(str(exc))}
|
||||
|
||||
|
||||
@appops_mcp.tool(name="deployment.verify", annotations=_READ_ONLY_TOOL)
|
||||
@_tracked("deployment.verify")
|
||||
def deployment_verify(jobId: str) -> dict[str, Any]:
|
||||
"""Refresh the controller receipt of an exact previously submitted deployment."""
|
||||
return deployment_status(jobId)
|
||||
|
||||
|
||||
@appops_mcp.tool(name="deployment.rollback", annotations=_OPERATION_RUN_TOOL)
|
||||
@_tracked("deployment.rollback")
|
||||
def deployment_rollback(plan: dict[str, Any], idempotencyKey: str) -> dict[str, Any]:
|
||||
"""Ask the controller to run its receipt-bound rollback for one typed plan."""
|
||||
if not _MCP_EXECUTION_LOCK.acquire(blocking=False):
|
||||
raise RuntimeError("server_operation_capacity_busy")
|
||||
try:
|
||||
typed_plan = DeploymentPlanV1.model_validate(plan)
|
||||
if not typed_plan.rollback_enabled:
|
||||
return {"status": "blocked", "executed": False, "reason": "rollback_disabled_by_typed_plan"}
|
||||
with Session(engine) as session:
|
||||
return _json(
|
||||
DeploymentControlPlane().execute(
|
||||
session,
|
||||
plan=typed_plan,
|
||||
idempotency_key=idempotencyKey,
|
||||
operation="rollback",
|
||||
)
|
||||
)
|
||||
except (ValueError, DeploymentControlError) as exc:
|
||||
return {"status": "invalid", "executed": False, "reason": redact_message(str(exc))}
|
||||
finally:
|
||||
_MCP_EXECUTION_LOCK.release()
|
||||
|
||||
|
||||
@appops_mcp.resource("appops://capabilities", mime_type="application/json")
|
||||
def capabilities() -> str:
|
||||
"""Describe the immutable security boundary of this MCP server."""
|
||||
return json.dumps(
|
||||
{
|
||||
"scope": "governed_operations",
|
||||
"transport": "streamable_http",
|
||||
"mutationTools": True,
|
||||
"directShell": False,
|
||||
"dockerSocket": False,
|
||||
"secrets": "excluded",
|
||||
"execution": "typed AppOps executors immediately after central policy evaluation",
|
||||
"approvalRequiredForMcpMutations": False,
|
||||
"humanApprovalWorkflowExposedByMcp": False,
|
||||
"blockedActionsRemainBlocked": True,
|
||||
"unsupportedActionsFailClosed": True,
|
||||
"legacyApprovalCompatibility": True,
|
||||
"maxConcurrentExecutions": 1,
|
||||
"deploymentControlPlane": {
|
||||
"version": "v1",
|
||||
"typedPlanOnly": True,
|
||||
"rawCompose": False,
|
||||
"directShell": False,
|
||||
"brokerEvidenceRequired": True,
|
||||
},
|
||||
"enrollmentProvisioningPlane": {
|
||||
"version": "v1",
|
||||
"typedPlanOnly": True,
|
||||
"profileControlled": True,
|
||||
"rawCompose": False,
|
||||
"bootstrapCredentials": "write_only",
|
||||
"wildcardPolicies": False,
|
||||
},
|
||||
"giteaProjectRegistration": {
|
||||
"version": "v1",
|
||||
"automatic": True,
|
||||
"catalogTool": "gitea.projects.catalog",
|
||||
"reconcileTool": "gitea.projects.reconcile",
|
||||
"wildcardDeploymentPolicy": False,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
mcp_http_app = appops_mcp.streamable_http_app(
|
||||
streamable_http_path="/",
|
||||
stateless_http=True,
|
||||
json_response=True,
|
||||
max_request_body_size=1_048_576,
|
||||
transport_security=TransportSecuritySettings(
|
||||
enable_dns_rebinding_protection=True,
|
||||
allowed_hosts=list(settings.mcp_allowed_hosts or []),
|
||||
allowed_origins=[settings.frontend_origin],
|
||||
),
|
||||
)
|
||||
Reference in new issue
Block a user