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
+1 -1
View File
@@ -31,5 +31,5 @@ RAGCORE_API_TOKEN=
# ITWorx MCP Hub integration
MCP_HUB_REGISTRATION_ENABLED=false
MCP_HUB_BASE_URL=http://itworx-mcp-hub:8000
MCP_HUB_SERVICE_TOKEN=
MCP_HUB_SERVICE_TOKEN=replace-me-mcp-hub-token
MCP_PROVIDER_ID=mobilityops
+15 -3
View File
@@ -2,7 +2,7 @@
## Current milestone
M5 — complete. Starting M6 next.
M6 — complete. Starting M7 next.
## Locked decisions
@@ -108,10 +108,22 @@ M5 — complete. Starting M6 next.
- `npm run build` — clean.
- Full browser run of S6 end-to-end: asked "What must I do when a vehicle returns with damage?" on `/knowledge` → grounded answer citing "Vehicle return procedure" (2 sections) and "Damage handling procedure" with real excerpts. Also asked an unrelated question ("What is the weather forecast for tomorrow?") → correctly returned "Insufficient evidence" / "No matching procedure was found" with zero sources, confirming no fabrication.
### M6 — ITWorx MCP Hub publication
- `app/api/routers/mcp_integrations.py`: four read-only endpoints under `/api/v1/integrations/mcp/``GET operations-summary`, `GET attention-vehicles` (query params `minimum_severity`/`date`/`limit` matching `contracts/mcp-tools.json`'s `inputSchema` exactly), `GET vehicles/{vehicle_ref}`, `POST search-knowledge` (the "narrow façade" the doc calls for — wraps M5's `get_knowledge_provider()` rather than re-implementing retrieval; the contract's tool has no MobilityOps `endpoint` field, only `routing.preferred: ragcore`, so this façade path is MobilityOps's own addition for when the Hub needs a single provider boundary, not literally specified by the contract).
- Auth: new `require_mcp_service_token` dependency in `app/api/deps.py`, same shared-secret-header shape as the M4 n8n callback (`X-Service-Token` against `MCP_HUB_SERVICE_TOKEN`) plus an optional `X-Client-Id` header (defaults to `"unknown-mcp-client"`) used as the audit actor label — the Hub's actual client-identity header name is unknown (no live Hub to confirm against), so this is a reasonable guess documented here rather than assumed silently.
- `McpVehicleDetailOut` deliberately omits `registration_number` and all customer data — narrower than the browser-facing `VehicleOut`/`VehicleDetailOut`, matching "no customer or vehicle database access" and the read-only/summary intent of an AI-facing tool. Test `test_vehicle_details_known_ref` asserts the field's absence explicitly so a future change can't silently widen the exposed surface.
- Extracted `app/services/operations.py` (`compute_metrics`, `list_attention_vehicles`) out of `app/api/routers/dashboard.py` so the MCP operations-summary/attention-vehicles endpoints and the human dashboard share one query implementation instead of two copies that could drift — the same "do not duplicate retrieval logic" principle the doc states for the knowledge tool, applied here to the operational-summary tools too.
- Every provider call writes an `AuditEvent` (`actor_type="service"`, `actor_label=X-Client-Id`, `action="mcp_tool_request"`, `metadata={tool, status}`) — MobilityOps's own record that its provider APIs were reached, independent of whatever central tool-call audit the Hub itself keeps (per `docs/10-mcp-hub-integration.md`'s audit section, the Hub owns the central log; this is the local corroborating one).
- No write/mutation endpoints exist under the `/api/v1/integrations/mcp/` namespace at all (verified by `test_no_write_endpoints_exist_under_mcp_namespace` — POST/PUT/DELETE against the vehicle-details path all 404/405) — return registration, customer merge, and any booking/vehicle mutation are correctly absent, per the doc's explicit restriction list.
- Commands run and verified from this checkout:
- `docker compose run --rm api pytest -q`**66 passed** (new `tests/test_mcp_integrations.py`: token-required, wrong-token 401, all four tools' happy paths, severity/limit filtering, 404 for unknown vehicle, `max_sources` respected, audit actor/action verified, write-method rejection).
- `docker compose run --rm api ruff check .` — All checks passed.
- Live `curl` verification against the running stack (no browser needed — these are service-to-service endpoints, not UI): missing header → 422; wrong token → 401; correct token → all four endpoints return correct data (`operations-summary` metrics match the dashboard; `attention-vehicles?minimum_severity=high` returned `MO-016`×2 and `MO-031`, all severity `high`; `vehicles/MO-016` returned the narrow read-only shape; `search-knowledge` with `max_sources=2` returned exactly 2 grounded sources for the S6 question). Confirmed via `GET /api/v1/audit?action=mcp_tool_request` that all four calls were recorded with correct `actor_type=service`, tool name, and status.
## Known blockers
None. External service credentials may be absent; use the documented demo/degraded providers. The n8n workflow-activation steps from M4 are a one-time manual setup requirement in this environment, not a blocker — but not yet scripted; M7 should either automate it (e.g. a bootstrap script CI/compose can run) or document it clearly enough for `docs/14-testing-and-acceptance.md`'s clean-checkout criteria. RAGcore itself was never reachable this session — `RAGcoreKnowledgeProvider` is implemented and unit-tested for its unavailable-degradation path but its actual request/response contract against a real RAGcore instance is unverified; the demo provider is what M5's acceptance criteria are actually satisfied by.
None. External service credentials may be absent; use the documented demo/degraded providers. The n8n workflow-activation steps from M4 are a one-time manual setup requirement in this environment, not a blocker — but not yet scripted; M7 should either automate it (e.g. a bootstrap script CI/compose can run) or document it clearly enough for `docs/14-testing-and-acceptance.md`'s clean-checkout criteria. RAGcore and the ITWorx MCP Hub itself were never reachable this session — `RAGcoreKnowledgeProvider` and the M6 MCP provider endpoints are implemented and directly tested/curl-verified against MobilityOps's own API, but neither a real RAGcore instance nor a real Hub round trip was available to confirm end-to-end; M5's demo provider and M6's direct endpoint tests are what actually satisfy those milestones' acceptance criteria in this environment.
## Exact next action
Start M6 (ITWorx MCP Hub publication): read `docs/10-mcp-hub-integration.md`, `contracts/mcp-tools.json`. Implement the four read-only, service-token-protected provider endpoints under `/api/v1/integrations/mcp/` (`operations-summary`, `attention-vehicles`, `vehicles/{public_ref}`, and a knowledge-search façade wrapping the M5 `KnowledgeProvider` — per `contracts/mcp-tools.json`'s `mobilityops_search_knowledge` tool and `docs/10-mcp-hub-integration.md`'s note to route through a narrow façade rather than duplicate retrieval logic). Auth: reuse the same shared-secret-header pattern already built for the n8n callback in M4 (`X-Service-Token`, a new `MCP_HUB_SERVICE_TOKEN` setting — `.env.example` already has the env var name reserved) rather than inventing a second auth mechanism. Must not expose generic SQL, arbitrary fetch, write/mutation actions, or secrets — these are read-only summaries only. Add MobilityOps-side service-request audit events (the Hub owns its own central tool-call audit; MobilityOps only needs to record that its provider APIs were reached, with tool name/correlation ID/client identity/result status). No actual ITWorx MCP Hub instance is confirmed reachable in this environment (same situation as RAGcore in M5) — validate the four provider endpoints directly via authenticated `curl`/tests rather than a live Hub round trip, and note that gap explicitly rather than claiming an unverified integration works.
Start M7 (portfolio polish and final acceptance): read `docs/06-ui-ux.md`, `docs/14-testing-and-acceptance.md`, `docs/16-portfolio-case-study.md`. Work through `docs/14-testing-and-acceptance.md`'s clean-checkout acceptance list item by item from a genuinely fresh checkout (new clone, `cp .env.example .env`, documented bootstrap commands only) rather than this session's already-running/already-seeded stack — several manual one-time steps accumulated this session (n8n owner setup + workflow import/publish/restart, see M4 notes) that must either be scripted into the bootstrap or written up precisely in `docs/17-runbook.md`, since a true clean checkout won't have them done yet. Also needed: Playwright end-to-end test covering the full documented 5-minute demo script (login → dashboard → S1 return → S2 merge → S4 overlap already-visible → S6 knowledge question → audit → responsive nav at 360px — the ≤360px layout was flagged as visually unverified back in M1 and should finally be checked here), screenshots of the seven main pages, an architecture diagram, `artifacts/evidence/final-summary.md` per the doc's exact required contents (commit/tag, commands, test counts — currently 66 backend tests across M0M6 — screenshots, RAGcore success/unavailable evidence, n8n success/retry evidence, MCP sample calls, known limitations, truthful portfolio wording), and a final pass removing any rough edges/placeholders across the UI.
+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)
+1
View File
@@ -34,6 +34,7 @@ services:
RAGCORE_API_TOKEN: ${RAGCORE_API_TOKEN:-}
N8N_WEBHOOK_URL: ${N8N_WEBHOOK_URL:-http://n8n:5678/webhook/mobilityops-return}
N8N_CALLBACK_TOKEN: ${MOBILITYOPS_CALLBACK_TOKEN:-replace-me-n8n-callback-token}
MCP_HUB_SERVICE_TOKEN: ${MCP_HUB_SERVICE_TOKEN:-replace-me-mcp-hub-token}
ports:
- "8128:8000"
depends_on: