From c5b7e21f81694f0339ad31e3bf044db952d0fbe0 Mon Sep 17 00:00:00 2001 From: NuklearRabbit <145918611+NuklearRabbit@users.noreply.github.com> Date: Sat, 1 Aug 2026 23:05:04 +0200 Subject: [PATCH] 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). --- .env.example | 2 +- PROJECT_STATE.md | 18 ++- backend/app/api/deps.py | 13 +- backend/app/api/routers/dashboard.py | 27 +--- backend/app/api/routers/mcp_integrations.py | 134 ++++++++++++++++++++ backend/app/core/config.py | 1 + backend/app/main.py | 2 + backend/app/schemas.py | 31 +++++ backend/app/services/operations.py | 77 +++++++++++ backend/tests/test_mcp_integrations.py | 91 +++++++++++++ compose.yaml | 1 + 11 files changed, 368 insertions(+), 29 deletions(-) create mode 100644 backend/app/api/routers/mcp_integrations.py create mode 100644 backend/app/services/operations.py create mode 100644 backend/tests/test_mcp_integrations.py diff --git a/.env.example b/.env.example index 4be61a6..01f0a1b 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 06c26b8..c5e85ae 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -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 M0–M6 — 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. diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index c969ef3..81c844d 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -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 diff --git a/backend/app/api/routers/dashboard.py b/backend/app/api/routers/dashboard.py index 6153e64..694eecb 100644 --- a/backend/app/api/routers/dashboard.py +++ b/backend/app/api/routers/dashboard.py @@ -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()} diff --git a/backend/app/api/routers/mcp_integrations.py b/backend/app/api/routers/mcp_integrations.py new file mode 100644 index 0000000..56f66b5 --- /dev/null +++ b/backend/app/api/routers/mcp_integrations.py @@ -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 diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 7982219..ab21ae1 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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" diff --git a/backend/app/main.py b/backend/app/main.py index 9413299..7e11572 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 5a252cf..828109c 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -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 diff --git a/backend/app/services/operations.py b/backend/app/services/operations.py new file mode 100644 index 0000000..dd59ac5 --- /dev/null +++ b/backend/app/services/operations.py @@ -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 diff --git a/backend/tests/test_mcp_integrations.py b/backend/tests/test_mcp_integrations.py new file mode 100644 index 0000000..75b033c --- /dev/null +++ b/backend/tests/test_mcp_integrations.py @@ -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) diff --git a/compose.yaml b/compose.yaml index 934169f..3306969 100644 --- a/compose.yaml +++ b/compose.yaml @@ -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: