Files
ITWorx_MCP_Hub-Public/scripts/reconcile_gitea_public_surface.py
T

263 lines
9.9 KiB
Python

from __future__ import annotations
import fnmatch
import json
import os
import time
from pathlib import Path
from typing import Any
import yaml # type: ignore[import-untyped]
from services.control_api.contextforge import ContextForgeClient, ContextForgeSettings
from services.control_api.contextforge.models import VirtualServerDesiredState
from services.runtime.common import deterministic_id
PROJECT_SLUG = "gitea-all"
ENVIRONMENT = "prod"
GATEWAY_SLUG = "itworx-gitea"
SERVER_NAME = "gitea-all-prod-readonly"
REQUIRED_RECOVERY_TOOL = "gitea.pullrequest.sync"
RECONCILE_ATTEMPTS = 4
RECONCILE_DELAY_SECONDS = 2.0
def _entities(value: Any, key: str) -> list[dict[str, Any]]:
if isinstance(value, list):
return [item for item in value if isinstance(item, dict)]
if isinstance(value, dict) and isinstance(value.get(key), list):
return [item for item in value[key] if isinstance(item, dict)]
return []
def _published_tool_name(item: dict[str, Any]) -> str:
# ContextForge clients invoke customName when present. Prefer the actual
# published name over originalName so the canary validates the same surface
# ChatGPT sees rather than the gateway's import-time identifier.
for key in ("customName", "custom_name", "name", "originalName", "original_name"):
value = item.get(key)
if isinstance(value, str) and value:
return value
return ""
def _published_prompt_name(item: dict[str, Any]) -> str:
for key in ("customName", "custom_name", "name", "originalName", "original_name"):
value = item.get(key)
if isinstance(value, str) and value:
return value
return ""
def _load_surface(catalog_root: Path) -> tuple[list[str], list[str]]:
document = yaml.safe_load(
(catalog_root / "projects" / f"{PROJECT_SLUG}.yaml").read_text(encoding="utf-8")
)
if not isinstance(document, dict) or not isinstance(document.get("spec"), dict):
raise RuntimeError("gitea-all catalog document is invalid")
servers = document["spec"].get("virtualServers", [])
server = next(
(
item
for item in servers
if isinstance(item, dict)
and item.get("slug") == SERVER_NAME
and item.get("environment") == ENVIRONMENT
),
None,
)
if not isinstance(server, dict):
raise RuntimeError("gitea-all production virtual server is missing")
tools = server.get("toolAllowPatterns", [])
prompts = server.get("promptAllowPatterns", [])
if not isinstance(tools, list) or not all(isinstance(item, str) for item in tools):
raise RuntimeError("gitea-all tool surface is invalid")
if not isinstance(prompts, list) or not all(isinstance(item, str) for item in prompts):
raise RuntimeError("gitea-all prompt surface is invalid")
if any(any(token in item for token in ("*", "?", "[")) for item in tools):
raise RuntimeError("gitea-all post-deploy surface must remain explicit")
if REQUIRED_RECOVERY_TOOL not in tools:
raise RuntimeError("gitea pull-request recovery tool is not published")
return list(tools), list(prompts)
def _global_tools(client: ContextForgeClient) -> list[dict[str, Any]]:
value = client._request( # noqa: SLF001 - fixed ContextForge inventory endpoint
"GET",
"/tools",
params={"limit": 0, "include_pagination": "false"},
retryable=True,
)
return _entities(value, "tools")
def _global_prompts(client: ContextForgeClient) -> list[dict[str, Any]]:
value = client._request( # noqa: SLF001 - fixed ContextForge inventory endpoint
"GET",
"/prompts",
params={"limit": 0, "include_pagination": "false"},
retryable=True,
)
return _entities(value, "prompts")
def _select_tool_ids(tools: list[dict[str, Any]], names: list[str]) -> tuple[str, ...]:
by_name: dict[str, set[str]] = {}
for item in tools:
external_id = item.get("id")
name = _published_tool_name(item)
if not isinstance(external_id, str) or not external_id or not name:
continue
by_name.setdefault(name, set()).add(external_id)
missing = sorted(set(names) - by_name.keys())
if missing:
raise RuntimeError(
"ContextForge global inventory is missing required published tools: "
+ ", ".join(missing[:8])
)
ambiguous = sorted(name for name in names if len(by_name.get(name, set())) != 1)
if ambiguous:
raise RuntimeError(
"ContextForge global inventory has ambiguous published tools: "
+ ", ".join(ambiguous[:8])
)
return tuple(sorted(next(iter(by_name[name])) for name in names))
def _select_prompt_ids(prompts: list[dict[str, Any]], patterns: list[str]) -> tuple[str, ...]:
selected: set[str] = set()
for item in prompts:
external_id = item.get("id")
name = _published_prompt_name(item)
if not isinstance(external_id, str) or not external_id or not name:
continue
if any(fnmatch.fnmatchcase(name, pattern) for pattern in patterns):
selected.add(external_id)
return tuple(sorted(selected))
def _live_server_tools(client: ContextForgeClient, external_id: str) -> list[dict[str, Any]]:
value = client._request( # noqa: SLF001 - fixed managed virtual-server verifier
"GET",
f"/servers/{external_id}/tools",
params={"include_pagination": "false"},
retryable=True,
)
return _entities(value, "tools")
def _desired_state(
client: ContextForgeClient,
catalog_root: Path,
) -> tuple[VirtualServerDesiredState, set[str]]:
required_tools, prompt_patterns = _load_surface(catalog_root)
gateway = client._find(GATEWAY_SLUG) # noqa: SLF001 - fixed managed gateway only
if gateway is None:
raise RuntimeError("managed Gitea ContextForge gateway is missing")
# Canonicalize the managed Gitea gateway first. Then select the complete
# virtual-server surface from ContextForge's global registry so tools from
# other managed gateways (for example project.status.get) are preserved.
client.ensure_canonical_tool_names(client.list_gateway_tools(gateway))
tools = _global_tools(client)
try:
selected_tool_ids = _select_tool_ids(tools, required_tools)
except RuntimeError:
# One bounded refresh repairs stale Gitea discovery without repeatedly
# mutating ContextForge on a deterministic catalog error.
client.refresh_gateway(gateway)
client.ensure_canonical_tool_names(client.list_gateway_tools(gateway))
tools = _global_tools(client)
selected_tool_ids = _select_tool_ids(tools, required_tools)
selected_prompt_ids = _select_prompt_ids(_global_prompts(client), prompt_patterns)
desired = VirtualServerDesiredState(
external_id=deterministic_id("virtual-server", f"{PROJECT_SLUG}/{ENVIRONMENT}"),
name=SERVER_NAME,
description="ITWorx managed project and environment scoped MCP server",
associated_tool_ids=selected_tool_ids,
associated_prompt_ids=selected_prompt_ids,
)
return desired, set(required_tools)
def _reconcile_once(
client: ContextForgeClient,
catalog_root: Path,
) -> dict[str, object]:
desired, required_tools = _desired_state(client, catalog_root)
server = client.ensure_virtual_server(desired)
# ContextForge v1.0.6 may return camelCase association fields. Re-apply the
# exact desired state and verify the server-facing tools endpoint rather than
# trusting the shape of a successful update response.
client._request( # noqa: SLF001 - fixed server, exact catalog-derived associations
"PUT",
f"/servers/{server.external_id}",
json_body={
"name": desired.name,
"description": desired.description,
"associated_tools": list(desired.associated_tool_ids),
"associated_prompts": list(desired.associated_prompt_ids),
"visibility": desired.visibility,
},
)
live_tools = _live_server_tools(client, server.external_id)
live_ids = tuple(sorted(str(item.get("id", "")) for item in live_tools if item.get("id")))
live_names = {_published_tool_name(item) for item in live_tools if _published_tool_name(item)}
if live_ids != desired.associated_tool_ids:
raise RuntimeError("ContextForge virtual-server tool association read-back mismatch")
missing_live = sorted(required_tools - live_names)
if missing_live:
raise RuntimeError(
"ContextForge virtual server is missing required published tools: "
+ ", ".join(missing_live[:8])
)
if REQUIRED_RECOVERY_TOOL not in live_names:
raise RuntimeError("Gitea pull-request recovery tool is not live")
return {
"status": "pass",
"server": SERVER_NAME,
"toolCount": len(live_ids),
"promptCount": len(desired.associated_prompt_ids),
"pullRequestSyncLive": True,
}
def reconcile_gitea_public_surface(
client: ContextForgeClient,
catalog_root: Path,
*,
attempts: int = RECONCILE_ATTEMPTS,
delay_seconds: float = RECONCILE_DELAY_SECONDS,
) -> dict[str, object]:
if not 1 <= attempts <= 6:
raise ValueError("reconcile attempts must be between 1 and 6")
last_error: RuntimeError | None = None
for attempt in range(attempts):
try:
return _reconcile_once(client, catalog_root)
except RuntimeError as exc:
last_error = exc
if attempt + 1 < attempts:
time.sleep(delay_seconds)
assert last_error is not None
raise last_error
def main() -> None:
catalog_root = Path(os.environ.get("HUB_CATALOG_ROOT", "/opt/hub/catalog")).resolve()
client = ContextForgeClient(ContextForgeSettings.from_env())
try:
result = reconcile_gitea_public_surface(client, catalog_root)
finally:
client.close()
print(json.dumps(result, sort_keys=True, separators=(",", ":")))
if __name__ == "__main__":
main()