Four read-only, service-token-protected MCP provider endpoints (operations summary, attention vehicles, vehicle details, knowledge search facade). Shared-secret auth reusing the M4 callback pattern. Service-request audit trail for every call. Extracted shared operations-summary logic out of the dashboard router to avoid duplicating retrieval logic. 66 backend tests passing, ruff clean. Verified all four endpoints and audit trail directly via curl against the live stack (no live MCP Hub instance available in this environment).
135 lines
4.6 KiB
Python
135 lines
4.6 KiB
Python
from __future__ import annotations
|
|
|
|
import uuid
|
|
from datetime import date
|
|
|
|
from fastapi import APIRouter, Depends, Query
|
|
from sqlalchemy import select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.api.deps import get_db, require_mcp_service_token
|
|
from app.core.config import get_settings
|
|
from app.core.errors import AppError
|
|
from app.models.booking import Booking
|
|
from app.models.data_quality import DataQualityIssue
|
|
from app.models.vehicle import Vehicle
|
|
from app.schemas import (
|
|
AttentionVehicleOut,
|
|
McpKnowledgeSearchRequest,
|
|
McpVehicleDetailOut,
|
|
OperationsSummaryOut,
|
|
)
|
|
from app.services.audit import record_audit_event
|
|
from app.services.knowledge import GroundedAnswer, get_knowledge_provider
|
|
from app.services.operations import compute_metrics, list_attention_vehicles
|
|
|
|
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:
|
|
record_audit_event(
|
|
db,
|
|
actor_type="service",
|
|
actor_label=client_id,
|
|
action="mcp_tool_request",
|
|
entity_type="mcp_tool",
|
|
correlation_id=uuid.uuid4(),
|
|
metadata={"tool": tool, "status": status_label},
|
|
)
|
|
db.commit()
|
|
|
|
|
|
@router.get("/operations-summary", response_model=OperationsSummaryOut)
|
|
def operations_summary(
|
|
db: Session = Depends(get_db),
|
|
client_id: str = Depends(require_mcp_service_token),
|
|
) -> OperationsSummaryOut:
|
|
metrics = compute_metrics(db)
|
|
_audit_service_request(
|
|
db, client_id=client_id, tool="mobilityops_get_operations_summary", status_label="ok"
|
|
)
|
|
return OperationsSummaryOut(tenant=settings.ragcore_tenant, metrics=metrics)
|
|
|
|
|
|
@router.get("/attention-vehicles", response_model=list[AttentionVehicleOut])
|
|
def attention_vehicles(
|
|
minimum_severity: str = Query(default="medium", pattern="^(low|medium|high)$"),
|
|
date_filter: date | None = Query(default=None, alias="date"),
|
|
limit: int = Query(default=20, ge=1, le=50),
|
|
db: Session = Depends(get_db),
|
|
client_id: str = Depends(require_mcp_service_token),
|
|
) -> 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"
|
|
)
|
|
return [AttentionVehicleOut(**r) for r in results]
|
|
|
|
|
|
@router.get("/vehicles/{vehicle_ref}", response_model=McpVehicleDetailOut)
|
|
def vehicle_details(
|
|
vehicle_ref: str,
|
|
db: Session = Depends(get_db),
|
|
client_id: str = Depends(require_mcp_service_token),
|
|
) -> 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",
|
|
status_label="not_found",
|
|
)
|
|
raise AppError("VEHICLE_NOT_FOUND", "Vehicle not found.", status_code=404)
|
|
|
|
open_issue_count = len(
|
|
db.scalars(
|
|
select(DataQualityIssue.id).where(
|
|
DataQualityIssue.entity_type == "vehicle",
|
|
DataQualityIssue.entity_id == vehicle.id,
|
|
DataQualityIssue.status == "open",
|
|
)
|
|
).all()
|
|
)
|
|
current_booking = db.scalar(
|
|
select(Booking).where(Booking.vehicle_id == vehicle.id, Booking.status == "active")
|
|
)
|
|
|
|
_audit_service_request(
|
|
db, client_id=client_id, tool="mobilityops_get_vehicle_details", status_label="ok"
|
|
)
|
|
return McpVehicleDetailOut(
|
|
public_ref=vehicle.public_ref,
|
|
make=vehicle.make,
|
|
model=vehicle.model,
|
|
model_year=vehicle.model_year,
|
|
location=vehicle.location,
|
|
operational_status=vehicle.operational_status,
|
|
odometer_km=vehicle.odometer_km,
|
|
next_service_km=vehicle.next_service_km,
|
|
open_quality_issue_count=open_issue_count,
|
|
current_booking_ref=current_booking.public_ref if current_booking else None,
|
|
)
|
|
|
|
|
|
@router.post("/search-knowledge", response_model=GroundedAnswer)
|
|
def search_knowledge(
|
|
body: McpKnowledgeSearchRequest,
|
|
db: Session = Depends(get_db),
|
|
client_id: str = Depends(require_mcp_service_token),
|
|
) -> GroundedAnswer:
|
|
provider = get_knowledge_provider()
|
|
correlation_id = str(uuid.uuid4())
|
|
answer = provider.ask(body.question, correlation_id)
|
|
answer.sources = answer.sources[: body.max_sources]
|
|
_audit_service_request(
|
|
db,
|
|
client_id=client_id,
|
|
tool="mobilityops_search_knowledge",
|
|
status_label=answer.evidence_state,
|
|
)
|
|
return answer
|