867 lines
35 KiB
Python
867 lines
35 KiB
Python
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 .schemas.forge_bootstrap import ForgeBootstrapPreflightV1
|
|
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
|
|
from .services.runner_statistics import RunnerStatisticsService
|
|
|
|
_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())
|
|
|
|
|
|
@appops_mcp.tool(name="gitea.actions.runners.statistics", annotations=_READ_ONLY_TOOL)
|
|
@_tracked("gitea.actions.runners.statistics")
|
|
def gitea_actions_runner_statistics() -> dict[str, Any]:
|
|
"""Read bounded CI profile budgets, measured usage and job statistics."""
|
|
return _json(RunnerStatisticsService().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 a strict V1 plan or fixed Forge bootstrap profile read-only."""
|
|
try:
|
|
if plan.get("api_version") == "itworx.forge-bootstrap-preflight/v1":
|
|
from .services.forge_bootstrap_preflight import (
|
|
ForgeBootstrapPreflightService,
|
|
)
|
|
|
|
profile = ForgeBootstrapPreflightV1.model_validate(plan)
|
|
with Session(engine) as session:
|
|
return _json(
|
|
ForgeBootstrapPreflightService().preflight(
|
|
session, profile, idempotency_key=idempotencyKey
|
|
)
|
|
)
|
|
if "api_version" in plan:
|
|
return {
|
|
"status": "invalid",
|
|
"executed": False,
|
|
"reason": "Unsupported deployment.preflight profile discriminator.",
|
|
}
|
|
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],
|
|
),
|
|
)
|