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

235 lines
8.4 KiB
Python

"""Hub-owned gateway adapter contracts without public gateway IDs."""
from __future__ import annotations
import os
import re
from collections.abc import Mapping
from datetime import UTC, datetime
from pathlib import Path
from typing import Any, Literal, cast
from uuid import UUID
from pydantic import (
AnyHttpUrl,
BaseModel,
ConfigDict,
Field,
TypeAdapter,
field_validator,
model_validator,
)
from packages.hub_runtime import SecretFileError, read_secret_file
from .errors import ContextForgeError
class ContextForgeSettings(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
base_url: AnyHttpUrl
auth_token_env: str | None = Field(default=None, min_length=1, max_length=256)
auth_token_file: Path | None = None
auth_scheme: Literal["Bearer", "Basic"] = "Bearer"
basic_username: str = Field(default="itworx-contextforge-admin", min_length=1, max_length=256)
timeout_seconds: float = Field(default=5.0, gt=0, le=30)
retries: int = Field(default=2, ge=0, le=5)
max_response_bytes: int = Field(default=4 * 1024 * 1024, ge=1024, le=4 * 1024 * 1024)
@classmethod
def from_env(cls, environ: Mapping[str, str] | None = None) -> ContextForgeSettings:
values = environ if environ is not None else os.environ
base_url = values.get("HUB_CONTEXTFORGE_URL")
if not base_url:
raise ContextForgeError("gateway_configuration_missing", "Gateway URL is unavailable.")
try:
return cls(
base_url=TypeAdapter(AnyHttpUrl).validate_python(base_url),
auth_token_env=(
values.get("HUB_CONTEXTFORGE_TOKEN_ENV")
if values.get("HUB_CONTEXTFORGE_TOKEN_FILE")
else values.get("HUB_CONTEXTFORGE_TOKEN_ENV", "HUB_CONTEXTFORGE_TOKEN")
),
auth_token_file=(
Path(values["HUB_CONTEXTFORGE_TOKEN_FILE"])
if values.get("HUB_CONTEXTFORGE_TOKEN_FILE")
else None
),
auth_scheme=cast(
Literal["Bearer", "Basic"],
values.get("HUB_CONTEXTFORGE_AUTH_SCHEME", "Bearer"),
),
basic_username=values.get(
"HUB_CONTEXTFORGE_BASIC_USERNAME", "itworx-contextforge-admin"
),
timeout_seconds=float(values.get("HUB_CONTEXTFORGE_TIMEOUT_SECONDS", "5")),
retries=int(values.get("HUB_CONTEXTFORGE_RETRIES", "2")),
)
except (TypeError, ValueError) as exc:
raise ContextForgeError(
"gateway_configuration_invalid", "Gateway configuration is invalid."
) from exc
@model_validator(mode="after")
def exactly_one_credential_source(self) -> ContextForgeSettings:
if bool(self.auth_token_env) == bool(self.auth_token_file):
raise ValueError("configure exactly one ContextForge credential source")
return self
def credential(self, environ: Mapping[str, str] | None = None) -> str:
if self.auth_token_file:
try:
return read_secret_file(self.auth_token_file)
except SecretFileError as exc:
raise ContextForgeError(
"gateway_credentials_missing", "Gateway authorization is unavailable."
) from exc
value = (environ or os.environ).get(self.auth_token_env or "")
if not value:
raise ContextForgeError(
"gateway_credentials_missing", "Gateway authorization is unavailable."
)
return value
class GatewayDesiredState(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
slug: str = Field(
min_length=1, max_length=63, pattern=r"^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$"
)
url: AnyHttpUrl
description: str = Field(default="", max_length=1000)
transport: Literal["STREAMABLEHTTP", "SSE", "STDIO", "WEBSOCKET"] = "STREAMABLEHTTP"
enabled: bool = True
passthrough_headers: tuple[str, ...] = ()
@field_validator("passthrough_headers")
@classmethod
def validate_passthrough_headers(cls, value: tuple[str, ...]) -> tuple[str, ...]:
allowed = {
"x-itworx-project",
"x-itworx-environment",
"x-itworx-client-id",
"x-itworx-trace-id",
"x-itworx-identity-assertion",
}
normalized = tuple(dict.fromkeys(item.strip().lower() for item in value))
if any(not re.fullmatch(r"[a-z0-9-]{1,64}", item) for item in normalized):
raise ValueError("invalid passthrough header")
if not set(normalized).issubset(allowed):
raise ValueError("gateway passthrough header is not approved")
return normalized
class GatewaySnapshot(BaseModel):
"""Publicly safe gateway state; `external_id` is intentionally absent."""
model_config = ConfigDict(extra="forbid", frozen=True)
slug: str
url: str
description: str
transport: str
enabled: bool
status: str
passthrough_headers: tuple[str, ...] = ()
class GatewayHandle(BaseModel):
"""Adapter-internal handle; do not serialize into public Hub contracts."""
model_config = ConfigDict(extra="forbid", frozen=True)
external_id: str = Field(min_length=1, max_length=256)
snapshot: GatewaySnapshot
class GatewayTool(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
external_id: str = Field(min_length=1, max_length=256)
name: str = Field(min_length=1, max_length=256)
published_name: str = Field(min_length=1, max_length=256)
input_schema: dict[str, Any] | None = None
class GatewayPrompt(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
external_id: str = Field(min_length=1, max_length=256)
name: str = Field(min_length=1, max_length=256)
class VirtualServerDesiredState(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
external_id: UUID
name: str = Field(pattern=r"^[a-z0-9][a-z0-9-]{0,62}$")
description: str = Field(max_length=1000)
associated_tool_ids: tuple[str, ...] = Field(min_length=1)
associated_prompt_ids: tuple[str, ...] = ()
# ContextForge's service token is intentionally public-only. The virtual
# server is reachable solely on the private Docker network; the public Hub
# dataplane remains the OAuth, binding, policy and rate-limit boundary.
visibility: Literal["public"] = "public"
@field_validator("associated_tool_ids")
@classmethod
def unique_tool_ids(cls, value: tuple[str, ...]) -> tuple[str, ...]:
if len(set(value)) != len(value) or any(not item or len(item) > 256 for item in value):
raise ValueError("virtual server tool IDs must be unique bounded values")
return tuple(sorted(value))
@field_validator("associated_prompt_ids")
@classmethod
def unique_prompt_ids(cls, value: tuple[str, ...]) -> tuple[str, ...]:
if len(set(value)) != len(value) or any(not item or len(item) > 256 for item in value):
raise ValueError("virtual server prompt IDs must be unique bounded values")
return tuple(sorted(value))
class VirtualServerHandle(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
external_id: str = Field(min_length=1, max_length=256)
name: str = Field(min_length=1, max_length=63)
associated_tool_ids: tuple[str, ...]
associated_prompt_ids: tuple[str, ...] = ()
enabled: bool
visibility: Literal["public", "private", "team"]
class GatewayTestResult(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
reachable: bool
status: str
detail: str = Field(default="", max_length=256)
class GatewayCapabilities(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
health: bool
readiness: bool
version: str | None
gateway_crud: bool
gateway_test: bool
class SanitizedGatewayInventory(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
generated_at: datetime
capabilities: GatewayCapabilities
gateways: tuple[dict[str, Any], ...]
@field_validator("generated_at")
@classmethod
def require_aware_timestamp(cls, value: datetime) -> datetime:
if value.tzinfo is None or value.utcoffset() is None:
raise ValueError("inventory timestamp must be timezone-aware")
return value.astimezone(UTC)