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:
co-authored by
Claude Sonnet 5
parent
2ae2044e3a
commit
727c19a779
@@ -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
|
||||
|
||||
@@ -39,6 +39,11 @@ class Settings(BaseSettings):
|
||||
knowledge_dir: str = "/app/knowledge/procedures"
|
||||
mcp_hub_service_token: str = "replace-me-mcp-hub-token"
|
||||
mcp_hub_registration_enabled: bool = False
|
||||
# MCP Hub's own registration is catalog-driven on the Hub side (the Hub reconciles
|
||||
# its catalog into the gateway; Fleet Ops never pushes a registration call), so
|
||||
# these are only used for an honest reachability health check, not self-registration.
|
||||
mcp_hub_base_url: str = ""
|
||||
mcp_provider_id: str = "fleet-ops"
|
||||
cors_allow_origins: str = "http://localhost:1228"
|
||||
demo_organization_name: str = "Northstar Mobility"
|
||||
demo_timezone: str = "Europe/Brussels"
|
||||
|
||||
@@ -284,6 +284,7 @@ class McpHubIntegrationStatus(BaseModel):
|
||||
last_tool: str | None = None
|
||||
last_client: str | None = None
|
||||
last_called_at: datetime | None = None
|
||||
hub_reachable: bool | None = None
|
||||
|
||||
|
||||
class IntegrationStatusOut(BaseModel):
|
||||
@@ -403,6 +404,7 @@ class McpVehicleDetailOut(BaseModel):
|
||||
class McpKnowledgeSearchRequest(BaseModel):
|
||||
question: str = Field(min_length=3, max_length=1000)
|
||||
max_sources: int = Field(default=4, ge=1, le=8)
|
||||
locale: Literal["nl-BE", "en-GB", "fr-BE"] = "en-GB"
|
||||
|
||||
|
||||
class AuditEventOut(BaseModel):
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -153,6 +154,8 @@ def derive_mcp_hub_status(db: Session) -> McpHubIntegrationStatus:
|
||||
else:
|
||||
state = "no_evidence"
|
||||
|
||||
hub_reachable = _check_hub_reachable()
|
||||
|
||||
return McpHubIntegrationStatus(
|
||||
registration_enabled=settings.mcp_hub_registration_enabled,
|
||||
state=state,
|
||||
@@ -160,4 +163,18 @@ def derive_mcp_hub_status(db: Session) -> McpHubIntegrationStatus:
|
||||
last_tool=last_tool,
|
||||
last_client=last_client,
|
||||
last_called_at=last_called_at,
|
||||
hub_reachable=hub_reachable,
|
||||
)
|
||||
|
||||
|
||||
def _check_hub_reachable() -> bool | None:
|
||||
"""Real Hub-side health signal (MCP Hub's own registration is catalog-driven on
|
||||
its side, so this is the only thing Fleet Ops itself can honestly check).
|
||||
`None` means not configured / not checked, never a guess."""
|
||||
if not settings.mcp_hub_base_url:
|
||||
return None
|
||||
try:
|
||||
response = httpx.get(f"{settings.mcp_hub_base_url.rstrip('/')}/health", timeout=1.5)
|
||||
return response.status_code == 200
|
||||
except httpx.HTTPError:
|
||||
return False
|
||||
|
||||
Reference in New Issue
Block a user