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