Files
ITWorx_MCP_Hub-Public/connectors/mobilityops/server.py
T

319 lines
12 KiB
Python

"""Read-only MCP connector for the MobilityOps (Fleet Ops) operations API."""
from __future__ import annotations
from collections.abc import Callable
from datetime import UTC, datetime
from typing import Any
from packages.connector_kit import (
DEFAULT_KNOWLEDGE_LOCALE,
KNOWLEDGE_LOCALES,
ConnectorContext,
MobilityOpsClient,
MobilityOpsError,
ResourcePublication,
ToolPublication,
safe_snippet,
)
from packages.hub_policy import Governance, governed_tool
class MobilityOpsConnector:
"""Expose MobilityOps's four read-only operational tools.
Unlike Gitea, there is exactly one configured MobilityOps target per
environment -- no per-project repository registry, every call is
authorized purely by the caller's project/environment context via the
governance layer, matching MobilityOps's own narrow read-only API surface
(`docs/10-mcp-hub-integration.md` in the MobilityOps repository).
"""
connector_type = "mobilityops"
version = "1.0.0"
#: Identifies readiness probes in MobilityOps's own service-request audit log.
READINESS_CLIENT_ID = "itworx-mcp-hub:readiness"
def __init__(self, client: MobilityOpsClient, governance: Governance | None = None) -> None:
self.client = client
self.governance = governance
def _envelope(
self,
context: ConnectorContext,
data: Any,
*,
source: str = "mobilityops",
truncated: bool = False,
warnings: tuple[str, ...] = (),
) -> dict[str, Any]:
return {
"data": data,
"meta": {
"project": context.project_slug,
"environment": context.environment,
"source": source,
"truncated": truncated,
"nextCursor": None,
"generatedAt": datetime.now(UTC).isoformat(),
},
"warnings": list(warnings),
}
@staticmethod
def _client_id(context: ConnectorContext) -> str:
return f"itworx-mcp-hub:{context.project_slug}:{context.client_id or 'anonymous'}"
@staticmethod
def _optional_text(value: Any, limit: int) -> str | None:
"""Pass through exactly what MobilityOps returned, or nothing at all.
Never substitutes a placeholder: an absent `provider`/`correlationId` is
reported as `null` rather than invented.
"""
if value is None:
return None
text = str(value).strip()
return text[:limit] if text else None
@governed_tool("mobilityops.operations.summary")
def operations_summary(self, context: ConnectorContext) -> dict[str, Any]:
payload = self.client.operations_summary(
self._client_id(context), correlation_id=context.correlation_id
)
raw_metrics = payload.get("metrics")
metrics: dict[str, Any] = raw_metrics if isinstance(raw_metrics, dict) else {}
data = {
"tenant": safe_snippet(str(payload.get("tenant", "")), max_bytes=200),
"metrics": {
"available": int(metrics.get("available", 0)),
"rented": int(metrics.get("rented", 0)),
"cleaning": int(metrics.get("cleaning", 0)),
"maintenance": int(metrics.get("maintenance", 0)),
"blocked": int(metrics.get("blocked", 0)),
"openQualityIssues": int(metrics.get("open_quality_issues", 0)),
"pendingOrFailedWorkflows": int(metrics.get("pending_or_failed_workflows", 0)),
},
}
return self._envelope(context, data)
@governed_tool("mobilityops.attention.list")
def attention_list(
self,
context: ConnectorContext,
*,
minimum_severity: str = "medium",
date: str | None = None,
limit: int = 20,
) -> dict[str, Any]:
if minimum_severity not in {"low", "medium", "high"}:
raise ValueError("minimum_severity must be one of low, medium, high")
if not 1 <= limit <= 50:
raise ValueError("limit must be between 1 and 50")
items = self.client.attention_vehicles(
self._client_id(context),
minimum_severity=minimum_severity,
date=date,
limit=limit,
correlation_id=context.correlation_id,
)
mapped = [
{
"vehicleRef": str(item.get("vehicle_ref", ""))[:64],
"severity": str(item.get("severity", ""))[:32],
"ruleType": str(item.get("rule_type", ""))[:120],
"summary": safe_snippet(str(item.get("summary", "")), max_bytes=1000),
"detectedAt": str(item.get("detected_at", ""))[:64],
}
for item in items
]
data = {"items": mapped, "nextCursor": None, "truncated": False}
return self._envelope(context, data, truncated=False)
@governed_tool("mobilityops.vehicle.get")
def vehicle_get(self, context: ConnectorContext, vehicle_ref: str) -> dict[str, Any]:
payload = self.client.vehicle_details(
self._client_id(context), vehicle_ref, correlation_id=context.correlation_id
)
data = {
"publicRef": str(payload.get("public_ref", ""))[:64],
"make": str(payload.get("make", ""))[:120],
"model": str(payload.get("model", ""))[:120],
"modelYear": int(payload.get("model_year", 0)),
"location": str(payload.get("location", ""))[:200],
"operationalStatus": str(payload.get("operational_status", ""))[:64],
"odometerKm": int(payload.get("odometer_km", 0)),
"nextServiceKm": int(payload.get("next_service_km", 0)),
"openQualityIssueCount": int(payload.get("open_quality_issue_count", 0)),
"currentBookingRef": (
str(payload["current_booking_ref"])[:64]
if payload.get("current_booking_ref")
else None
),
}
return self._envelope(context, data, source=f"mobilityops:{vehicle_ref}")
@governed_tool("mobilityops.knowledge.search")
def knowledge_search(
self,
context: ConnectorContext,
question: str,
*,
max_sources: int = 4,
locale: str = DEFAULT_KNOWLEDGE_LOCALE,
) -> dict[str, Any]:
if not 3 <= len(question) <= 1000:
raise ValueError("question is outside the connector budget")
if not 1 <= max_sources <= 8:
raise ValueError("max_sources must be between 1 and 8")
if locale not in KNOWLEDGE_LOCALES:
raise ValueError("locale must be one of nl-BE, en-GB, fr-BE")
payload = self.client.search_knowledge(
self._client_id(context),
question,
max_sources=max_sources,
locale=locale,
correlation_id=context.correlation_id,
)
raw_sources = payload.get("sources")
sources: list[Any] = raw_sources if isinstance(raw_sources, list) else []
data = {
"answer": safe_snippet(str(payload.get("answer", "")), max_bytes=4000),
"evidenceState": str(payload.get("evidence_state", "unavailable"))[:32],
"provider": self._optional_text(payload.get("provider"), 120),
"correlationId": self._optional_text(payload.get("correlation_id"), 128),
"sources": [
{
"documentId": str(source.get("document_id", ""))[:200],
"title": str(source.get("title", ""))[:300],
"version": str(source.get("version", ""))[:120],
"section": str(source.get("section", ""))[:300],
"excerpt": safe_snippet(str(source.get("excerpt", "")), max_bytes=1500),
}
for source in sources
if isinstance(source, dict)
],
}
return self._envelope(context, data, source="mobilityops:knowledge")
def tools(self) -> tuple[ToolPublication, ...]:
return MOBILITYOPS_TOOLS
def resources(self) -> tuple[ResourcePublication, ...]:
return ()
def invoke(self, tool: str, context: ConnectorContext, **arguments: Any) -> dict[str, Any]:
methods: dict[str, Callable[..., dict[str, Any]]] = {
"mobilityops.operations.summary": self.operations_summary,
"mobilityops.attention.list": self.attention_list,
"mobilityops.vehicle.get": self.vehicle_get,
"mobilityops.knowledge.search": self.knowledge_search,
}
method = methods.get(tool)
if method is None:
raise ValueError("unknown mobilityops tool")
return method(context, **arguments)
def health(self) -> dict[str, Any]:
"""Liveness only: this process answers, independent of the MobilityOps upstream."""
return {"status": "healthy", "component": "mobilityops-connector", "readOnly": True}
#: Upstream faults that mean MobilityOps answered but this connector cannot use it.
_DEGRADED_CODES = frozenset(
{
"MOBILITYOPS_ACCESS_DENIED",
"MOBILITYOPS_REQUEST_INVALID",
"MOBILITYOPS_INVALID_RESPONSE",
"MOBILITYOPS_RESPONSE_TOO_LARGE",
"MOBILITYOPS_REDIRECT_DENIED",
"MOBILITYOPS_SECRET_UNAVAILABLE",
"MOBILITYOPS_SECRET_INVALID",
"MOBILITYOPS_SECRET_REFERENCE_INVALID",
}
)
def readiness(self) -> dict[str, Any]:
"""Ready only when the real MobilityOps upstream actually answers.
Uses one short, bounded, authenticated upstream read. Only the connector's own
stable error code is reported -- never a raw exception, response body, header or
secret.
"""
snapshot: dict[str, Any] = {
"component": "mobilityops-connector",
"readOnly": True,
"upstream": "mobilityops",
}
try:
self.client.readiness_probe(self.READINESS_CLIENT_ID)
except MobilityOpsError as exc:
snapshot["status"] = "degraded" if exc.code in self._DEGRADED_CODES else "unavailable"
snapshot["reason"] = exc.code
return snapshot
except Exception: # noqa: BLE001 - readiness must never surface a raw exception.
snapshot["status"] = "unavailable"
snapshot["reason"] = "MOBILITYOPS_READINESS_FAILED"
return snapshot
snapshot["status"] = "ready"
return snapshot
_EMPTY_INPUT = {"type": "object", "additionalProperties": False, "properties": {}}
_ENVELOPE_OUTPUT = {"type": "object", "additionalProperties": False}
_OPERATIONS_SUMMARY_INPUT = _EMPTY_INPUT
_ATTENTION_LIST_INPUT = {
"type": "object",
"additionalProperties": False,
"properties": {
"minimum_severity": {"type": "string", "enum": ["low", "medium", "high"]},
"date": {"type": ["string", "null"], "format": "date"},
"limit": {"type": "integer", "minimum": 1, "maximum": 50},
},
}
_VEHICLE_GET_INPUT = {
"type": "object",
"additionalProperties": False,
"properties": {"vehicleRef": {"type": "string", "minLength": 1, "maxLength": 64}},
"required": ["vehicleRef"],
}
_KNOWLEDGE_SEARCH_INPUT = {
"type": "object",
"additionalProperties": False,
"properties": {
"question": {"type": "string", "minLength": 3, "maxLength": 1000},
"max_sources": {"type": "integer", "minimum": 1, "maximum": 8},
"locale": {
"type": "string",
"enum": list(KNOWLEDGE_LOCALES),
"default": DEFAULT_KNOWLEDGE_LOCALE,
},
},
"required": ["question"],
}
_INPUT_SCHEMAS: dict[str, dict[str, Any]] = {
"mobilityops.operations.summary": _OPERATIONS_SUMMARY_INPUT,
"mobilityops.attention.list": _ATTENTION_LIST_INPUT,
"mobilityops.vehicle.get": _VEHICLE_GET_INPUT,
"mobilityops.knowledge.search": _KNOWLEDGE_SEARCH_INPUT,
}
MOBILITYOPS_TOOLS = tuple(
ToolPublication(
canonical_name=name,
version="1.0.0",
connector_type="mobilityops",
risk_class="R1",
input_schema=_INPUT_SCHEMAS[name],
output_schema=_ENVELOPE_OUTPUT,
required_scopes=("project:{project}:read",),
)
for name in (
"mobilityops.operations.summary",
"mobilityops.attention.list",
"mobilityops.vehicle.get",
"mobilityops.knowledge.search",
)
)