Files
ITWorx_MCP_Hub-Public/services/runtime/control_api.py
T

1370 lines
54 KiB
Python

from __future__ import annotations
import base64
import json
import os
import stat
from collections.abc import AsyncIterator, Iterable
from contextlib import asynccontextmanager
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any, Protocol, cast
from urllib.parse import urlparse
from uuid import NAMESPACE_URL, UUID, uuid5
import httpx
import yaml # type: ignore[import-untyped]
from fastapi import Depends, FastAPI, HTTPException, Query, Request, Response, status
from fastapi.responses import JSONResponse
from jsonschema import Draft202012Validator # type: ignore[import-untyped]
from pydantic import BaseModel, ConfigDict, Field, field_validator
from sqlalchemy import func, select, text
from sqlalchemy.engine import Engine
from packages.hub_clients import ClientRecord, ClientStatus
from packages.hub_policy import Decision, Governance, PolicyDenied, PolicySimulator
from services.control_api.auth import BFFService, OIDCProviderClient, OIDCSettings, PrincipalMapper
from services.control_api.auth.errors import AuthError
from services.control_api.auth.models import AuthenticatedPrincipal
from services.control_api.auth.router import build_auth_router
from services.control_api.authz import InsufficientRole, check_role
from services.control_api.catalog import CatalogLoader
from services.control_api.clients import build_client_router
from services.control_api.contextforge import ContextForgeClient, ContextForgeSettings
from services.control_api.persistence import create_engine_from_url, session_factory
from services.control_api.persistence.models import AuditEventRow, ProjectRow
from .audit import SqlAlchemyAuditLog
from .client_service import PersistentClientService
from .common import hub_database_url, identity_assertion, load_connector_identity_key, run_asgi
from .dataplane import _policy_engine
from .state_sync import CONNECTOR_GATEWAYS, reconcile_runtime_state
class ClientLister(Protocol):
"""The minimal shape simulate_policy() needs -- lets tests pass a plain
in-memory stand-in instead of a real database-backed ClientService."""
def list(self) -> Iterable[ClientRecord]: ...
RESOURCE_FOLDERS = {
"projects": "projects",
"servers": "projects",
"tools": "tools",
"connectors": "connectors",
"policies": "policies",
}
CONNECTOR_GATEWAY_BY_TYPE = {
slug.removeprefix("itworx-"): slug for slug, _url in CONNECTOR_GATEWAYS
}
CONNECTOR_READINESS_URL_BY_SLUG = {
slug: f"{url.removesuffix('/mcp')}/health/ready" for slug, url in CONNECTOR_GATEWAYS
}
CONNECTOR_URL_BY_TYPE = {slug.removeprefix("itworx-"): url for slug, url in CONNECTOR_GATEWAYS}
class ToolReadinessRequest(BaseModel):
"""Bounded management-plane request; never a general MCP invocation."""
model_config = ConfigDict(extra="forbid")
project: str = Field(pattern=r"^[a-z0-9][a-z0-9-]{0,62}$")
environment: str = Field(pattern=r"^(dev|test|staging|prod)$")
tool: str = Field(min_length=1, max_length=256)
arguments: dict[str, Any] = Field(default_factory=dict)
@field_validator("arguments")
@classmethod
def bound_arguments(cls, value: dict[str, Any]) -> dict[str, Any]:
if len(value) > 64:
raise ValueError("arguments contain too many top-level properties")
serialized = json.dumps(value, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
if len(serialized) > 32768:
raise ValueError("arguments exceed the readiness-check size limit")
return value
class ToolInvokeRequest(ToolReadinessRequest):
"""The management console can invoke only cataloged read-only tools."""
def read_backup_status(path: Path, *, now: datetime | None = None) -> dict[str, Any]:
"""Read a bounded operator-produced status projection, never backup contents."""
unknown = {
"state": "unknown",
"lastSuccessfulAt": None,
"detail": "No verified backup status has been published yet.",
"restoreVerified": False,
"artifactCount": 0,
}
try:
info = path.lstat()
if not stat.S_ISREG(info.st_mode) or path.is_symlink() or info.st_size > 65536:
return unknown
value = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(value, dict) or value.get("schemaVersion") != "1":
return unknown
completed = datetime.fromisoformat(str(value.get("completedAt", "")))
if completed.tzinfo is None or completed.utcoffset() is None:
return unknown
restore_verified = value.get("restoreVerified") is True
artifact_count = int(value.get("artifactCount", 0))
if not restore_verified or not 1 <= artifact_count <= 1000:
return {
**unknown,
"state": "degraded",
"detail": "The latest backup status is incomplete or lacks restore verification.",
"lastSuccessfulAt": completed.astimezone(UTC).isoformat(),
"artifactCount": max(0, artifact_count),
}
age = (now or datetime.now(UTC)) - completed.astimezone(UTC)
stale_after = timedelta(hours=int(os.environ.get("HUB_BACKUP_STALE_AFTER_HOURS", "26")))
stale = age > stale_after or age < timedelta(minutes=-5)
return {
"state": "degraded" if stale else "healthy",
"lastSuccessfulAt": completed.astimezone(UTC).isoformat(),
"detail": (
"Latest backup is restore-verified but older than the freshness target."
if stale
else "Latest backup and isolated restore verification succeeded."
),
"restoreVerified": True,
"artifactCount": artifact_count,
}
except (OSError, ValueError, TypeError, json.JSONDecodeError):
return unknown
def _documents(root: Path, folder: str) -> list[dict[str, Any]]:
values: list[dict[str, Any]] = []
for path in sorted((root / folder).glob("*.yaml")):
document = yaml.safe_load(path.read_text(encoding="utf-8"))
if isinstance(document, dict):
values.append(document)
return values
def _catalog_registration_health(document: dict[str, Any]) -> tuple[str, str]:
"""Whether a catalog document is a well-formed, registered entry.
This aggregate list signal is registration validity, not an on-demand
readiness probe. Operators can run the bounded readiness workflow for
individual tool paths. Shared between resource rows and the dashboard
overview so those surfaces do not disagree.
"""
if document.get("kind"):
return (
"healthy",
"Catalog entry is registered and well-formed; "
"use tool readiness for a live path check.",
)
return "unknown", "Catalog entry is missing or malformed."
def _row(document: dict[str, Any], kind: str) -> dict[str, Any]:
metadata = cast(
dict[str, Any],
document.get("metadata") if isinstance(document.get("metadata"), dict) else {},
)
spec = cast(
dict[str, Any],
document.get("spec") if isinstance(document.get("spec"), dict) else {},
)
slug = str(metadata.get("slug", "unknown"))[:128]
name = str(metadata.get("name", slug))[:256]
summary = str(spec.get("description", document.get("kind", kind)))[:1000]
status, _detail = _catalog_registration_health(document)
return {
"id": slug,
"name": name,
"status": status,
"summary": summary,
"project": str(spec.get("project", "")) or None,
"environment": None,
"risk": str(spec.get("riskClass", "R0")) if kind == "tools" else None,
# The catalog slug (`id` above) and canonicalName are different
# strings (e.g. "deployment-project-status" vs
# "deployment.project.status") -- callers that need to invoke or
# simulate this tool (not just display it) need the latter.
"canonicalName": str(spec.get("canonicalName")) if kind == "tools" else None,
}
def _verify_contextforge_dependency(contextforge: ContextForgeClient) -> None:
health = contextforge.health()
if health.get("status") not in {"ready", "healthy", "ok"}:
raise RuntimeError("gateway not healthy")
contextforge.list_gateways(limit=1)
def measure_connector_drift(contextforge: ContextForgeClient) -> dict[str, Any]:
"""Compare ContextForge with the connector state production reconciliation owns.
Project virtual servers are a separate resource type and must not be compared
with connector gateways. Keeping this desired state sourced from
``CONNECTOR_GATEWAYS`` makes the dashboard and ``reconcile_runtime_state``
describe the same operational reality.
"""
desired = {slug: url.rstrip("/") for slug, url in CONNECTOR_GATEWAYS}
actual = {gateway.slug: gateway for gateway in contextforge.list_gateways(limit=100)}
affected = {
slug
for slug, expected_url in desired.items()
if (gateway := actual.get(slug)) is None
or not gateway.enabled
or gateway.url.rstrip("/") != expected_url
}
affected.update(set(actual) - set(desired))
ordered = sorted(affected)
return {
"count": len(ordered),
"projects": ordered,
"known": True,
"detail": (
"No connector gateway drift measured."
if not ordered
else "Measured connector gateway drift requires review before reconciliation."
),
}
_CURSOR_TTL_SECONDS = 3600
_AUDIT_FETCH_CAP = 1000 # v1 scale bound: bounded audit browsing, not unbounded history search
def encode_page_cursor(offset: int) -> str:
"""Opaque cursor: offset + issue time, so an expired cursor can be
rejected outright instead of silently wrapping around or resetting to
page 1 (which would look like success while returning the wrong page)."""
payload = f"{offset}:{int(datetime.now(UTC).timestamp())}"
return base64.urlsafe_b64encode(payload.encode("utf-8")).decode("ascii")
def decode_page_cursor(cursor: str) -> int:
"""Raises HTTPException(400) for a malformed or expired cursor -- fails
safely (visible 400, never a 500, never a silent wraparound to page 1)."""
try:
decoded = base64.urlsafe_b64decode(cursor.encode("ascii")).decode("utf-8")
offset_text, issued_text = decoded.split(":", 1)
offset = int(offset_text)
issued_at = int(issued_text)
except Exception as exc:
raise HTTPException(status_code=400, detail="invalid pagination cursor") from exc
if offset < 0:
raise HTTPException(status_code=400, detail="invalid pagination cursor")
if datetime.now(UTC).timestamp() - issued_at > _CURSOR_TTL_SECONDS:
raise HTTPException(
status_code=400,
detail="pagination cursor has expired; restart from the first page",
)
return offset
_ALLOW_DECISIONS = frozenset(
{
Decision.ALLOW,
Decision.ALLOW_WITH_PREVIEW,
Decision.ALLOW_WITH_APPROVAL,
Decision.ALLOW_IF_CONDITIONS,
}
)
def simulate_policy(
catalog_root: Path,
clients: ClientLister,
payload: dict[str, Any],
principal: AuthenticatedPrincipal,
) -> dict[str, Any]:
"""Real policy simulation: the exact evaluator production traffic uses
(`_policy_engine` from services/runtime/dataplane.py, unmodified),
against catalog-derived context -- not a hand-maintained stub. Never
mutates runtime state: PolicySimulator only ever calls
PolicyEngine.evaluate(), which reads policy rules and returns a
DecisionTrace; nothing here writes to the catalog, the database, or any
external system.
Module-level (not a nested closure inside build_app()) specifically so
it's unit-testable without constructing a full FastAPI app -- see
tests/test_wp230_policy_simulate.py.
"""
project_slug = str(payload.get("project", ""))
environment = str(payload.get("environment", ""))
tool_name = str(payload.get("tool", ""))
projects_by_slug = {
str(doc.get("metadata", {}).get("slug", "")): doc
for doc in _documents(catalog_root, "projects")
}
tools_by_name = {
str(doc.get("spec", {}).get("canonicalName", "")): doc
for doc in _documents(catalog_root, "tools")
}
connector_type_by_slug = {
str(doc.get("metadata", {}).get("slug", "")): str(
doc.get("spec", {}).get("connectorType", "")
)
for doc in _documents(catalog_root, "connectors")
}
project_doc = projects_by_slug.get(project_slug)
project_spec = cast(dict[str, Any], project_doc.get("spec", {})) if project_doc else {}
tool_doc = tools_by_name.get(tool_name)
tool_spec = cast(dict[str, Any], tool_doc.get("spec", {})) if tool_doc else {}
connector_type = str(tool_spec.get("connectorType", "unknown"))
risk_class = str(tool_spec.get("riskClass", "R1"))
required_scopes = list(tool_spec.get("requiredScopes", []))
environments_spec = cast(dict[str, Any], project_spec.get("environments") or {})
environment_spec = cast(dict[str, Any], environments_spec.get(environment) or {})
environment_classification = environment_spec.get("classification")
# Real capability-pack/binding lookup: is there a registered connector
# binding, for this project, of this tool's connector type, covering
# this environment? This is exactly what "capability pack or binding"
# in the WP-230B acceptance criteria means -- not a caller-supplied flag.
binding_present = any(
connector_type_by_slug.get(str(cast(dict[str, Any], binding).get("ref", "")))
== connector_type
and environment in (cast(dict[str, Any], binding).get("environments") or [])
for binding in project_spec.get("connectorBindings", [])
if isinstance(binding, dict)
)
client_id: UUID | None = None
raw_client_id = payload.get("clientId")
if raw_client_id:
try:
client_id = UUID(str(raw_client_id))
except ValueError:
client_id = None # invalid input -> simulated as an absent/unknown client, not a 400
known_clients = {record.id for record in clients.list()}
client_enabled = False
if client_id is not None:
matching = next((record for record in clients.list() if record.id == client_id), None)
client_enabled = bool(matching and matching.status == ClientStatus.ENABLED)
# Caller-supplied overrides are honored (a deliberate "what if" simulation
# capability -- e.g. "what would happen if this client were bound") but
# every value has a real, catalog/database-derived default rather than an
# unconditional True the way the old stub effectively assumed.
context = Governance.context(
workspace="itworx",
project=project_slug,
environment=environment,
principal=principal.subject,
principal_type=principal.kind.value,
tool=tool_name,
connector=connector_type,
arguments={"requestedScopes": payload.get("scopes", required_scopes)},
client_id=client_id,
binding_present=bool(payload.get("bindingPresent", binding_present)),
client_enabled=bool(payload.get("clientEnabled", client_enabled)),
capability=payload.get("capability"),
risk_class=risk_class,
environment_classification=environment_classification,
)
engine = _policy_engine(catalog_root)
simulator = PolicySimulator(
engine,
known_projects=set(projects_by_slug),
known_clients=known_clients,
known_tools=set(tools_by_name),
)
trace = simulator.simulate(context)
return {
"decision": trace.decision.value,
"allowed": trace.decision in _ALLOW_DECISIONS,
"matchedPolicies": list(trace.matched_rule_ids),
"reasons": [trace.reason_code],
"reasonCode": trace.reason_code,
"traceId": trace.trace_id,
"requiredScopes": required_scopes,
"steps": [step.model_dump(mode="json") for step in trace.steps],
}
def _tool_input_errors(schema: dict[str, Any], arguments: dict[str, Any]) -> list[str]:
"""Return bounded, non-value-bearing JSON Schema diagnostics."""
errors = sorted(
Draft202012Validator(schema).iter_errors(arguments),
key=lambda error: tuple(str(part) for part in error.absolute_path),
)
diagnostics: list[str] = []
for error in errors[:10]:
path = ".".join(str(part) for part in error.absolute_path) or "$"
diagnostics.append(f"{path}: {error.validator or 'schema'} validation failed")
return diagnostics
def _direct_connector_readiness(gateway_slug: str) -> dict[str, Any]:
"""Use an allowlisted internal probe when ContextForge has no test route."""
url = CONNECTOR_READINESS_URL_BY_SLUG.get(gateway_slug)
if url is None:
return {
"reachable": False,
"status": "unsupported",
"detail": "No bounded connector readiness route is registered.",
}
try:
response = httpx.get(url, timeout=5.0, follow_redirects=False)
if response.status_code != 200 or len(response.content) > 65536:
raise ValueError("connector readiness response was not acceptable")
payload = response.json()
status_value = (
str(payload.get("status", "unknown")) if isinstance(payload, dict) else "unknown"
)
reachable = status_value in {"ready", "healthy"}
return {
"reachable": reachable,
"status": status_value if reachable else "unavailable",
"detail": (
"Direct connector readiness verified."
if reachable
else "Connector readiness did not report ready."
),
}
except Exception:
return {
"reachable": False,
"status": "unavailable",
"detail": "Connector readiness could not be verified.",
}
def evaluate_tool_readiness(
catalog_root: Path,
clients: ClientLister,
contextforge: ContextForgeClient,
request: ToolReadinessRequest,
principal: AuthenticatedPrincipal,
) -> dict[str, Any]:
"""Validate a tool path without invoking the tool or returning input values."""
projects_by_slug = {
str(doc.get("metadata", {}).get("slug", "")): doc
for doc in _documents(catalog_root, "projects")
}
tools_by_name = {
str(doc.get("spec", {}).get("canonicalName", "")): doc
for doc in _documents(catalog_root, "tools")
}
connector_type_by_slug = {
str(doc.get("metadata", {}).get("slug", "")): str(
doc.get("spec", {}).get("connectorType", "")
)
for doc in _documents(catalog_root, "connectors")
}
project_doc = projects_by_slug.get(request.project)
if project_doc is None:
raise HTTPException(status_code=404, detail="project is not registered")
tool_doc = tools_by_name.get(request.tool)
if tool_doc is None:
raise HTTPException(status_code=404, detail="tool is not registered")
project_spec = cast(dict[str, Any], project_doc.get("spec", {}))
environments = cast(dict[str, Any], project_spec.get("environments") or {})
if request.environment not in environments:
raise HTTPException(status_code=422, detail="environment is not registered for project")
tool_spec = cast(dict[str, Any], tool_doc.get("spec", {}))
connector_type = str(tool_spec.get("connectorType", ""))
read_only = bool(tool_spec.get("readOnly", False))
binding_present = any(
connector_type_by_slug.get(str(binding.get("ref", ""))) == connector_type
and request.environment in (binding.get("environments") or [])
and binding.get("verification") == "verified"
for binding in project_spec.get("connectorBindings", [])
if isinstance(binding, dict)
)
input_schema = cast(dict[str, Any], tool_spec.get("inputSchema") or {})
input_errors = _tool_input_errors(input_schema, request.arguments)
policy = simulate_policy(
catalog_root,
clients,
{
"project": request.project,
"environment": request.environment,
"tool": request.tool,
"bindingPresent": binding_present,
"clientEnabled": True,
},
principal,
)
gateway_slug = CONNECTOR_GATEWAY_BY_TYPE.get(connector_type)
if gateway_slug is None:
connector_check = {
"reachable": False,
"status": "not-deployed",
"detail": "No production connector gateway is registered for this connector type.",
}
else:
try:
result = contextforge.test_gateway_by_slug(gateway_slug)
connector_check = result.model_dump(mode="json")
if connector_check["status"] == "unsupported":
connector_check = _direct_connector_readiness(gateway_slug)
except Exception:
connector_check = {
"reachable": False,
"status": "unavailable",
"detail": "Connector readiness could not be verified.",
}
ready = bool(
read_only
and not input_errors
and binding_present
and policy["allowed"]
and connector_check["reachable"]
)
return {
"ready": ready,
"tool": request.tool,
"project": request.project,
"environment": request.environment,
"checks": {
"catalog": {"valid": True, "readOnly": read_only, "connectorType": connector_type},
"input": {"valid": not input_errors, "errors": input_errors},
"binding": {
"valid": binding_present,
"detail": (
"Project connector binding covers this environment."
if binding_present
else "Project connector binding is missing for this environment."
),
},
"policy": {
"allowed": policy["allowed"],
"decision": policy["decision"],
"reasonCode": policy["reasonCode"],
"traceId": policy["traceId"],
},
"connector": connector_check,
},
"note": "Readiness only; no MCP tool invocation was performed.",
}
def _schema_projection(value: Any, schema: dict[str, Any]) -> Any:
if schema.get("type") == "object" and isinstance(value, dict):
properties = schema.get("properties")
if not isinstance(properties, dict):
return value
projected = {
name: _schema_projection(value[name], cast(dict[str, Any], child_schema))
for name, child_schema in properties.items()
if name in value and isinstance(child_schema, dict)
}
additional = schema.get("additionalProperties", True)
if additional is not False:
additional_schema = additional if isinstance(additional, dict) else {}
projected.update(
{
name: _schema_projection(item, additional_schema)
for name, item in value.items()
if name not in properties
}
)
return projected
if schema.get("type") == "array" and isinstance(value, list):
item_schema = schema.get("items")
if isinstance(item_schema, dict):
return [_schema_projection(item, item_schema) for item in value]
return value
def _project_connector_output(
structured_content: Any, output_schema: dict[str, Any]
) -> tuple[Any, list[str]]:
"""Project a standard connector envelope onto its registered public contract."""
if not isinstance(structured_content, dict) or "data" not in structured_content:
return structured_content, []
meta = structured_content.get("meta")
warnings = structured_content.get("warnings")
if (
not isinstance(meta, dict)
or not isinstance(warnings, list)
or len(warnings) > 20
or any(not isinstance(item, str) or len(item) > 500 for item in warnings)
):
raise ValueError("connector envelope is invalid")
data = structured_content["data"]
properties = output_schema.get("properties")
if isinstance(data, dict) and isinstance(properties, dict):
data = {
**data,
**{name: meta[name] for name in properties if name not in data and name in meta},
}
return _schema_projection(data, output_schema), warnings
def invoke_read_only_tool(
catalog_root: Path,
clients: ClientLister,
contextforge: ContextForgeClient,
sessions: Any,
request: ToolInvokeRequest,
principal: AuthenticatedPrincipal,
signing_key: bytes,
*,
transport: httpx.BaseTransport | None = None,
) -> dict[str, Any]:
"""Invoke one cataloged read-only connector through a bounded operator path."""
readiness = evaluate_tool_readiness(catalog_root, clients, contextforge, request, principal)
if not readiness["ready"]:
raise HTTPException(status_code=409, detail="tool path is not ready")
tool_doc = next(
doc
for doc in _documents(catalog_root, "tools")
if str(doc.get("spec", {}).get("canonicalName", "")) == request.tool
)
tool_spec = cast(dict[str, Any], tool_doc.get("spec", {}))
if tool_spec.get("readOnly") is not True:
raise HTTPException(status_code=403, detail="management console permits read-only tools")
connector_type = str(tool_spec.get("connectorType", ""))
connector_url = CONNECTOR_URL_BY_TYPE.get(connector_type)
if connector_url is None:
raise HTTPException(status_code=409, detail="connector is not registered")
console_client_id = uuid5(
NAMESPACE_URL, f"https://hub.example.com/management-console/{principal.subject}"
)
governance = Governance(
_policy_engine(catalog_root),
SqlAlchemyAuditLog(sessions, issuer=principal.issuer),
)
context = Governance.context(
workspace="itworx",
project=request.project,
environment=request.environment,
principal=principal.subject,
principal_type=principal.kind.value,
client_id=console_client_id,
binding_present=True,
client_enabled=True,
tool=request.tool,
connector=connector_type,
arguments=request.arguments,
resource_type="tool",
risk_class=str(tool_spec.get("riskClass", "R1")),
environment_classification=(
"production" if request.environment == "prod" else "development"
),
)
try:
trace, started = governance.begin(context)
except PolicyDenied as exc:
raise HTTPException(
status_code=403, detail="policy denied management-console invocation"
) from exc
headers = {
"Accept": "application/json",
"Content-Type": "application/json",
"MCP-Protocol-Version": "2025-11-25",
"x-itworx-project": request.project,
"x-itworx-environment": request.environment,
"x-itworx-client-id": str(console_client_id),
"x-itworx-trace-id": trace.trace_id,
"x-itworx-identity-assertion": identity_assertion(
signing_key,
project=request.project,
environment=request.environment,
client_id=str(console_client_id),
trace_id=trace.trace_id,
),
}
body = {
"jsonrpc": "2.0",
"id": trace.trace_id,
"method": "tools/call",
"params": {"name": request.tool, "arguments": request.arguments},
}
max_output = min(int(tool_spec.get("limits", {}).get("maxOutputBytes", 131072)), 262144)
timeout_seconds = min(
max(float(tool_spec.get("limits", {}).get("timeoutMs", 10000)) / 1000, 1.0),
20.0,
)
try:
with httpx.Client(
transport=transport, timeout=timeout_seconds, follow_redirects=False
) as client:
response = client.post(connector_url, headers=headers, json=body)
if response.status_code != 200 or len(response.content) > max_output:
governance.finish(
context,
trace,
started,
outcome="error",
error_code="CONNECTOR_RESPONSE_REJECTED",
)
raise HTTPException(status_code=502, detail="connector response was not acceptable")
document = response.json()
if not isinstance(document, dict):
raise ValueError("connector response is not an object")
result = document.get("result")
error = document.get("error")
if error is not None or not isinstance(result, dict):
governance.finish(context, trace, started, outcome="error", error_code="MCP_TOOL_ERROR")
return {
"ok": False,
"traceId": trace.trace_id,
"tool": request.tool,
"error": {"code": "MCP_TOOL_ERROR", "message": "The tool returned an error."},
}
is_error = result.get("isError") is True
output_schema = cast(dict[str, Any], tool_spec.get("outputSchema") or {})
structured_content = result.get("structuredContent")
try:
contract_content, connector_warnings = _project_connector_output(
structured_content, output_schema
)
except ValueError as exc:
governance.finish(
context,
trace,
started,
outcome="error",
error_code="OUTPUT_ENVELOPE_INVALID",
)
raise HTTPException(
status_code=502, detail="connector output envelope was not acceptable"
) from exc
output_errors = list(Draft202012Validator(output_schema).iter_errors(contract_content))
if not is_error and output_errors:
governance.finish(
context,
trace,
started,
outcome="error",
error_code="OUTPUT_SCHEMA_INVALID",
)
raise HTTPException(
status_code=502, detail="connector output failed the registered contract"
)
governance.finish(
context,
trace,
started,
outcome="error" if is_error else "success",
error_code="MCP_TOOL_ERROR" if is_error else None,
result_summary=(
(contract_content.get("data") or {}).get("auditEvidence")
if not is_error and request.tool == "gitea.pullrequest.merge"
else None
),
)
return {
"ok": not is_error,
"traceId": trace.trace_id,
"tool": request.tool,
"structuredContent": contract_content,
"warnings": connector_warnings,
}
except HTTPException:
raise
except (httpx.HTTPError, ValueError, json.JSONDecodeError) as exc:
governance.finish(
context, trace, started, outcome="error", error_code="CONNECTOR_UNAVAILABLE"
)
raise HTTPException(status_code=502, detail="connector invocation failed safely") from exc
def operations_snapshot(sessions: Any, backup_path: Path) -> dict[str, Any]:
"""Return aggregated, bounded operational signals without audit payloads."""
now = datetime.now(UTC)
since = now - timedelta(hours=24)
with sessions() as session:
rows = session.execute(
select(AuditEventRow.occurred_at, AuditEventRow.outcome, AuditEventRow.tool_name)
.where(AuditEventRow.occurred_at >= since)
.order_by(AuditEventRow.occurred_at)
.limit(5001)
).all()
truncated = len(rows) > 5000
rows = rows[:5000]
buckets: dict[str, dict[str, int]] = {}
for occurred_at, outcome, _tool in rows:
key = occurred_at.astimezone(UTC).replace(minute=0, second=0, microsecond=0).isoformat()
bucket = buckets.setdefault(key, {"success": 0, "denied": 0, "error": 0, "rate_limited": 0})
if outcome in bucket:
bucket[outcome] += 1
totals = {"success": 0, "denied": 0, "error": 0, "rate_limited": 0}
for bucket in buckets.values():
for outcome in totals:
totals[outcome] += bucket[outcome]
backup = read_backup_status(backup_path, now=now)
alerts: list[dict[str, str]] = []
if backup["state"] != "healthy":
alerts.append(
{"severity": "warning", "code": "BACKUP_ATTENTION", "message": backup["detail"]}
)
if totals["error"]:
alerts.append(
{
"severity": "warning",
"code": "TOOL_ERRORS",
"message": f"{totals['error']} governed call errors occurred in the last 24 hours.",
}
)
if totals["denied"] + totals["rate_limited"] >= 10:
alerts.append(
{
"severity": "info",
"code": "AUTHORIZATION_ACTIVITY",
"message": "Authorization denials or rate limits need review.",
}
)
if truncated:
alerts.append(
{
"severity": "info",
"code": "AUDIT_AGGREGATE_TRUNCATED",
"message": "The 24-hour aggregate reached its 5,000-event display bound.",
}
)
return {
"generatedAt": now.isoformat(),
"backup": backup,
"audit24h": totals,
"auditTruncated": truncated,
"trend": [{"hour": hour, **values} for hour, values in sorted(buckets.items())],
"alerts": alerts,
}
def build_app(
*,
oidc: OIDCSettings | None = None,
provider_transport: httpx.BaseTransport | None = None,
contextforge_settings: ContextForgeSettings | None = None,
contextforge_transport: httpx.BaseTransport | None = None,
engine: Engine | None = None,
catalog_root: Path | None = None,
) -> FastAPI:
"""Build the production ASGI app.
Every keyword argument is an optional injection point for tests: with
none given, behavior is identical to before (settings from environment,
real network transports, a real database engine). Tests that need a
fully real app -- to exercise actual route wiring and RBAC end to end,
not just the underlying matrix in isolation -- pass a fake OIDC/ContextForge
transport (`services/control_api/auth/fake_provider.py`,
`services/control_api/contextforge/fake.py`) and a throwaway SQLite engine.
"""
catalog_root = (
catalog_root or Path(os.environ.get("HUB_CATALOG_ROOT", "/opt/hub/catalog")).resolve()
)
if not catalog_root.is_dir():
raise ValueError("catalog root is unavailable")
oidc = oidc or OIDCSettings.from_env()
provider = OIDCProviderClient(oidc, transport=provider_transport)
mapper = PrincipalMapper(role_group_map=oidc.role_group_map)
bff = BFFService(oidc, provider, principal_mapper=mapper.map_claims)
contextforge = ContextForgeClient(
contextforge_settings or ContextForgeSettings.from_env(), transport=contextforge_transport
)
engine = engine or create_engine_from_url(hub_database_url())
sessions = session_factory(engine)
loader = CatalogLoader(
catalog_root,
public_base_url=os.environ.get("HUB_PUBLIC_MCP_URL", "https://mcp.example.com"),
)
public_mcp_url = os.environ.get("HUB_PUBLIC_MCP_URL", "https://mcp.example.com")
public_host = urlparse(public_mcp_url).hostname
if not public_host:
raise ValueError("HUB_PUBLIC_MCP_URL must contain a hostname")
clients = PersistentClientService(
sessions,
issuer=str(oidc.issuer),
public_host=public_host,
)
backup_status_path = Path(
os.environ.get("HUB_BACKUP_STATUS_FILE", "/var/lib/itworx/backup-status.json")
)
@asynccontextmanager
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
try:
yield
finally:
provider.close()
contextforge.close()
engine.dispose()
app = FastAPI(
title="ITWorx MCP Hub control API",
version=os.environ.get("HUB_RELEASE_VERSION", "1.0.0-rc"),
docs_url=None,
redoc_url=None,
openapi_url=None,
lifespan=lifespan,
)
app.include_router(build_auth_router(bff))
@app.get("/healthz")
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "healthy", "component": "control-api"}
@app.get("/ready")
@app.get("/health/ready")
def ready(response: Response) -> dict[str, str]:
try:
with sessions() as session:
session.execute(text("SELECT 1")).scalar_one()
_verify_contextforge_dependency(contextforge)
except Exception:
response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
return {"status": "not-ready", "component": "control-api"}
return {"status": "ready", "component": "control-api"}
@app.get("/version")
def version() -> dict[str, str]:
return {"version": os.environ.get("HUB_RELEASE_VERSION", "1.0.0-rc")}
def authenticate(request: Request) -> AuthenticatedPrincipal:
"""Verify the session is valid and carries at least one recognized
management role. This is authentication plus a coarse baseline gate,
not authorization for any specific action -- see `require()` below
for the actual per-endpoint permission check."""
try:
principal = bff.require_session(request.cookies.get(oidc.cookie_name))
except AuthError as exc:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"code": exc.code, "message": exc.public_message},
) from exc
if not principal.roles:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="management role is required",
)
return principal
# Bound once, reused as route defaults below: ruff (flake8-bugbear B008)
# flags calling `Depends(...)` directly inside a function signature's
# default value, the same reason the pre-WP-230 code bound
# `authorized = Depends(authorize)` once instead of inlining it per route.
authenticated = Depends(authenticate)
def require(action: str) -> Any:
"""FastAPI dependency: authenticate, then enforce the per-endpoint
permission matrix (services/control_api/authz.py) for `action`."""
def dependency(principal: AuthenticatedPrincipal = authenticated) -> AuthenticatedPrincipal:
try:
check_role(principal, action)
except InsufficientRole as exc:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"role does not permit {action}",
) from exc
return principal
return Depends(dependency)
require_dashboard_read = require("dashboard.read")
require_resource_read = require("resource.read")
require_policy_simulate = require("policy.simulate")
require_tool_test = require("tool.test")
require_tool_invoke = require("tool.invoke")
require_operations_check = require("operations.check")
require_catalog_preview = require("catalog.preview")
require_catalog_apply = require("catalog.apply")
def authorize_action(principal: AuthenticatedPrincipal, action: str) -> None:
"""Non-dependency form of the same check, for routers (e.g. the client
router) that need to select an action per-request rather than
per-route."""
try:
check_role(principal, action)
except InsufficientRole as exc:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"role does not permit {action}",
) from exc
app.include_router(
build_client_router(
clients, # type: ignore[arg-type]
authenticate=authenticate,
authorize=authorize_action,
prefix="/client-management",
token_endpoint=(
f"{urlparse(str(oidc.issuer)).scheme}://"
f"{urlparse(str(oidc.issuer)).netloc}/application/o/token/"
),
oauth_client_id=oidc.client_id,
),
prefix="/api",
)
@app.middleware("http")
async def response_security(request: Request, call_next: Any) -> Any:
if request.url.path.startswith("/api/") and request.method not in {
"GET",
"HEAD",
"OPTIONS",
}:
try:
bff.verify_csrf(
request.cookies.get(oidc.cookie_name), request.headers.get("X-CSRF-Token")
)
except AuthError as exc:
return JSONResponse(
status_code=status.HTTP_403_FORBIDDEN,
content={"detail": {"code": exc.code, "message": exc.public_message}},
)
response = await call_next(request)
response.headers["Cache-Control"] = "no-store"
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["Referrer-Policy"] = "no-referrer"
response.headers["X-Frame-Options"] = "DENY"
return response
@app.get("/api/dashboard")
def dashboard(_: Any = require_dashboard_read) -> dict[str, Any]:
gateway = "unavailable"
gateway_detail = "ContextForge unavailable"
try:
health = contextforge.health()
gateway = "healthy" if health.get("status") else "degraded"
gateway_detail = "ContextForge health verified"
except Exception:
gateway = "unavailable"
projects = []
for document in _documents(catalog_root, "projects"):
metadata = document.get("metadata", {})
spec = document.get("spec", {})
registration_health, registration_detail = _catalog_registration_health(document)
for environment in spec.get("environments") or {}:
projects.append(
{
"slug": metadata.get("slug", "unknown"),
"name": metadata.get("name", "Unknown"),
"environment": environment,
"health": registration_health,
"detail": registration_detail,
}
)
connectors = []
for item in _documents(catalog_root, "connectors"):
registration_state, registration_detail = _catalog_registration_health(item)
connectors.append(
{
"name": item.get("metadata", {}).get("name", "Unknown"),
"project": "registered",
"state": registration_state,
"detail": registration_detail,
}
)
with sessions() as session:
denial_count = session.scalar(
select(func.count(AuditEventRow.id)).where(
AuditEventRow.outcome.in_(("denied", "rate_limited"))
)
)
drift: dict[str, Any] = {
"count": 0,
"projects": [],
"known": False,
"detail": "Catalog drift could not be measured because the gateway is unavailable.",
}
try:
drift = measure_connector_drift(contextforge)
except Exception:
# A dashboard read must stay available when ContextForge is down;
# report that the result is unmeasured rather than implying zero drift.
drift["detail"] = (
"Catalog drift could not be measured because the gateway is unavailable."
)
return {
"generatedAt": datetime.now(UTC).isoformat(),
"staleAfterSeconds": 60,
"health": {
"hub": "healthy",
"gateway": gateway,
"detail": gateway_detail,
},
"projects": projects,
"connectors": connectors,
"denials": {"count": int(denial_count or 0), "window": "retained audit window"},
"drift": drift,
"activeClients": len([item for item in clients.list() if item.status == "enabled"]),
"backup": read_backup_status(backup_status_path),
"version": {
"application": os.environ.get("HUB_RELEASE_VERSION", "1.0.0-rc"),
"schema": os.environ.get("HUB_SCHEMA_VERSION", "0004"),
},
}
@app.get("/api/projects/{slug}")
def project_detail(slug: str, _: Any = require_resource_read) -> dict[str, Any]:
"""Real, catalog-derived project detail -- this is what the project
detail page's tabs actually render, replacing the old hardcoded
"prod" / "3 servers" placeholders that showed for every project slug
regardless of what was actually registered.
"""
project_doc = next(
(
doc
for doc in _documents(catalog_root, "projects")
if str(doc.get("metadata", {}).get("slug", "")) == slug
),
None,
)
if project_doc is None:
raise HTTPException(status_code=404, detail="project is not registered")
spec = cast(dict[str, Any], project_doc.get("spec", {}))
metadata = cast(dict[str, Any], project_doc.get("metadata", {}))
connector_type_by_slug = {
str(doc.get("metadata", {}).get("slug", "")): str(
doc.get("spec", {}).get("connectorType", "")
)
for doc in _documents(catalog_root, "connectors")
}
environments = cast(dict[str, Any], spec.get("environments") or {})
connector_bindings = [
{
"ref": binding.get("ref"),
"connectorType": connector_type_by_slug.get(str(binding.get("ref", ""))),
"environments": binding.get("environments", []),
"verification": binding.get("verification"),
}
for binding in spec.get("connectorBindings", [])
if isinstance(binding, dict)
]
project_clients = [item for item in clients.list() if item.project == slug]
virtual_servers = [
server for server in spec.get("virtualServers", []) if isinstance(server, dict)
]
return {
"slug": slug,
"name": str(metadata.get("name", slug)),
"description": str(spec.get("description", "")),
"environments": [
{"name": name, "classification": cast(dict[str, Any], env).get("classification")}
for name, env in environments.items()
],
"capabilityPacks": list(spec.get("capabilityPacks", [])),
"connectorBindings": connector_bindings,
"virtualServerCount": len(virtual_servers),
"clientCount": len(project_clients),
"activeClientCount": len(
[item for item in project_clients if item.status == "enabled"]
),
}
@app.get("/api/tool-definitions/{canonical_name:path}")
def tool_definition(canonical_name: str, _: Any = require_resource_read) -> dict[str, Any]:
document = next(
(
item
for item in _documents(catalog_root, "tools")
if str(item.get("spec", {}).get("canonicalName", "")) == canonical_name
),
None,
)
if document is None:
raise HTTPException(status_code=404, detail="tool is not registered")
spec = cast(dict[str, Any], document.get("spec", {}))
return {
"canonicalName": canonical_name,
"description": str(spec.get("description", ""))[:1000],
"readOnly": spec.get("readOnly") is True,
"riskClass": str(spec.get("riskClass", "R1")),
"inputSchema": cast(dict[str, Any], spec.get("inputSchema") or {}),
"limits": cast(dict[str, Any], spec.get("limits") or {}),
}
@app.get("/api/operations/summary")
def operation_summary(_: Any = require_dashboard_read) -> dict[str, Any]:
return operations_snapshot(sessions, backup_status_path)
@app.post("/api/operations/check")
def operation_check(_: Any = require_operations_check) -> dict[str, Any]:
checks = {
slug: _direct_connector_readiness(slug)
for slug in sorted(CONNECTOR_READINESS_URL_BY_SLUG)
}
return {
"generatedAt": datetime.now(UTC).isoformat(),
"healthy": all(item["reachable"] for item in checks.values()),
"connectors": checks,
}
@app.get("/api/{kind}")
def resource_page(
kind: str,
q: str = Query(default="", max_length=200),
limit: int = Query(default=25, ge=1, le=100),
cursor: str | None = Query(default=None, max_length=128),
principal: AuthenticatedPrincipal = authenticated,
) -> dict[str, Any]:
# A single handler serves several resource kinds with different
# sensitivity -- audit reads require auditor+, everything else here
# is viewer+ -- so the permission check happens here, keyed on
# `kind`, rather than as a single fixed per-route dependency.
authorize_action(principal, "audit.read" if kind == "audit" else "resource.read")
offset = decode_page_cursor(cursor) if cursor else 0
documents: list[dict[str, Any]] = []
rows: list[dict[str, Any]]
if kind == "clients":
rows = [
{
"id": str(item.id),
"name": item.name,
"status": (
"healthy"
if item.status == "enabled"
else "unavailable"
if item.status == "revoked"
else "degraded"
),
"summary": (
f"{item.kind.value}; last seen "
f"{item.last_seen_at.isoformat() if item.last_seen_at else 'never'}"
),
"project": item.project,
"environment": item.environment,
"risk": None,
}
for item in clients.list()
]
elif kind == "audit":
# Fetched up to a fixed v1 scale bound, then paginated the same
# way as every other kind below -- bounded audit browsing, not
# unbounded history search (see _AUDIT_FETCH_CAP).
with sessions() as session:
audit_rows = session.execute(
select(AuditEventRow, ProjectRow.slug)
.join(ProjectRow, ProjectRow.id == AuditEventRow.project_id)
.order_by(AuditEventRow.occurred_at.desc(), AuditEventRow.id.desc())
.limit(_AUDIT_FETCH_CAP)
).all()
rows = [
{
"id": str(event.id),
"name": event.event_type,
"status": "healthy" if event.outcome == "success" else "degraded",
"summary": (
f"{event.tool_name or 'mcp.request'}; {event.outcome}; "
f"{event.occurred_at.isoformat()}"
),
"project": project_slug,
"environment": None,
"decision": event.policy_decision,
"traceId": event.trace_id,
}
for event, project_slug in audit_rows
]
else:
folder = RESOURCE_FOLDERS.get(kind)
if folder is None:
raise HTTPException(status_code=404, detail="resource kind is unavailable")
documents = _documents(catalog_root, folder)
if kind not in {"clients", "audit"}:
rows = [_row(document, kind) for document in documents]
if q:
needle = q.casefold()
rows = [
item for item in rows if needle in f"{item['name']} {item['summary']}".casefold()
]
total = len(rows)
page_rows = rows[offset : offset + limit]
next_cursor = encode_page_cursor(offset + limit) if offset + limit < total else None
return {
"kind": kind,
"generatedAt": datetime.now(UTC).isoformat(),
"rows": page_rows,
"page": {"total": total, "nextCursor": next_cursor},
}
@app.post("/api/policies/simulate")
def policy_simulation(
payload: dict[str, Any], principal: AuthenticatedPrincipal = require_policy_simulate
) -> dict[str, Any]:
return simulate_policy(catalog_root, clients, payload, principal)
@app.post("/api/tools/test")
def tool_test(
payload: ToolReadinessRequest,
principal: AuthenticatedPrincipal = require_tool_test,
) -> dict[str, Any]:
return evaluate_tool_readiness(catalog_root, clients, contextforge, payload, principal)
@app.post("/api/tools/invoke")
def tool_invoke(
payload: ToolInvokeRequest,
principal: AuthenticatedPrincipal = require_tool_invoke,
) -> dict[str, Any]:
return invoke_read_only_tool(
catalog_root,
clients,
contextforge,
sessions,
payload,
principal,
load_connector_identity_key(),
)
@app.post("/api/catalog/preview")
def catalog_preview(
payload: dict[str, Any], __: Any = require_catalog_preview
) -> dict[str, Any]:
project = str(payload.get("project", ""))
projects = {
str(item.get("metadata", {}).get("slug", ""))
for item in _documents(catalog_root, "projects")
}
report = loader.load()
errors = [issue.message for issue in report.issues]
if project not in projects:
errors.append("requested project is not registered")
return {
"valid": report.valid and project in projects,
"changes": [f"reconcile registered project {project}"] if not errors else [],
"errors": errors,
}
@app.post("/api/catalog/apply")
def catalog_apply(payload: dict[str, Any], __: Any = require_catalog_apply) -> dict[str, Any]:
project = str(payload.get("project", ""))
projects = {
str(item.get("metadata", {}).get("slug", ""))
for item in _documents(catalog_root, "projects")
}
if project not in projects:
raise HTTPException(status_code=404, detail="requested project is not registered")
reconcile_runtime_state(
contextforge,
sessions,
catalog_root,
str(oidc.issuer),
)
return {"accepted": True, "message": "Catalog reconciliation completed and verified."}
return app
def run() -> None:
run_asgi(build_app())