Files
ITWorx_MCP_Hub-Public/packages/connector_kit/mobilityops.py
T

357 lines
14 KiB
Python

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