"""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) )