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:
NuklearRabbit
2026-08-01 23:05:04 +02:00
parent b511ba2dbc
commit c5b7e21f81
11 changed files with 368 additions and 29 deletions
+12 -1
View File
@@ -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 -24
View File
@@ -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()}
+134
View File
@@ -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
+1
View File
@@ -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"
+2
View File
@@ -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)
+31
View File
@@ -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
+77
View File
@@ -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
+91
View File
@@ -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)