M9: MCP Hub locale/correlation propagation, real Hub health check, fix stale test image

Fixed two concrete gaps in the MCP knowledge-search endpoint: no locale field
existed at all (now nl-BE/en-GB/fr-BE, wired to the knowledge provider's
existing language param), and the correlation ID was always freshly minted,
ignoring any inbound X-Correlation-Id header. Added a shared dependency and
applied it to all four MCP endpoints so Fleet Ops's own audit log preserves
the Hub's real correlation ID end to end.

MCP_HUB_BASE_URL/MCP_PROVIDER_ID were declared in .env.example but never read
anywhere. Since the Hub's own registration is catalog-driven (it never needs
Fleet Ops to push a registration call), wired mcp_hub_base_url for a real Hub
reachability health check instead of an unneeded self-registration call.

Renamed Fleet Ops's own internal audit tool labels mobilityops_* -> fleet_ops_*
(mirrored in contracts/mcp-tools.json with mobilityops_* kept as deprecated
aliases); documented that the live Hub connector's own dotted tool namespace
is a separate, Hub-owned naming layer, deliberately not touched.

Automation page's MCP card now shows real evidence (last tool/client/count/
timestamp, honest no-evidence state) instead of just the registration flag.

Also fixed a real methodology gap found mid-session: compose.yaml's api
service has no bind mount, so `docker compose run --rm api` silently tests a
stale image until rebuilt. Re-ran every local gate after rebuilding; fixed one
genuinely stale test assertion and two lint line-length errors surfaced by
that rebuild. 176 tests passing, ruff clean, mypy clean (50 files).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
NuklearRabbit
2026-08-05 13:30:24 +02:00
co-authored by Claude Sonnet 5
parent 2ae2044e3a
commit 727c19a779
17 changed files with 269 additions and 45 deletions
+43 -10
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import uuid
from datetime import date
from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Depends, Header, Query
from sqlalchemy import select
from sqlalchemy.orm import Session
@@ -27,14 +27,30 @@ router = APIRouter(prefix="/api/v1/integrations/mcp", tags=["mcp"])
settings = get_settings()
def _audit_service_request(db: Session, *, client_id: str, tool: str, status_label: str) -> None:
def get_correlation_id(
x_correlation_id: str | None = Header(default=None, alias="X-Correlation-Id"),
) -> str:
"""Preserve the Hub's own inbound correlation ID through MCP client -> Hub -> Fleet
Ops -> RAGcore -> Fleet Ops Audit; only mint a fresh one when none was supplied or
it isn't a valid UUID (per the task's own correlation-propagation contract)."""
if x_correlation_id:
try:
return str(uuid.UUID(x_correlation_id))
except ValueError:
pass
return str(uuid.uuid4())
def _audit_service_request(
db: Session, *, client_id: str, tool: str, status_label: str, correlation_id: str
) -> None:
record_audit_event(
db,
actor_type="service",
actor_label=client_id,
action="mcp_tool_request",
entity_type="mcp_tool",
correlation_id=uuid.uuid4(),
correlation_id=uuid.UUID(correlation_id),
metadata={"tool": tool, "status": status_label},
)
db.commit()
@@ -44,10 +60,15 @@ def _audit_service_request(db: Session, *, client_id: str, tool: str, status_lab
def operations_summary(
db: Session = Depends(get_db),
client_id: str = Depends(require_mcp_service_token),
correlation_id: str = Depends(get_correlation_id),
) -> OperationsSummaryOut:
metrics = compute_metrics(db)
_audit_service_request(
db, client_id=client_id, tool="mobilityops_get_operations_summary", status_label="ok"
db,
client_id=client_id,
tool="fleet_ops_get_operations_summary",
status_label="ok",
correlation_id=correlation_id,
)
return OperationsSummaryOut(tenant=settings.ragcore_tenant, metrics=metrics)
@@ -59,12 +80,17 @@ def attention_vehicles(
limit: int = Query(default=20, ge=1, le=50),
db: Session = Depends(get_db),
client_id: str = Depends(require_mcp_service_token),
correlation_id: str = Depends(get_correlation_id),
) -> list[AttentionVehicleOut]:
results = list_attention_vehicles(
db, minimum_severity=minimum_severity, on_or_before=date_filter, limit=limit
)
_audit_service_request(
db, client_id=client_id, tool="mobilityops_list_attention_vehicles", status_label="ok"
db,
client_id=client_id,
tool="fleet_ops_list_attention_vehicles",
status_label="ok",
correlation_id=correlation_id,
)
return [AttentionVehicleOut(**r) for r in results]
@@ -74,14 +100,16 @@ def vehicle_details(
vehicle_ref: str,
db: Session = Depends(get_db),
client_id: str = Depends(require_mcp_service_token),
correlation_id: str = Depends(get_correlation_id),
) -> McpVehicleDetailOut:
vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == vehicle_ref))
if vehicle is None:
_audit_service_request(
db,
client_id=client_id,
tool="mobilityops_get_vehicle_details",
tool="fleet_ops_get_vehicle_details",
status_label="not_found",
correlation_id=correlation_id,
)
raise AppError("VEHICLE_NOT_FOUND", "Vehicle not found.", status_code=404)
@@ -99,7 +127,11 @@ def vehicle_details(
)
_audit_service_request(
db, client_id=client_id, tool="mobilityops_get_vehicle_details", status_label="ok"
db,
client_id=client_id,
tool="fleet_ops_get_vehicle_details",
status_label="ok",
correlation_id=correlation_id,
)
return McpVehicleDetailOut(
public_ref=vehicle.public_ref,
@@ -120,15 +152,16 @@ def search_knowledge(
body: McpKnowledgeSearchRequest,
db: Session = Depends(get_db),
client_id: str = Depends(require_mcp_service_token),
correlation_id: str = Depends(get_correlation_id),
) -> GroundedAnswer:
provider = get_knowledge_provider()
correlation_id = str(uuid.uuid4())
answer = provider.ask(body.question, correlation_id)
answer = provider.ask(body.question, correlation_id, language=body.locale)
answer.sources = answer.sources[: body.max_sources]
_audit_service_request(
db,
client_id=client_id,
tool="mobilityops_search_knowledge",
tool="fleet_ops_search_knowledge",
status_label=answer.evidence_state,
correlation_id=correlation_id,
)
return answer