Files
ITWorx_MCP_Hub-Public/services/control_api/contextforge/client.py
T

626 lines
24 KiB
Python

"""Typed, retry-bounded ContextForge HTTP adapter."""
from __future__ import annotations
import hashlib
import json
import os
import time
from base64 import b64encode
from collections.abc import Mapping
from datetime import UTC, datetime
from typing import Any, Literal, Protocol, cast
from uuid import UUID
import httpx
from .errors import ContextForgeError
from .models import (
ContextForgeSettings,
GatewayCapabilities,
GatewayDesiredState,
GatewayHandle,
GatewayPrompt,
GatewaySnapshot,
GatewayTestResult,
GatewayTool,
SanitizedGatewayInventory,
VirtualServerDesiredState,
VirtualServerHandle,
)
class GatewayAdapter(Protocol):
def health(self) -> dict[str, Any]: ...
def capabilities(self) -> GatewayCapabilities: ...
def list_gateways(
self, *, cursor: str | None = None, limit: int = 50
) -> tuple[GatewaySnapshot, ...]: ...
def ensure_gateway(self, desired: GatewayDesiredState) -> GatewayHandle: ...
def update_gateway(
self, handle: GatewayHandle, desired: GatewayDesiredState
) -> GatewayHandle: ...
def delete_gateway(self, handle: GatewayHandle) -> None: ...
def refresh_gateway(self, handle: GatewayHandle) -> None: ...
def list_gateway_tools(self, handle: GatewayHandle) -> tuple[GatewayTool, ...]: ...
def ensure_canonical_tool_names(
self, tools: tuple[GatewayTool, ...]
) -> tuple[GatewayTool, ...]: ...
def list_gateway_prompts(self, handle: GatewayHandle) -> tuple[GatewayPrompt, ...]: ...
def test_gateway(self, handle: GatewayHandle) -> GatewayTestResult: ...
class ContextForgeClient:
"""ContextForge API client; raw gateway IDs stay inside this adapter."""
def __init__(
self,
settings: ContextForgeSettings,
*,
transport: httpx.BaseTransport | None = None,
environ: Mapping[str, str] | None = None,
) -> None:
self.settings = settings
self._environ = environ if environ is not None else os.environ
self._http = httpx.Client(
base_url=str(settings.base_url).rstrip("/"),
timeout=settings.timeout_seconds,
transport=transport,
)
self._capabilities: GatewayCapabilities | None = None
def close(self) -> None:
self._http.close()
def _auth_headers(self) -> dict[str, str]:
token = self.settings.credential(self._environ)
scheme = self.settings.auth_scheme
if scheme == "Basic":
encoded = b64encode(f"{self.settings.basic_username}:{token}".encode()).decode("ascii")
credential = f"Basic {encoded}"
else:
credential = f"Bearer {token}"
return {"Accept": "application/json", "Authorization": credential}
def authorization_header(self) -> str:
"""Build the outbound credential only at the request boundary."""
return self._auth_headers()["Authorization"]
def _request(
self,
method: str,
path: str,
*,
params: Mapping[str, str | int] | None = None,
json_body: Mapping[str, Any] | None = None,
retryable: bool = False,
) -> Any:
attempts = self.settings.retries if retryable else 0
for attempt in range(attempts + 1):
try:
response = self._http.request(
method,
path,
headers=self._auth_headers(),
params=params,
json=json_body,
)
except httpx.TimeoutException as exc:
if attempt < attempts:
time.sleep(min(0.25 * (attempt + 1), 1.0))
continue
raise ContextForgeError(
"gateway_timeout", "Gateway request timed out.", retryable=True
) from exc
except httpx.HTTPError as exc:
if attempt < attempts:
time.sleep(min(0.25 * (attempt + 1), 1.0))
continue
raise ContextForgeError(
"gateway_unavailable", "Gateway could not be reached.", retryable=True
) from exc
if response.status_code >= 400:
error = ContextForgeError.from_status(response.status_code)
if error.retryable and attempt < attempts:
time.sleep(min(0.25 * (attempt + 1), 1.0))
continue
raise error
if len(response.content) > self.settings.max_response_bytes:
raise ContextForgeError(
"gateway_response_too_large", "Gateway response exceeded the output budget."
)
if not response.content:
return {}
try:
return response.json()
except (ValueError, json.JSONDecodeError) as exc:
raise ContextForgeError(
"gateway_invalid_response", "Gateway returned an invalid response."
) from exc
raise ContextForgeError("gateway_unavailable", "Gateway request failed.", retryable=True)
def health(self) -> dict[str, Any]:
value = self._request("GET", "/health", retryable=True)
if not isinstance(value, dict):
raise ContextForgeError(
"gateway_invalid_response", "Gateway health response was invalid."
)
return {str(key): value[key] for key in value if key in {"status", "version"}}
def readiness(self) -> dict[str, Any]:
value = self._request("GET", "/ready", retryable=True)
if not isinstance(value, dict):
raise ContextForgeError(
"gateway_invalid_response", "Gateway readiness response was invalid."
)
return {str(key): value[key] for key in value if key in {"status"}}
def version(self) -> str | None:
value = self._request("GET", "/version", retryable=True)
if not isinstance(value, dict):
raise ContextForgeError(
"gateway_invalid_response", "Gateway version response was invalid."
)
raw = value.get("version") or value.get("release")
if raw is None and isinstance(value.get("app"), dict):
raw = value["app"].get("version")
return str(raw) if raw is not None else None
def capabilities(self) -> GatewayCapabilities:
if self._capabilities is not None:
return self._capabilities
health_ok = False
readiness_ok = False
version_value: str | None = None
try:
self.health()
health_ok = True
except ContextForgeError:
pass
try:
self.readiness()
readiness_ok = True
except ContextForgeError:
pass
try:
version_value = self.version()
except ContextForgeError:
pass
crud_ok = False
if health_ok:
try:
self.list_gateways(limit=1)
crud_ok = True
except ContextForgeError:
pass
self._capabilities = GatewayCapabilities(
health=health_ok,
readiness=readiness_ok,
version=version_value,
gateway_crud=crud_ok,
gateway_test=health_ok,
)
return self._capabilities
@staticmethod
def _snapshot(payload: Mapping[str, Any]) -> tuple[str, GatewaySnapshot]:
external_id = payload.get("id")
name = payload.get("name") or payload.get("slug")
if (
not isinstance(external_id, str)
or not external_id
or not isinstance(name, str)
or not name
):
raise ContextForgeError(
"gateway_invalid_response", "Gateway resource identity was invalid."
)
snapshot = GatewaySnapshot(
slug=name,
url=str(payload.get("url", "")),
description=str(payload.get("description", ""))[:1000],
transport=str(payload.get("transport", "STREAMABLEHTTP")),
enabled=bool(payload.get("enabled", True)),
status=str(payload.get("status", "unknown")),
passthrough_headers=tuple(
str(item).lower()
for item in payload.get(
"passthroughHeaders", payload.get("passthrough_headers", [])
)
),
)
return external_id, snapshot
def list_gateways(
self, *, cursor: str | None = None, limit: int = 50
) -> tuple[GatewaySnapshot, ...]:
if not 1 <= limit <= 100:
raise ValueError("gateway list limit must be between 1 and 100")
params: dict[str, str | int] = {"limit": limit, "include_pagination": "false"}
if cursor:
params["cursor"] = cursor
value = self._request("GET", "/gateways", params=params, retryable=True)
entities = (
value
if isinstance(value, list)
else value.get("gateways", [])
if isinstance(value, dict)
else []
)
if not isinstance(entities, list):
raise ContextForgeError(
"gateway_invalid_response", "Gateway inventory response was invalid."
)
return tuple(self._snapshot(item)[1] for item in entities if isinstance(item, dict))
def _find(self, slug: str) -> GatewayHandle | None:
value = self._request(
"GET",
"/gateways",
params={"limit": 100, "include_pagination": "false"},
retryable=True,
)
entities = (
value
if isinstance(value, list)
else value.get("gateways", [])
if isinstance(value, dict)
else []
)
for item in entities if isinstance(entities, list) else []:
if isinstance(item, dict) and (item.get("name") or item.get("slug")) == slug:
external_id, snapshot = self._snapshot(item)
return GatewayHandle(external_id=external_id, snapshot=snapshot)
return None
@staticmethod
def _desired_payload(desired: GatewayDesiredState) -> dict[str, Any]:
return {
"name": desired.slug,
"url": str(desired.url),
"description": desired.description,
"transport": desired.transport,
"enabled": desired.enabled,
"passthrough_headers": list(desired.passthrough_headers),
}
def create_gateway(self, desired: GatewayDesiredState) -> GatewayHandle:
value = self._request("POST", "/gateways", json_body=self._desired_payload(desired))
if not isinstance(value, dict):
raise ContextForgeError(
"gateway_invalid_response", "Gateway create response was invalid."
)
external_id, snapshot = self._snapshot(value)
return GatewayHandle(external_id=external_id, snapshot=snapshot)
def ensure_gateway(self, desired: GatewayDesiredState) -> GatewayHandle:
existing = self._find(desired.slug)
if existing is None:
return self.create_gateway(desired)
# ContextForge can deactivate a gateway after repeated upstream failures
# while leaving its configured ``enabled`` flag set. Treat runtime status
# as part of the desired state so a recovered connector is explicitly
# reactivated instead of retaining a stale, offline tool inventory.
runtime_status = existing.snapshot.status.strip().lower()
if (
existing.snapshot.url != str(desired.url)
or existing.snapshot.description != desired.description
or existing.snapshot.transport != desired.transport
or existing.snapshot.enabled != desired.enabled
or existing.snapshot.passthrough_headers != desired.passthrough_headers
or runtime_status not in {"active", "healthy", "online", "ready"}
):
return self.update_gateway(existing, desired)
return existing
def update_gateway(self, handle: GatewayHandle, desired: GatewayDesiredState) -> GatewayHandle:
value = self._request(
"PUT",
f"/gateways/{handle.external_id}",
json_body=self._desired_payload(desired),
)
if not isinstance(value, dict):
raise ContextForgeError(
"gateway_invalid_response", "Gateway update response was invalid."
)
external_id, snapshot = self._snapshot(value)
return GatewayHandle(external_id=external_id, snapshot=snapshot)
def delete_gateway(self, handle: GatewayHandle) -> None:
self._request("DELETE", f"/gateways/{handle.external_id}")
def list_gateway_tools(self, handle: GatewayHandle) -> tuple[GatewayTool, ...]:
value = self._request(
"GET",
"/tools",
params={
"gateway_id": handle.external_id,
"limit": 0,
"include_pagination": "false",
},
retryable=True,
)
entities = (
value
if isinstance(value, list)
else value.get("tools", [])
if isinstance(value, dict)
else []
)
tools: list[GatewayTool] = []
for item in entities if isinstance(entities, list) else []:
if not isinstance(item, dict):
continue
external_id = item.get("id")
name = (
item.get("originalName")
or item.get("original_name")
or item.get("customName")
or item.get("name")
)
published_name = item.get("customName") or item.get("custom_name") or item.get("name")
if isinstance(external_id, str) and isinstance(name, str):
if not isinstance(published_name, str):
published_name = name
raw_input_schema = item.get("inputSchema", item.get("input_schema"))
tools.append(
GatewayTool(
external_id=external_id,
name=name,
published_name=published_name,
input_schema=(
raw_input_schema if isinstance(raw_input_schema, dict) else None
),
)
)
return tuple(sorted(tools, key=lambda item: item.name))
def ensure_canonical_tool_names(
self, tools: tuple[GatewayTool, ...]
) -> tuple[GatewayTool, ...]:
"""Publish connector names verbatim instead of ContextForge prefixes.
ContextForge makes discovered tool names globally unique by prefixing the
gateway slug. The Hub catalog and ChatGPT app expose the connector's
canonical MCP names, so leaving that prefix in place makes every routed
``tools/call`` miss even though discovery itself is healthy.
"""
canonical: list[GatewayTool] = []
for tool in tools:
if tool.published_name == tool.name:
canonical.append(tool)
continue
value = self._request(
"PUT", f"/tools/{tool.external_id}", json_body={"customName": tool.name}
)
if not isinstance(value, dict) or value.get("customName") != tool.name:
raise ContextForgeError(
"gateway_invalid_response", "Gateway tool rename was not applied."
)
canonical.append(tool.model_copy(update={"published_name": tool.name}))
return tuple(canonical)
def refresh_gateway(self, handle: GatewayHandle) -> None:
value = self._request(
"POST",
f"/gateways/{handle.external_id}/tools/refresh",
params={"include_resources": "true", "include_prompts": "true"},
)
if not isinstance(value, dict) or value.get("success") is not True:
raise ContextForgeError(
"gateway_invalid_response", "Gateway refresh did not complete successfully."
)
def list_gateway_prompts(self, handle: GatewayHandle) -> tuple[GatewayPrompt, ...]:
value = self._request(
"GET",
"/prompts",
params={
"gateway_id": handle.external_id,
"limit": 0,
"include_pagination": "false",
},
retryable=True,
)
entities = (
value
if isinstance(value, list)
else value.get("prompts", [])
if isinstance(value, dict)
else []
)
prompts: list[GatewayPrompt] = []
for item in entities if isinstance(entities, list) else []:
if not isinstance(item, dict):
continue
external_id = item.get("id")
name = (
item.get("originalName")
or item.get("original_name")
or item.get("customName")
or item.get("name")
)
if isinstance(external_id, str) and isinstance(name, str):
prompts.append(GatewayPrompt(external_id=external_id, name=name))
return tuple(sorted(prompts, key=lambda item: item.name))
@staticmethod
def _server_handle(payload: Mapping[str, Any]) -> VirtualServerHandle:
external_id = payload.get("id")
name = payload.get("name")
tool_ids = payload.get(
"associated_tool_ids",
payload.get("associatedToolIds", payload.get("associated_tools", [])),
)
prompt_ids = payload.get(
"associated_prompt_ids",
payload.get("associatedPromptIds", payload.get("associated_prompts", [])),
)
if (
not isinstance(external_id, str)
or not isinstance(name, str)
or not isinstance(tool_ids, list)
or not isinstance(prompt_ids, list)
):
raise ContextForgeError(
"gateway_invalid_response", "Virtual server response was invalid."
)
normalized = tuple(
str(item.get("id")) if isinstance(item, dict) else str(item) for item in tool_ids
)
normalized_prompts = tuple(
str(item.get("id")) if isinstance(item, dict) else str(item) for item in prompt_ids
)
return VirtualServerHandle(
external_id=external_id,
name=name,
associated_tool_ids=tuple(sorted(normalized)),
associated_prompt_ids=tuple(sorted(normalized_prompts)),
enabled=bool(payload.get("enabled", True)),
visibility=cast(
Literal["public", "private", "team"],
str(payload.get("visibility", "private")),
),
)
def _find_server(self, desired: VirtualServerDesiredState) -> VirtualServerHandle | None:
value = self._request(
"GET",
"/servers",
params={"limit": 0, "include_pagination": "false", "include_inactive": "true"},
retryable=True,
)
entities = (
value
if isinstance(value, list)
else value.get("servers", [])
if isinstance(value, dict)
else []
)
for item in entities if isinstance(entities, list) else []:
if not isinstance(item, dict):
continue
candidate = self._server_handle(item)
try:
same_id = UUID(candidate.external_id) == desired.external_id
except ValueError:
same_id = False
if same_id or candidate.name == desired.name:
return candidate
return None
@staticmethod
def _server_payload(desired: VirtualServerDesiredState, *, include_id: bool) -> dict[str, Any]:
payload: dict[str, Any] = {
"name": desired.name,
"description": desired.description,
"associated_tools": list(desired.associated_tool_ids),
"associated_prompts": list(desired.associated_prompt_ids),
"visibility": desired.visibility,
}
if include_id:
payload["id"] = str(desired.external_id)
return payload
def ensure_virtual_server(self, desired: VirtualServerDesiredState) -> VirtualServerHandle:
existing = self._find_server(desired)
if existing is None:
server_payload = self._server_payload(desired, include_id=True)
value = self._request("POST", "/servers", json_body={"server": server_payload})
elif (
existing.name != desired.name
or existing.associated_tool_ids != desired.associated_tool_ids
or existing.associated_prompt_ids != desired.associated_prompt_ids
or existing.visibility != desired.visibility
):
value = self._request(
"PUT",
f"/servers/{existing.external_id}",
json_body=self._server_payload(desired, include_id=False),
)
else:
return existing
if not isinstance(value, dict):
raise ContextForgeError(
"gateway_invalid_response", "Virtual server response was invalid."
)
return self._server_handle(value)
def test_gateway(self, handle: GatewayHandle) -> GatewayTestResult:
try:
value = self._request("GET", f"/gateways/{handle.external_id}/test", retryable=True)
except ContextForgeError as exc:
if exc.code == "gateway_not_found":
return GatewayTestResult(
reachable=False,
status="unsupported",
detail="Gateway test endpoint unavailable.",
)
raise
if not isinstance(value, dict):
raise ContextForgeError(
"gateway_invalid_response", "Gateway test response was invalid."
)
return GatewayTestResult(
reachable=bool(value.get("reachable", value.get("status") in {"ok", "healthy"})),
status=str(value.get("status", "unknown")),
detail=str(value.get("detail", ""))[:256],
)
def test_gateway_by_slug(self, slug: str) -> GatewayTestResult:
"""Probe one registered gateway without exposing its external ID."""
handle = self._find(slug)
if handle is None:
return GatewayTestResult(
reachable=False,
status="missing",
detail="Connector gateway is not registered.",
)
return self.test_gateway(handle)
def inventory(self) -> SanitizedGatewayInventory:
capabilities = self.capabilities()
value = self._request(
"GET",
"/gateways",
params={"limit": 100, "include_pagination": "false"},
retryable=True,
)
entities = (
value
if isinstance(value, list)
else value.get("gateways", [])
if isinstance(value, dict)
else []
)
inventory: list[dict[str, Any]] = []
for item in entities if isinstance(entities, list) else []:
if not isinstance(item, dict):
continue
external_id = str(item.get("id", ""))
inventory.append(
{
"slug": str(item.get("name") or item.get("slug") or ""),
"external_id_digest": hashlib.sha256(external_id.encode()).hexdigest(),
"url": str(item.get("url", "")),
"status": str(item.get("status", "unknown")),
"enabled": bool(item.get("enabled", True)),
}
)
return SanitizedGatewayInventory(
generated_at=datetime.now(UTC), capabilities=capabilities, gateways=tuple(inventory)
)