M6: implement ITWorx MCP Hub publication
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).
This commit is contained in:
+12
-1
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
|
||||
from fastapi import Depends, HTTPException, Request, status
|
||||
from fastapi import Depends, Header, HTTPException, Request, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import get_settings
|
||||
@@ -39,3 +39,14 @@ def require_operations_manager(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail="Operations Manager role required"
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
def require_mcp_service_token(
|
||||
x_service_token: str = Header(..., alias="X-Service-Token"),
|
||||
x_client_id: str = Header(default="unknown-mcp-client", alias="X-Client-Id"),
|
||||
) -> str:
|
||||
if x_service_token != settings.mcp_hub_service_token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid service token"
|
||||
)
|
||||
return x_client_id
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
from datetime import date, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_user, get_db
|
||||
@@ -17,10 +17,10 @@ from app.schemas import (
|
||||
AttentionItem,
|
||||
AutomationRunOut,
|
||||
CurrentUser,
|
||||
DashboardMetrics,
|
||||
DashboardOut,
|
||||
TodayItem,
|
||||
)
|
||||
from app.services.operations import compute_metrics
|
||||
|
||||
router = APIRouter(prefix="/api/v1/dashboard", tags=["dashboard"])
|
||||
settings = get_settings()
|
||||
@@ -37,28 +37,7 @@ def get_dashboard(
|
||||
db: Session = Depends(get_db),
|
||||
_user: CurrentUser = Depends(get_current_user),
|
||||
) -> DashboardOut:
|
||||
status_counts = dict(
|
||||
db.execute(
|
||||
select(Vehicle.operational_status, func.count()).group_by(Vehicle.operational_status)
|
||||
).all()
|
||||
)
|
||||
open_issues = db.scalar(
|
||||
select(func.count()).select_from(DataQualityIssue).where(DataQualityIssue.status == "open")
|
||||
)
|
||||
pending_or_failed = db.scalar(
|
||||
select(func.count())
|
||||
.select_from(OutboxEvent)
|
||||
.where(OutboxEvent.delivery_status.in_(["pending", "failed"]))
|
||||
)
|
||||
metrics = DashboardMetrics(
|
||||
available=status_counts.get("available", 0),
|
||||
rented=status_counts.get("rented", 0),
|
||||
cleaning=status_counts.get("cleaning", 0),
|
||||
maintenance=status_counts.get("maintenance", 0),
|
||||
blocked=status_counts.get("blocked", 0),
|
||||
open_quality_issues=open_issues or 0,
|
||||
pending_or_failed_workflows=pending_or_failed or 0,
|
||||
)
|
||||
metrics = compute_metrics(db)
|
||||
|
||||
vehicles_by_id = {v.id: v for v in db.scalars(select(Vehicle)).all()}
|
||||
customers_by_id = {c.id: c for c in db.scalars(select(Customer)).all()}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
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
|
||||
@@ -27,6 +27,7 @@ class Settings(BaseSettings):
|
||||
session_ttl_seconds: int = 60 * 60 * 8
|
||||
seed_dir: str = "/app/seed"
|
||||
knowledge_dir: str = "/app/knowledge/procedures"
|
||||
mcp_hub_service_token: str = "replace-me-mcp-hub-token"
|
||||
cors_allow_origins: str = "http://localhost:1228"
|
||||
demo_today: str = "2026-08-01"
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ from app.api.routers import (
|
||||
demo,
|
||||
integrations,
|
||||
knowledge,
|
||||
mcp_integrations,
|
||||
vehicles,
|
||||
workflows,
|
||||
)
|
||||
@@ -86,3 +87,4 @@ app.include_router(data_quality.router)
|
||||
app.include_router(workflows.router)
|
||||
app.include_router(integrations.router)
|
||||
app.include_router(knowledge.router)
|
||||
app.include_router(mcp_integrations.router)
|
||||
|
||||
@@ -178,6 +178,37 @@ class DashboardOut(BaseModel):
|
||||
recent_automation: list[AutomationRunOut]
|
||||
|
||||
|
||||
class OperationsSummaryOut(BaseModel):
|
||||
tenant: str
|
||||
metrics: DashboardMetrics
|
||||
|
||||
|
||||
class AttentionVehicleOut(BaseModel):
|
||||
vehicle_ref: str
|
||||
severity: Literal["low", "medium", "high"]
|
||||
rule_type: str
|
||||
summary: str
|
||||
detected_at: datetime
|
||||
|
||||
|
||||
class McpVehicleDetailOut(BaseModel):
|
||||
public_ref: str
|
||||
make: str
|
||||
model: str
|
||||
model_year: int
|
||||
location: str
|
||||
operational_status: str
|
||||
odometer_km: int
|
||||
next_service_km: int
|
||||
open_quality_issue_count: int
|
||||
current_booking_ref: str | None
|
||||
|
||||
|
||||
class McpKnowledgeSearchRequest(BaseModel):
|
||||
question: str = Field(min_length=3, max_length=1000)
|
||||
max_sources: int = Field(default=4, ge=1, le=8)
|
||||
|
||||
|
||||
class AuditEventOut(BaseModel):
|
||||
id: str
|
||||
actor_type: str
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.data_quality import DataQualityIssue
|
||||
from app.models.outbox import OutboxEvent
|
||||
from app.models.vehicle import Vehicle
|
||||
from app.schemas import DashboardMetrics
|
||||
|
||||
SEVERITY_ORDER = {"high": 0, "medium": 1, "low": 2}
|
||||
|
||||
|
||||
def compute_metrics(db: Session) -> DashboardMetrics:
|
||||
"""Shared operations-summary computation used by both the dashboard and the MCP
|
||||
provider API, so the two never drift out of sync with two copies of the same query."""
|
||||
status_counts = dict(
|
||||
db.execute(
|
||||
select(Vehicle.operational_status, func.count()).group_by(Vehicle.operational_status)
|
||||
).all()
|
||||
)
|
||||
open_issues = db.scalar(
|
||||
select(func.count()).select_from(DataQualityIssue).where(DataQualityIssue.status == "open")
|
||||
)
|
||||
pending_or_failed = db.scalar(
|
||||
select(func.count())
|
||||
.select_from(OutboxEvent)
|
||||
.where(OutboxEvent.delivery_status.in_(["pending", "failed"]))
|
||||
)
|
||||
return DashboardMetrics(
|
||||
available=status_counts.get("available", 0),
|
||||
rented=status_counts.get("rented", 0),
|
||||
cleaning=status_counts.get("cleaning", 0),
|
||||
maintenance=status_counts.get("maintenance", 0),
|
||||
blocked=status_counts.get("blocked", 0),
|
||||
open_quality_issues=open_issues or 0,
|
||||
pending_or_failed_workflows=pending_or_failed or 0,
|
||||
)
|
||||
|
||||
|
||||
def list_attention_vehicles(
|
||||
db: Session, minimum_severity: str = "medium", on_or_before: date | None = None, limit: int = 20
|
||||
) -> list[dict]:
|
||||
max_rank = SEVERITY_ORDER.get(minimum_severity, 1)
|
||||
stmt = (
|
||||
select(DataQualityIssue)
|
||||
.where(DataQualityIssue.entity_type == "vehicle", DataQualityIssue.status == "open")
|
||||
.order_by(DataQualityIssue.detected_at.asc())
|
||||
)
|
||||
if on_or_before is not None:
|
||||
stmt = stmt.where(func.date(DataQualityIssue.detected_at) <= on_or_before)
|
||||
issues = db.scalars(stmt).all()
|
||||
filtered = [i for i in issues if SEVERITY_ORDER.get(i.severity, 3) <= max_rank]
|
||||
filtered.sort(key=lambda i: SEVERITY_ORDER.get(i.severity, 3))
|
||||
|
||||
vehicle_ids = {i.entity_id for i in filtered}
|
||||
vehicles_by_id = {
|
||||
v.id: v for v in db.scalars(select(Vehicle).where(Vehicle.id.in_(vehicle_ids))).all()
|
||||
}
|
||||
|
||||
results = []
|
||||
for issue in filtered[:limit]:
|
||||
vehicle = vehicles_by_id.get(issue.entity_id)
|
||||
if vehicle is None:
|
||||
continue
|
||||
results.append(
|
||||
{
|
||||
"vehicle_ref": vehicle.public_ref,
|
||||
"severity": issue.severity,
|
||||
"rule_type": issue.rule_type,
|
||||
"summary": issue.evidence_json.get("summary", ""),
|
||||
"detected_at": issue.detected_at,
|
||||
}
|
||||
)
|
||||
return results
|
||||
@@ -0,0 +1,91 @@
|
||||
from app.core.config import get_settings
|
||||
|
||||
|
||||
def _headers(token: str | None = None, client_id: str = "test-mcp-client"):
|
||||
settings = get_settings()
|
||||
return {
|
||||
"X-Service-Token": token if token is not None else settings.mcp_hub_service_token,
|
||||
"X-Client-Id": client_id,
|
||||
}
|
||||
|
||||
|
||||
def test_operations_summary_requires_service_token(client):
|
||||
response = client.get(
|
||||
"/api/v1/integrations/mcp/operations-summary", headers=_headers(token="wrong")
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_operations_summary_returns_metrics(client):
|
||||
response = client.get("/api/v1/integrations/mcp/operations-summary", headers=_headers())
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert "metrics" in body
|
||||
assert body["metrics"]["available"] >= 0
|
||||
|
||||
|
||||
def test_attention_vehicles_filters_by_severity(client):
|
||||
response = client.get(
|
||||
"/api/v1/integrations/mcp/attention-vehicles",
|
||||
params={"minimum_severity": "high", "limit": 50},
|
||||
headers=_headers(),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
rows = response.json()
|
||||
assert all(r["severity"] == "high" for r in rows)
|
||||
|
||||
|
||||
def test_attention_vehicles_respects_limit(client):
|
||||
response = client.get(
|
||||
"/api/v1/integrations/mcp/attention-vehicles",
|
||||
params={"minimum_severity": "low", "limit": 2},
|
||||
headers=_headers(),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert len(response.json()) <= 2
|
||||
|
||||
|
||||
def test_vehicle_details_known_ref(client):
|
||||
response = client.get(
|
||||
"/api/v1/integrations/mcp/vehicles/MO-016", headers=_headers()
|
||||
)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["public_ref"] == "MO-016"
|
||||
assert "registration_number" not in body # narrow read-only view, not the full record
|
||||
|
||||
|
||||
def test_vehicle_details_unknown_ref_is_404(client):
|
||||
response = client.get(
|
||||
"/api/v1/integrations/mcp/vehicles/MO-999", headers=_headers()
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_search_knowledge_grounded_and_respects_max_sources(client):
|
||||
response = client.post(
|
||||
"/api/v1/integrations/mcp/search-knowledge",
|
||||
json={"question": "What must I do when a vehicle returns with damage?", "max_sources": 1},
|
||||
headers=_headers(),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["evidence_state"] == "grounded"
|
||||
assert len(body["sources"]) == 1
|
||||
|
||||
|
||||
def test_mcp_tool_requests_are_audited(client, ops_client):
|
||||
client.get("/api/v1/integrations/mcp/operations-summary", headers=_headers(client_id="probe-1"))
|
||||
events = ops_client.get("/api/v1/audit", params={"action": "mcp_tool_request"}).json()
|
||||
assert len(events) >= 1
|
||||
assert events[0]["actor_type"] == "service"
|
||||
|
||||
|
||||
def test_no_write_endpoints_exist_under_mcp_namespace(client):
|
||||
for method, path in [
|
||||
("post", "/api/v1/integrations/mcp/vehicles/MO-016"),
|
||||
("put", "/api/v1/integrations/mcp/vehicles/MO-016"),
|
||||
("delete", "/api/v1/integrations/mcp/vehicles/MO-016"),
|
||||
]:
|
||||
response = getattr(client, method)(path, headers=_headers())
|
||||
assert response.status_code in (404, 405)
|
||||
Reference in New Issue
Block a user