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: