Initial curated ITWorx_MCP_Hub source from 368fba691ea8d23403b4d56b53b5ec670d7c5020
This commit is contained in:
commit
cb5be898ea
663 files changed
+82674
No files matched your search
@@ -0,0 +1,356 @@
|
||||
"""Typed, read-only MobilityOps (Fleet Ops) API client primitives.
|
||||
|
||||
MobilityOps has exactly one configured target per environment (unlike Gitea's
|
||||
per-project repository registry) -- shape follows the simpler `unraid` broker
|
||||
client pattern, with Gitea's stricter settings/transport validation reused
|
||||
since this, like Gitea, is an external HTTPS origin authenticated by a
|
||||
long-lived shared secret rather than an internally-issued short-lived token.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import json
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
from typing import Any, Protocol, cast
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
import httpx
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
KNOWLEDGE_LOCALES: tuple[str, ...] = ("nl-BE", "en-GB", "fr-BE")
|
||||
"""Locales MobilityOps (Fleet Ops) accepts on `POST /search-knowledge`."""
|
||||
|
||||
DEFAULT_KNOWLEDGE_LOCALE = "en-GB"
|
||||
"""Compatible default for older clients that send no locale at all."""
|
||||
|
||||
CORRELATION_ID = re.compile(r"^[A-Za-z0-9._:-]{1,128}$")
|
||||
|
||||
|
||||
class MobilityOpsError(RuntimeError):
|
||||
"""Stable connector error that never contains credentials or raw headers."""
|
||||
|
||||
def __init__(self, code: str, message: str) -> None:
|
||||
self.code = code
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class MobilityOpsSettings(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
base_url: str = Field(min_length=1, max_length=2048)
|
||||
token_reference: str = Field(min_length=1, max_length=512)
|
||||
timeout_seconds: float = Field(default=15.0, gt=0, le=60)
|
||||
max_response_bytes: int = Field(default=262144, ge=1024, le=4 * 1024 * 1024)
|
||||
allow_insecure_http_for_tests: bool = False
|
||||
|
||||
@field_validator("base_url")
|
||||
@classmethod
|
||||
def validate_base_url(cls, value: str) -> str:
|
||||
parsed = urlparse(value.strip().rstrip("/"))
|
||||
if parsed.scheme not in {"https", "http"} or not parsed.hostname:
|
||||
raise ValueError("MobilityOps base_url must be an absolute HTTP(S) URL")
|
||||
if parsed.username or parsed.password or parsed.query or parsed.fragment:
|
||||
raise ValueError("MobilityOps base_url must not contain credentials or query data")
|
||||
hostname = (parsed.hostname or "").casefold()
|
||||
if hostname in {"localhost", "metadata", "metadata.google.internal"}:
|
||||
raise ValueError("MobilityOps base_url host is not allowed")
|
||||
try:
|
||||
address = ipaddress.ip_address(hostname)
|
||||
except ValueError:
|
||||
address = None
|
||||
if address is not None and (
|
||||
address.is_private
|
||||
or address.is_loopback
|
||||
or address.is_link_local
|
||||
or address.is_reserved
|
||||
or address.is_unspecified
|
||||
or address.is_multicast
|
||||
):
|
||||
raise ValueError("MobilityOps base_url host is not allowed")
|
||||
return value.strip().rstrip("/")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def require_tls(self) -> MobilityOpsSettings:
|
||||
if urlparse(self.base_url).scheme != "https" and not self.allow_insecure_http_for_tests:
|
||||
raise ValueError("MobilityOps base_url must use HTTPS")
|
||||
if not self.token_reference.startswith(("docker-secret://", "/run/secrets/")):
|
||||
raise ValueError("token_reference must name a mounted secret, not contain a token")
|
||||
return self
|
||||
|
||||
|
||||
class TokenProvider(Protocol):
|
||||
def get_token(self, reference: str) -> str: ...
|
||||
|
||||
|
||||
class FileTokenProvider:
|
||||
"""Read-only provider for Docker secrets mounted beneath /run/secrets."""
|
||||
|
||||
def get_token(self, reference: str) -> str:
|
||||
if reference.startswith("docker-secret://"):
|
||||
name = reference.removeprefix("docker-secret://")
|
||||
path = Path("/run/secrets") / name
|
||||
else:
|
||||
path = Path(reference)
|
||||
root = Path("/run/secrets").resolve()
|
||||
try:
|
||||
resolved = path.resolve()
|
||||
resolved.relative_to(root)
|
||||
except (OSError, ValueError) as exc:
|
||||
raise MobilityOpsError(
|
||||
"MOBILITYOPS_SECRET_REFERENCE_INVALID", "secret reference is outside /run/secrets"
|
||||
) from exc
|
||||
try:
|
||||
token = resolved.read_text(encoding="utf-8").strip()
|
||||
except OSError as exc:
|
||||
raise MobilityOpsError(
|
||||
"MOBILITYOPS_SECRET_UNAVAILABLE", "configured MobilityOps secret is unavailable"
|
||||
) from exc
|
||||
if not token or len(token) > 4096 or any(ord(char) < 0x20 for char in token):
|
||||
raise MobilityOpsError(
|
||||
"MOBILITYOPS_SECRET_INVALID", "configured MobilityOps secret is invalid"
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
class StaticTokenProvider:
|
||||
"""Deterministic fake-only provider; never serializes the token."""
|
||||
|
||||
def __init__(self, token: str) -> None:
|
||||
self._token = token
|
||||
|
||||
def get_token(self, reference: str) -> str:
|
||||
if not reference.startswith(("docker-secret://", "/run/secrets/")):
|
||||
raise MobilityOpsError(
|
||||
"MOBILITYOPS_SECRET_REFERENCE_INVALID", "invalid secret reference"
|
||||
)
|
||||
return self._token
|
||||
|
||||
|
||||
class MobilityOpsClient:
|
||||
"""Small MobilityOps API client with same-origin redirect enforcement.
|
||||
|
||||
Auth is a shared-secret `X-Service-Token` header (MobilityOps's own
|
||||
`require_mcp_service_token` dependency), not a per-repo bearer token --
|
||||
there is exactly one configured MobilityOps target per environment.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
settings: MobilityOpsSettings,
|
||||
token_provider: TokenProvider,
|
||||
*,
|
||||
transport: httpx.BaseTransport | None = None,
|
||||
) -> None:
|
||||
self.settings = settings
|
||||
self._token_provider = token_provider
|
||||
self._client = httpx.Client(
|
||||
base_url=f"{settings.base_url}/api/v1/integrations/mcp",
|
||||
timeout=settings.timeout_seconds,
|
||||
follow_redirects=False,
|
||||
transport=transport,
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
self._client.close()
|
||||
|
||||
def _request(
|
||||
self,
|
||||
client_id: str,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
params: Mapping[str, str | int] | None = None,
|
||||
json_body: Mapping[str, Any] | None = None,
|
||||
correlation_id: str | None = None,
|
||||
timeout: float | None = None,
|
||||
) -> Any:
|
||||
token = self._token_provider.get_token(self.settings.token_reference)
|
||||
headers = {
|
||||
"X-Service-Token": token,
|
||||
"X-Client-Id": client_id,
|
||||
"Accept": "application/json",
|
||||
}
|
||||
if correlation_id is not None:
|
||||
# The Hub already has a trace id for this invocation; MobilityOps preserves
|
||||
# whatever valid correlation id it receives, so never mint a second one here.
|
||||
if not CORRELATION_ID.fullmatch(correlation_id):
|
||||
raise MobilityOpsError(
|
||||
"MOBILITYOPS_CORRELATION_ID_INVALID",
|
||||
"inbound correlation id is not a safe header value",
|
||||
)
|
||||
headers["X-Correlation-Id"] = correlation_id
|
||||
request_timeout = self.settings.timeout_seconds if timeout is None else timeout
|
||||
try:
|
||||
response = self._client.request(
|
||||
method,
|
||||
path,
|
||||
params=params,
|
||||
json=json_body,
|
||||
headers=headers,
|
||||
timeout=request_timeout,
|
||||
)
|
||||
if 300 <= response.status_code < 400:
|
||||
location = response.headers.get("location")
|
||||
if not location:
|
||||
raise MobilityOpsError(
|
||||
"MOBILITYOPS_REDIRECT_DENIED",
|
||||
"MobilityOps redirect did not provide a location",
|
||||
)
|
||||
target = urlparse(urljoin(str(response.url), location))
|
||||
base = urlparse(self.settings.base_url)
|
||||
if (target.scheme, target.hostname, target.port) != (
|
||||
base.scheme,
|
||||
base.hostname,
|
||||
base.port,
|
||||
):
|
||||
raise MobilityOpsError(
|
||||
"MOBILITYOPS_REDIRECT_DENIED",
|
||||
"MobilityOps redirect leaves the configured origin",
|
||||
)
|
||||
response = self._client.request(
|
||||
method, target.geturl(), headers=headers, timeout=request_timeout
|
||||
)
|
||||
if 300 <= response.status_code < 400:
|
||||
raise MobilityOpsError(
|
||||
"MOBILITYOPS_REDIRECT_DENIED", "MobilityOps returned multiple redirects"
|
||||
)
|
||||
except MobilityOpsError:
|
||||
raise
|
||||
except (httpx.TimeoutException, httpx.NetworkError) as exc:
|
||||
raise MobilityOpsError(
|
||||
"MOBILITYOPS_UNAVAILABLE", "MobilityOps target is unavailable"
|
||||
) from exc
|
||||
if len(response.content) > self.settings.max_response_bytes:
|
||||
raise MobilityOpsError(
|
||||
"MOBILITYOPS_RESPONSE_TOO_LARGE",
|
||||
"MobilityOps response exceeds the connector budget",
|
||||
)
|
||||
if response.status_code == 404:
|
||||
raise MobilityOpsError("MOBILITYOPS_NOT_FOUND", "MobilityOps resource was not found")
|
||||
if response.status_code in {401, 403}:
|
||||
raise MobilityOpsError(
|
||||
"MOBILITYOPS_ACCESS_DENIED", "MobilityOps denied the read request"
|
||||
)
|
||||
if response.status_code == 422:
|
||||
raise MobilityOpsError(
|
||||
"MOBILITYOPS_REQUEST_INVALID", "MobilityOps rejected the request shape"
|
||||
)
|
||||
if response.status_code >= 400:
|
||||
raise MobilityOpsError(
|
||||
"MOBILITYOPS_REQUEST_FAILED", "MobilityOps returned an unsuccessful response"
|
||||
)
|
||||
try:
|
||||
return response.json()
|
||||
except (ValueError, json.JSONDecodeError) as exc:
|
||||
raise MobilityOpsError(
|
||||
"MOBILITYOPS_INVALID_RESPONSE", "MobilityOps returned invalid JSON"
|
||||
) from exc
|
||||
|
||||
def operations_summary(
|
||||
self, client_id: str, *, correlation_id: str | None = None
|
||||
) -> dict[str, Any]:
|
||||
return cast(
|
||||
dict[str, Any],
|
||||
self._request(client_id, "GET", "/operations-summary", correlation_id=correlation_id),
|
||||
)
|
||||
|
||||
READINESS_TIMEOUT_SECONDS = 5.0
|
||||
|
||||
def readiness_probe(self, client_id: str, *, correlation_id: str | None = None) -> None:
|
||||
"""Short, bounded, authenticated read used as the connector's readiness signal.
|
||||
|
||||
MobilityOps exposes no authenticated readiness route on this origin -- its only
|
||||
health route (`GET /health`) is unauthenticated and lives outside the
|
||||
`/api/v1/integrations/mcp` prefix this client is bound to -- so the narrowest
|
||||
authenticated read, `operations-summary`, is the readiness probe instead.
|
||||
Raises `MobilityOpsError` with a stable code; never leaks the token or a raw
|
||||
transport exception.
|
||||
"""
|
||||
payload = self._request(
|
||||
client_id,
|
||||
"GET",
|
||||
"/operations-summary",
|
||||
correlation_id=correlation_id,
|
||||
timeout=min(self.settings.timeout_seconds, self.READINESS_TIMEOUT_SECONDS),
|
||||
)
|
||||
if not isinstance(payload, dict) or not isinstance(payload.get("metrics"), dict):
|
||||
raise MobilityOpsError(
|
||||
"MOBILITYOPS_INVALID_RESPONSE",
|
||||
"MobilityOps readiness response did not carry operational metrics",
|
||||
)
|
||||
|
||||
def attention_vehicles(
|
||||
self,
|
||||
client_id: str,
|
||||
*,
|
||||
minimum_severity: str,
|
||||
date: str | None,
|
||||
limit: int,
|
||||
correlation_id: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
params: dict[str, str | int] = {"minimum_severity": minimum_severity, "limit": limit}
|
||||
if date:
|
||||
params["date"] = date
|
||||
value = self._request(
|
||||
client_id,
|
||||
"GET",
|
||||
"/attention-vehicles",
|
||||
params=params,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
if not isinstance(value, list) or not all(isinstance(item, dict) for item in value):
|
||||
raise MobilityOpsError(
|
||||
"MOBILITYOPS_INVALID_RESPONSE", "MobilityOps returned an unexpected collection"
|
||||
)
|
||||
return value
|
||||
|
||||
_VEHICLE_REF = re.compile(r"^[A-Za-z0-9][A-Za-z0-9-]{0,63}$")
|
||||
|
||||
def vehicle_details(
|
||||
self, client_id: str, vehicle_ref: str, *, correlation_id: str | None = None
|
||||
) -> dict[str, Any]:
|
||||
if not self._VEHICLE_REF.fullmatch(vehicle_ref):
|
||||
raise MobilityOpsError(
|
||||
"MOBILITYOPS_VEHICLE_REF_INVALID", "vehicle reference is invalid"
|
||||
)
|
||||
return cast(
|
||||
dict[str, Any],
|
||||
self._request(
|
||||
client_id,
|
||||
"GET",
|
||||
f"/vehicles/{vehicle_ref}",
|
||||
correlation_id=correlation_id,
|
||||
),
|
||||
)
|
||||
|
||||
def search_knowledge(
|
||||
self,
|
||||
client_id: str,
|
||||
question: str,
|
||||
*,
|
||||
max_sources: int,
|
||||
locale: str = DEFAULT_KNOWLEDGE_LOCALE,
|
||||
correlation_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if locale not in KNOWLEDGE_LOCALES:
|
||||
raise MobilityOpsError(
|
||||
"MOBILITYOPS_LOCALE_INVALID",
|
||||
"locale must be one of nl-BE, en-GB, fr-BE",
|
||||
)
|
||||
return cast(
|
||||
dict[str, Any],
|
||||
self._request(
|
||||
client_id,
|
||||
"POST",
|
||||
"/search-knowledge",
|
||||
json_body={
|
||||
"question": question,
|
||||
"max_sources": max_sources,
|
||||
"locale": locale,
|
||||
},
|
||||
correlation_id=correlation_id,
|
||||
),
|
||||
)
|
||||
Reference in new issue
Block a user