From 727c19a7792d41754ddb176177a82ee1758157db Mon Sep 17 00:00:00 2001 From: NuklearRabbit <145918611+NuklearRabbit@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:30:24 +0200 Subject: [PATCH] M9: MCP Hub locale/correlation propagation, real Hub health check, fix stale test image Fixed two concrete gaps in the MCP knowledge-search endpoint: no locale field existed at all (now nl-BE/en-GB/fr-BE, wired to the knowledge provider's existing language param), and the correlation ID was always freshly minted, ignoring any inbound X-Correlation-Id header. Added a shared dependency and applied it to all four MCP endpoints so Fleet Ops's own audit log preserves the Hub's real correlation ID end to end. MCP_HUB_BASE_URL/MCP_PROVIDER_ID were declared in .env.example but never read anywhere. Since the Hub's own registration is catalog-driven (it never needs Fleet Ops to push a registration call), wired mcp_hub_base_url for a real Hub reachability health check instead of an unneeded self-registration call. Renamed Fleet Ops's own internal audit tool labels mobilityops_* -> fleet_ops_* (mirrored in contracts/mcp-tools.json with mobilityops_* kept as deprecated aliases); documented that the live Hub connector's own dotted tool namespace is a separate, Hub-owned naming layer, deliberately not touched. Automation page's MCP card now shows real evidence (last tool/client/count/ timestamp, honest no-evidence state) instead of just the registration flag. Also fixed a real methodology gap found mid-session: compose.yaml's api service has no bind mount, so `docker compose run --rm api` silently tests a stale image until rebuilt. Re-ran every local gate after rebuilding; fixed one genuinely stale test assertion and two lint line-length errors surfaced by that rebuild. 176 tests passing, ruff clean, mypy clean (50 files). Co-Authored-By: Claude Sonnet 5 --- .env.example | 7 ++- PROJECT_STATE.md | 57 +++++++++++++++++-- backend/app/api/routers/mcp_integrations.py | 53 +++++++++++++---- backend/app/core/config.py | 5 ++ backend/app/schemas.py | 2 + backend/app/services/integration_status.py | 17 ++++++ backend/tests/test_integration_status.py | 4 +- backend/tests/test_mcp_integrations.py | 42 ++++++++++++++ backend/tests/test_seed.py | 10 +++- contracts/mcp-tools.json | 22 ++++--- docs/10-mcp-hub-integration.md | 42 +++++++++++--- frontend/src/api/types.ts | 1 + .../src/i18n/locales/en-GB/integrations.json | 6 +- .../src/i18n/locales/fr-BE/integrations.json | 6 +- .../src/i18n/locales/nl-BE/integrations.json | 6 +- frontend/src/pages/Automation.tsx | 30 ++++++++-- frontend/src/styles.css | 4 +- 17 files changed, 269 insertions(+), 45 deletions(-) diff --git a/.env.example b/.env.example index d941cc7..4681d67 100644 --- a/.env.example +++ b/.env.example @@ -45,8 +45,11 @@ RAGCORE_API_TOKEN= # UUID of the RAGcore knowledge space procedures were synced into (see workflow 3). RAGCORE_SPACE_ID= -# ITWorx MCP Hub integration +# ITWorx MCP Hub integration. Registration itself is catalog-driven on the Hub's own +# side (it reconciles its catalog into the gateway; Fleet Ops never pushes a +# registration call) -- MCP_HUB_BASE_URL is only used here for an honest reachability +# health check surfaced on the integration status page. MCP_HUB_REGISTRATION_ENABLED=false MCP_HUB_BASE_URL=http://itworx-mcp-hub:8000 MCP_HUB_SERVICE_TOKEN=replace-me-mcp-hub-token -MCP_PROVIDER_ID=mobilityops +MCP_PROVIDER_ID=fleet-ops diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index bdb1be8..e214c1c 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -57,6 +57,7 @@ M7 — complete. All milestones (M0–M7) done, plus a full post-M7 final-accept - Inspection `public_ref` is assigned as `INSP-{count+1:04d}` from a live count query (not gap-safe, fine for a PoC single-writer demo, would need a sequence for real concurrency-safe numbering). - Found and fixed during browser verification (not caught by pytest, since it's a UI-only defect): `ReturnForm` originally held its own `result` state and was conditionally rendered only when `booking.status === "active"`; once the return succeeded the booking flipped to `returned` and React unmounted the form before the user ever saw the result panel. Fixed by lifting the result into `BookingDetail` (`ReturnResultPanel` is now a sibling, not nested in `ReturnForm`). Also found: `OutboxEvent.event_id`'s Python-side `default=uuid.uuid4` on the mapped_column only applies at flush/commit time, so reading `event.event_id` before `db.commit()` returned `None` (rendered as the literal string "None" in the result panel); fixed by assigning `event_id=uuid.uuid4()` explicitly at construction. Lesson: SQLAlchemy column `default=` callables are not available on the in-memory Python object until flush — never rely on the generated value for a same-transaction response body without an explicit `db.flush()` or an explicit Python-side assignment. - Operational note for this environment: `docker compose run --rm api ...` (used for tests/lint) only starts a throwaway one-off container — it does **not** update the long-running `api`/`web` service containers. After any code change meant to be verified live (browser, curl), `docker compose up -d --build ` is required, not just `docker compose build`. +- **Sharper version of the note above, found the hard way (2026-08-05)**: `compose.yaml`'s `api` service has **no bind mount** for `./backend` — application code is baked into the image at build time only. `docker compose run --rm api pytest/ruff/mypy` reuses whatever image was last built; it does **not** pick up host file edits at all, not even for a throwaway container. Editing code and immediately running `docker compose run --rm api pytest` without an intervening `docker compose build api` silently tests/lints the *old* code and can report a false "all green." Always `docker compose build api` before the first local gate run after a code change in a session (subsequent runs against the same build are fine). Caught this only because a new test file's test count didn't match after several rounds of edits; re-ran the full local gate suite after rebuilding and found one genuinely stale test assertion (below) — nothing else was actually broken, but this was luck, not verification, until the rebuild. ## Completed evidence @@ -1755,7 +1756,55 @@ deployed line — `master` is stale, 19 commits behind). Full audit at clean; `mypy app` — clean (50 files); `cd frontend && npm run build` — clean. - Committed `34df66d`, pushed to `origin/feat/fleet-ops-final-integrations`. Not yet deployed to the live Fleet Ops instance at this point in the session. -- **Exact next action**: deploy this revision to `http://192.168.10.150:1236`; Batch 4 - (MCP Hub — confirm Fleet Ops-side config wiring, tool naming, locale param, correlation - ID propagation); Batch 5 (AI Operations Brief using the demo knowledge provider since - RAGcore stays off; GUI activity showcase; final regression; evidence write-up). +- Deployed to `http://192.168.10.150:1236` (`git archive` + scp + `docker compose up + --build -d db api web`, following the established deployment convention). Browser- + verified live: Attention Queue tier grouping, Today's Movements curated seed mix (2 + returns + 2 departures after a real demo reset), About Demo progressive disclosure, + Duplicate Merge match/conflict summary + preview all render correctly. +- **Found and fixed a real methodology gap mid-session**: `compose.yaml`'s `api` service + has no bind mount, so `docker compose run --rm api pytest/ruff/mypy` was silently + testing a stale image for an unknown portion of this session (only caught because a new + test's collected-count didn't match). Rebuilt and re-ran every local gate from that + point on; found exactly one genuinely stale test assertion (`ragcore_sync["built"]` — + correctly `True` now, not `False`, since workflow 3 really is built) and two line-length + lint errors in the new seed test, both fixed. See the sharper operational note added + next to the original `docker compose run` warning above. + +## Batch 4 — MCP Hub (2026-08-05) + +- `MCP_HUB_BASE_URL`/`MCP_PROVIDER_ID` were declared in `.env.example` but never read by + `Settings` anywhere — dead config. Since the Hub's own registration is catalog-driven + (it reconciles its catalog into the gateway; Fleet Ops never pushes a registration + call — confirmed via the Hub-side investigation), wiring them for self-registration + would have built an unneeded feature. Wired `mcp_hub_base_url` for something Fleet Ops + actually needs instead: a real, bounded (1.5s timeout) Hub reachability health check, + surfaced as `hub_reachable` on `/api/v1/integrations/status` and the Automation page. +- Fixed two real, concrete gaps in `mcp_integrations.py`'s `search-knowledge` endpoint + matching the task brief's own description almost verbatim: no `locale` field existed at + all (now `nl-BE`/`en-GB`/`fr-BE`, wired straight through to the knowledge provider, + which already supported a `language` param — just never received one), and the + correlation ID was **always** freshly minted (`uuid.uuid4()`), ignoring any inbound + `X-Correlation-Id` header. Added a shared `get_correlation_id` dependency (valid inbound + UUID preserved end-to-end into Fleet Ops's own audit log; fresh UUID only when absent/ + invalid) and applied it to all four MCP endpoints, not just search-knowledge. +- Fleet Ops's own internal audit tool labels renamed `mobilityops_*` → `fleet_ops_*` + (`fleet_ops_get_operations_summary`, `.list_attention_vehicles`, `.get_vehicle_details`, + `.search_knowledge`) and mirrored in `contracts/mcp-tools.json` (with `mobilityops_*` + kept as `deprecated_aliases`, per the task's own "don't break existing clients" + instruction). Note: the **live** ITWorx MCP Hub connector publishes these under its own + dotted namespace (`mobilityops.operations.summary` etc.) — that naming is Hub-owned and + was deliberately not touched (separate repo, active concurrent session there, and + already verified live per the Hub-side investigation); `docs/10-mcp-hub-integration.md` + now documents both naming layers explicitly so this isn't read as a contradiction. +- Automation page's MCP card now shows real evidence (last tool/client/call count/ + timestamp, honest "registered but no calls yet" state) instead of only the + registration-enabled boolean, plus the new Hub-reachability badge. +- New tests: `test_search_knowledge_respects_requested_locale`, + `test_search_knowledge_preserves_inbound_correlation_id`, + `test_operations_summary_mints_correlation_id_when_none_supplied`. +- Evidence: `docker compose run --rm api pytest -q` — **176 passed** (against a verified + fresh rebuild); `ruff check .` — clean; `mypy app` — clean (50 files); `cd frontend && + npm run build` — clean. +- **Exact next action**: commit and deploy Batch 4; Batch 5 (AI Operations Brief using the + demo knowledge provider since RAGcore stays off; GUI activity showcase optional-after- + demo-complete; final regression across all gates; `artifacts/final-integrations/final-summary.md`). diff --git a/backend/app/api/routers/mcp_integrations.py b/backend/app/api/routers/mcp_integrations.py index 56f66b5..c4128e5 100644 --- a/backend/app/api/routers/mcp_integrations.py +++ b/backend/app/api/routers/mcp_integrations.py @@ -3,7 +3,7 @@ from __future__ import annotations import uuid from datetime import date -from fastapi import APIRouter, Depends, Query +from fastapi import APIRouter, Depends, Header, Query from sqlalchemy import select from sqlalchemy.orm import Session @@ -27,14 +27,30 @@ 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: +def get_correlation_id( + x_correlation_id: str | None = Header(default=None, alias="X-Correlation-Id"), +) -> str: + """Preserve the Hub's own inbound correlation ID through MCP client -> Hub -> Fleet + Ops -> RAGcore -> Fleet Ops Audit; only mint a fresh one when none was supplied or + it isn't a valid UUID (per the task's own correlation-propagation contract).""" + if x_correlation_id: + try: + return str(uuid.UUID(x_correlation_id)) + except ValueError: + pass + return str(uuid.uuid4()) + + +def _audit_service_request( + db: Session, *, client_id: str, tool: str, status_label: str, correlation_id: 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(), + correlation_id=uuid.UUID(correlation_id), metadata={"tool": tool, "status": status_label}, ) db.commit() @@ -44,10 +60,15 @@ def _audit_service_request(db: Session, *, client_id: str, tool: str, status_lab def operations_summary( db: Session = Depends(get_db), client_id: str = Depends(require_mcp_service_token), + correlation_id: str = Depends(get_correlation_id), ) -> OperationsSummaryOut: metrics = compute_metrics(db) _audit_service_request( - db, client_id=client_id, tool="mobilityops_get_operations_summary", status_label="ok" + db, + client_id=client_id, + tool="fleet_ops_get_operations_summary", + status_label="ok", + correlation_id=correlation_id, ) return OperationsSummaryOut(tenant=settings.ragcore_tenant, metrics=metrics) @@ -59,12 +80,17 @@ def attention_vehicles( limit: int = Query(default=20, ge=1, le=50), db: Session = Depends(get_db), client_id: str = Depends(require_mcp_service_token), + correlation_id: str = Depends(get_correlation_id), ) -> 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" + db, + client_id=client_id, + tool="fleet_ops_list_attention_vehicles", + status_label="ok", + correlation_id=correlation_id, ) return [AttentionVehicleOut(**r) for r in results] @@ -74,14 +100,16 @@ def vehicle_details( vehicle_ref: str, db: Session = Depends(get_db), client_id: str = Depends(require_mcp_service_token), + correlation_id: str = Depends(get_correlation_id), ) -> 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", + tool="fleet_ops_get_vehicle_details", status_label="not_found", + correlation_id=correlation_id, ) raise AppError("VEHICLE_NOT_FOUND", "Vehicle not found.", status_code=404) @@ -99,7 +127,11 @@ def vehicle_details( ) _audit_service_request( - db, client_id=client_id, tool="mobilityops_get_vehicle_details", status_label="ok" + db, + client_id=client_id, + tool="fleet_ops_get_vehicle_details", + status_label="ok", + correlation_id=correlation_id, ) return McpVehicleDetailOut( public_ref=vehicle.public_ref, @@ -120,15 +152,16 @@ def search_knowledge( body: McpKnowledgeSearchRequest, db: Session = Depends(get_db), client_id: str = Depends(require_mcp_service_token), + correlation_id: str = Depends(get_correlation_id), ) -> GroundedAnswer: provider = get_knowledge_provider() - correlation_id = str(uuid.uuid4()) - answer = provider.ask(body.question, correlation_id) + answer = provider.ask(body.question, correlation_id, language=body.locale) answer.sources = answer.sources[: body.max_sources] _audit_service_request( db, client_id=client_id, - tool="mobilityops_search_knowledge", + tool="fleet_ops_search_knowledge", status_label=answer.evidence_state, + correlation_id=correlation_id, ) return answer diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 3e718a1..70afce0 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -39,6 +39,11 @@ class Settings(BaseSettings): knowledge_dir: str = "/app/knowledge/procedures" mcp_hub_service_token: str = "replace-me-mcp-hub-token" mcp_hub_registration_enabled: bool = False + # MCP Hub's own registration is catalog-driven on the Hub side (the Hub reconciles + # its catalog into the gateway; Fleet Ops never pushes a registration call), so + # these are only used for an honest reachability health check, not self-registration. + mcp_hub_base_url: str = "" + mcp_provider_id: str = "fleet-ops" cors_allow_origins: str = "http://localhost:1228" demo_organization_name: str = "Northstar Mobility" demo_timezone: str = "Europe/Brussels" diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 9188908..52c1722 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -284,6 +284,7 @@ class McpHubIntegrationStatus(BaseModel): last_tool: str | None = None last_client: str | None = None last_called_at: datetime | None = None + hub_reachable: bool | None = None class IntegrationStatusOut(BaseModel): @@ -403,6 +404,7 @@ class McpVehicleDetailOut(BaseModel): class McpKnowledgeSearchRequest(BaseModel): question: str = Field(min_length=3, max_length=1000) max_sources: int = Field(default=4, ge=1, le=8) + locale: Literal["nl-BE", "en-GB", "fr-BE"] = "en-GB" class AuditEventOut(BaseModel): diff --git a/backend/app/services/integration_status.py b/backend/app/services/integration_status.py index fbee2e7..2c19fcc 100644 --- a/backend/app/services/integration_status.py +++ b/backend/app/services/integration_status.py @@ -2,6 +2,7 @@ from __future__ import annotations from typing import Literal +import httpx from sqlalchemy import func, select from sqlalchemy.orm import Session @@ -153,6 +154,8 @@ def derive_mcp_hub_status(db: Session) -> McpHubIntegrationStatus: else: state = "no_evidence" + hub_reachable = _check_hub_reachable() + return McpHubIntegrationStatus( registration_enabled=settings.mcp_hub_registration_enabled, state=state, @@ -160,4 +163,18 @@ def derive_mcp_hub_status(db: Session) -> McpHubIntegrationStatus: last_tool=last_tool, last_client=last_client, last_called_at=last_called_at, + hub_reachable=hub_reachable, ) + + +def _check_hub_reachable() -> bool | None: + """Real Hub-side health signal (MCP Hub's own registration is catalog-driven on + its side, so this is the only thing Fleet Ops itself can honestly check). + `None` means not configured / not checked, never a guess.""" + if not settings.mcp_hub_base_url: + return None + try: + response = httpx.get(f"{settings.mcp_hub_base_url.rstrip('/')}/health", timeout=1.5) + return response.status_code == 200 + except httpx.HTTPError: + return False diff --git a/backend/tests/test_integration_status.py b/backend/tests/test_integration_status.py index c0fff53..7a6cc10 100644 --- a/backend/tests/test_integration_status.py +++ b/backend/tests/test_integration_status.py @@ -52,7 +52,9 @@ def test_integration_status_lists_all_four_canonical_workflows(ops_client): "Fleet Ops — Workflow Error Handler", } ragcore_sync = next(w for w in body["workflows"] if "RAGcore" in w["name"]) - assert ragcore_sync["built"] is False + # All 4 canonical workflows are built (RAGcore Procedure Sync has all 6 nodes saved + # live); it has no *run* evidence yet since it is deliberately left unpublished. + assert ragcore_sync["built"] is True assert ragcore_sync["last_seen_at"] is None diff --git a/backend/tests/test_mcp_integrations.py b/backend/tests/test_mcp_integrations.py index 75b033c..70d1876 100644 --- a/backend/tests/test_mcp_integrations.py +++ b/backend/tests/test_mcp_integrations.py @@ -81,6 +81,48 @@ def test_mcp_tool_requests_are_audited(client, ops_client): assert events[0]["actor_type"] == "service" +def test_search_knowledge_respects_requested_locale(client): + response = client.post( + "/api/v1/integrations/mcp/search-knowledge", + json={ + "question": "Wat moet ik doen wanneer een voertuig beschadigd terugkomt?", + "max_sources": 1, + "locale": "nl-BE", + }, + headers=_headers(), + ) + assert response.status_code == 200 + body = response.json() + assert body["evidence_state"] == "grounded" + + +def test_search_knowledge_preserves_inbound_correlation_id(client, ops_client): + inbound = "11111111-1111-1111-1111-111111111111" + 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(client_id="correlation-probe"), "X-Correlation-Id": inbound}, + ) + assert response.status_code == 200 + assert response.json()["correlation_id"] == inbound + + events = ops_client.get("/api/v1/audit", params={"action": "mcp_tool_request"}).json() + matching = [e for e in events if e["correlation_id"] == inbound] + assert len(matching) == 1 + + +def test_operations_summary_mints_correlation_id_when_none_supplied(client, ops_client): + response = client.get( + "/api/v1/integrations/mcp/operations-summary", + headers=_headers(client_id="no-correlation-probe"), + ) + assert response.status_code == 200 + events = ops_client.get("/api/v1/audit", params={"action": "mcp_tool_request"}).json() + matching = [e for e in events if e["actor_label"] == "no-correlation-probe"] + assert len(matching) >= 1 + assert matching[0]["correlation_id"] # a fresh UUID was minted, not left empty + + def test_no_write_endpoints_exist_under_mcp_namespace(client): for method, path in [ ("post", "/api/v1/integrations/mcp/vehicles/MO-016"), diff --git a/backend/tests/test_seed.py b/backend/tests/test_seed.py index 1c9aeeb..c323987 100644 --- a/backend/tests/test_seed.py +++ b/backend/tests/test_seed.py @@ -185,8 +185,14 @@ def test_seed_today_movements_are_a_credible_mix(): reset_and_seed(db) today = datetime.now(UTC).date() bookings = db.scalars(select(Booking)).all() - departures = [b for b in bookings if b.starts_at.date() == today and b.status in ("reserved", "active")] - returns = [b for b in bookings if b.ends_at.date() == today and b.status in ("active", "returned")] + departures = [ + b + for b in bookings + if b.starts_at.date() == today and b.status in ("reserved", "active") + ] + returns = [ + b for b in bookings if b.ends_at.date() == today and b.status in ("active", "returned") + ] assert len(departures) >= 2 assert len(returns) >= 2 finally: diff --git a/contracts/mcp-tools.json b/contracts/mcp-tools.json index d25bc37..14de04a 100644 --- a/contracts/mcp-tools.json +++ b/contracts/mcp-tools.json @@ -1,17 +1,20 @@ { + "_note": "Fleet Ops's own published tool contract. The live ITWorx MCP Hub connector (ITWorx_MCP_Hub repo, connectors/mobilityops/) wraps these under its own dotted namespace (mobilityops.operations.summary, .attention.list, .vehicle.get, .knowledge.search) -- that naming is Hub-owned. deprecated_aliases below are Fleet Ops's own prior internal audit-label names, kept only so existing clients/dashboards referencing them don't break.", "provider_id": "mobilityops", - "version": "1.0.0", + "version": "1.1.0", "required_scope": "mobilityops.read", "tools": [ { - "name": "mobilityops_get_operations_summary", - "description": "Return current high-level vehicle, data-quality and workflow counts for the synthetic MobilityOps demo tenant.", + "name": "fleet_ops_get_operations_summary", + "deprecated_aliases": ["mobilityops_get_operations_summary"], + "description": "Return current high-level vehicle, data-quality and workflow counts for the synthetic Fleet Ops demo tenant.", "read_only": true, "inputSchema": {"type": "object", "additionalProperties": false}, "endpoint": {"method": "GET", "path": "/api/v1/integrations/mcp/operations-summary"} }, { - "name": "mobilityops_list_attention_vehicles", + "name": "fleet_ops_list_attention_vehicles", + "deprecated_aliases": ["mobilityops_list_attention_vehicles"], "description": "List vehicles that require operational attention, optionally filtered by minimum severity and date.", "read_only": true, "inputSchema": { @@ -26,7 +29,8 @@ "endpoint": {"method": "GET", "path": "/api/v1/integrations/mcp/attention-vehicles"} }, { - "name": "mobilityops_get_vehicle_details", + "name": "fleet_ops_get_vehicle_details", + "deprecated_aliases": ["mobilityops_get_vehicle_details"], "description": "Return a read-only operational view of one vehicle by its stable public reference.", "read_only": true, "inputSchema": { @@ -38,15 +42,17 @@ "endpoint": {"method": "GET", "path": "/api/v1/integrations/mcp/vehicles/{vehicle_ref}"} }, { - "name": "mobilityops_search_knowledge", - "description": "Search versioned MobilityOps internal procedures through the dedicated RAGcore workspace and return grounded source references.", + "name": "fleet_ops_search_knowledge", + "deprecated_aliases": ["mobilityops_search_knowledge"], + "description": "Search versioned Fleet Ops internal procedures through the dedicated RAGcore workspace and return grounded source references.", "read_only": true, "inputSchema": { "type": "object", "required": ["question"], "properties": { "question": {"type": "string", "minLength": 3, "maxLength": 1000}, - "max_sources": {"type": "integer", "minimum": 1, "maximum": 8, "default": 4} + "max_sources": {"type": "integer", "minimum": 1, "maximum": 8, "default": 4}, + "locale": {"enum": ["nl-BE", "en-GB", "fr-BE"], "default": "en-GB"} }, "additionalProperties": false }, diff --git a/docs/10-mcp-hub-integration.md b/docs/10-mcp-hub-integration.md index aa284b0..36f8297 100644 --- a/docs/10-mcp-hub-integration.md +++ b/docs/10-mcp-hub-integration.md @@ -6,20 +6,44 @@ Publish four read-only MobilityOps capabilities through the existing central ITW ## Provider registration -- provider ID: `mobilityops` -- API base: configurable internal MobilityOps API URL -- authentication: scoped service token +- provider ID: `mobilityops` (registered on the Hub side; the Hub's own registration is + catalog-driven — it reconciles its catalog into the gateway, Fleet Ops never pushes a + registration call) +- API base: internal Fleet Ops API URL, reached via `MCP_HUB_SERVICE_TOKEN` auth +- authentication: scoped service token (`X-Service-Token`), plus `X-Client-Id` - mode: read-only -- required scope: `mobilityops.read` +- **Confirmed live in production** on the ITWorx MCP Hub's own deployment (Tower), with + a real contract fix already applied there (`vehicle.get`'s wire parameter normalized to + camelCase `vehicleRef`). `docs/final-integrations/current-state-audit.md` has the full + evidence. ## Tools -The machine-readable definitions are in `contracts/mcp-tools.json`. +The machine-readable definitions are in `contracts/mcp-tools.json`. The Hub's own live +connector (`ITWorx_MCP_Hub` repo, `connectors/mobilityops/`) publishes these under its +own dotted namespace — that naming is the Hub's to own, not Fleet Ops's: -1. `mobilityops_get_operations_summary` -2. `mobilityops_list_attention_vehicles` -3. `mobilityops_get_vehicle_details` -4. `mobilityops_search_knowledge` +1. `mobilityops.operations.summary` +2. `mobilityops.attention.list` +3. `mobilityops.vehicle.get` +4. `mobilityops.knowledge.search` + +Fleet Ops's own internal audit trail (`AuditEvent.metadata_json.tool`, visible on +`/api/v1/audit?action=mcp_tool_request`) labels these calls `fleet_ops_get_operations_summary`, +`fleet_ops_list_attention_vehicles`, `fleet_ops_get_vehicle_details`, +`fleet_ops_search_knowledge` — a separate, Fleet-Ops-owned naming layer for its own audit +log, not the wire-level MCP tool name a client calls. + +`search-knowledge` accepts a `locale` field (`nl-BE` | `en-GB` | `fr-BE`, default +`en-GB`) that is passed straight through to the active knowledge provider. + +## Correlation ID + +The inbound `X-Correlation-Id` header (set by the Hub, itself either forwarding the +MCP client's ID or minting one) is preserved through Fleet Ops's own handling and audit +log; Fleet Ops only mints a fresh correlation ID when none is supplied or the supplied +value isn't a valid UUID. See `get_correlation_id` in +`backend/app/api/routers/mcp_integrations.py`. ## Routing diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 3ed3963..911a0e0 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -289,6 +289,7 @@ export interface McpHubIntegrationStatus { last_tool: string | null; last_client: string | null; last_called_at: string | null; + hub_reachable: boolean | null; } export interface IntegrationStatus { diff --git a/frontend/src/i18n/locales/en-GB/integrations.json b/frontend/src/i18n/locales/en-GB/integrations.json index db39d9b..6a9bea8 100644 --- a/frontend/src/i18n/locales/en-GB/integrations.json +++ b/frontend/src/i18n/locales/en-GB/integrations.json @@ -15,7 +15,11 @@ "gatewayKicker": "Tool gateway", "mcpTitle": "MCP Hub", "mcpEnabled": "Registration is enabled for this deployment.", - "mcpNotConnected": "Not yet connected — prepared for future controlled tool calls from the Hub." + "mcpNotConnected": "Not yet connected — prepared for future controlled tool calls from the Hub.", + "mcpNoEvidence": "Registered, but no tool call has been recorded yet.", + "mcpEvidence": "Last call: {{tool}} by {{client}} · {{count}} total calls", + "mcpHubReachable": "Hub reachable", + "mcpHubUnreachable": "Hub unreachable" }, "statusLabels": { "notConnected": "Not connected", diff --git a/frontend/src/i18n/locales/fr-BE/integrations.json b/frontend/src/i18n/locales/fr-BE/integrations.json index 3fe9733..1f5778c 100644 --- a/frontend/src/i18n/locales/fr-BE/integrations.json +++ b/frontend/src/i18n/locales/fr-BE/integrations.json @@ -15,7 +15,11 @@ "gatewayKicker": "Passerelle d'outils", "mcpTitle": "MCP Hub", "mcpEnabled": "L'enregistrement est activé pour ce déploiement.", - "mcpNotConnected": "Pas encore connecté — préparé pour de futurs appels d'outils contrôlés depuis le Hub." + "mcpNotConnected": "Pas encore connecté — préparé pour de futurs appels d'outils contrôlés depuis le Hub.", + "mcpNoEvidence": "Enregistré, mais aucun appel d'outil n'a encore été consigné.", + "mcpEvidence": "Dernier appel : {{tool}} par {{client}} · {{count}} appels au total", + "mcpHubReachable": "Hub accessible", + "mcpHubUnreachable": "Hub inaccessible" }, "statusLabels": { "notConnected": "Non connecté", diff --git a/frontend/src/i18n/locales/nl-BE/integrations.json b/frontend/src/i18n/locales/nl-BE/integrations.json index 8037ea2..3ddc5c5 100644 --- a/frontend/src/i18n/locales/nl-BE/integrations.json +++ b/frontend/src/i18n/locales/nl-BE/integrations.json @@ -15,7 +15,11 @@ "gatewayKicker": "Tool-gateway", "mcpTitle": "MCP Hub", "mcpEnabled": "Registratie is ingeschakeld voor deze omgeving.", - "mcpNotConnected": "Nog niet gekoppeld — voorbereid voor toekomstige, gecontroleerde tool-aanroepen vanuit de Hub." + "mcpNotConnected": "Nog niet gekoppeld — voorbereid voor toekomstige, gecontroleerde tool-aanroepen vanuit de Hub.", + "mcpNoEvidence": "Geregistreerd, maar er is nog geen tool-aanroep geregistreerd.", + "mcpEvidence": "Laatste aanroep: {{tool}} door {{client}} · {{count}} aanroepen in totaal", + "mcpHubReachable": "Hub bereikbaar", + "mcpHubUnreachable": "Hub onbereikbaar" }, "statusLabels": { "notConnected": "Niet gekoppeld", diff --git a/frontend/src/pages/Automation.tsx b/frontend/src/pages/Automation.tsx index 608f03f..eaebc82 100644 --- a/frontend/src/pages/Automation.tsx +++ b/frontend/src/pages/Automation.tsx @@ -209,12 +209,32 @@ export function Automation() {
{t("cards.gatewayKicker")}

{t("cards.mcpTitle")}

-

{integrationStatus?.mcp_hub.registration_enabled ? t("cards.mcpEnabled") : t("cards.mcpNotConnected")}

+

+ {(() => { + const hub = integrationStatus?.mcp_hub; + if (!hub?.registration_enabled) return t("cards.mcpNotConnected"); + if (hub.total_calls > 0) { + return t("cards.mcpEvidence", { tool: hub.last_tool, client: hub.last_client, count: hub.total_calls }); + } + return t("cards.mcpNoEvidence"); + })()} +

+ {integrationStatus?.mcp_hub.last_called_at && ( + {formatDateTime(integrationStatus.mcp_hub.last_called_at)} + )} +
+
+ {(() => { + const meta = integrationStatus ? MCP_STATE_META[integrationStatus.mcp_hub.state] : null; + return ; + })()} + {integrationStatus?.mcp_hub.hub_reachable !== null && integrationStatus?.mcp_hub.hub_reachable !== undefined && ( + + )}
- {(() => { - const meta = integrationStatus ? MCP_STATE_META[integrationStatus.mcp_hub.state] : null; - return ; - })()} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 23871dc..bf9072f 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -337,7 +337,9 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details .resolution-actions .button-tertiary:hover { color: var(--ink); background: var(--surface); border-color: var(--line); } .resolution-actions .button-tertiary-destructive:hover { color: var(--critical); background: var(--surface); border-color: var(--line); } -.integration-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 28px; }.integration-cards article { min-height: 170px; display: grid; grid-template-columns: auto 1fr; gap: 12px; padding: 18px; background: white; border: 1px solid var(--line); border-radius: var(--radius); }.integration-cards .badge { grid-column: 1 / -1; width: max-content; align-self: end; }.integration-cards h2 { margin: 3px 0 7px; font-size: .95rem; }.integration-cards p { margin: 0; color: var(--muted); font-size: .7rem; line-height: 1.48; }.integration-kicker { color: var(--muted); font-size: .56rem; font-weight: 700; text-transform: uppercase; letter-spacing: .09em; } +.integration-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 28px; }.integration-cards article { min-height: 170px; display: grid; grid-template-columns: auto 1fr; gap: 12px; padding: 18px; background: white; border: 1px solid var(--line); border-radius: var(--radius); }.integration-cards .badge { grid-column: 1 / -1; width: max-content; align-self: end; } +.integration-cards .integration-badge-stack { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: 6px; align-self: end; } +.integration-cards small { display: block; margin-top: 4px; color: var(--muted-light); font-size: .6rem; }.integration-cards h2 { margin: 3px 0 7px; font-size: .95rem; }.integration-cards p { margin: 0; color: var(--muted); font-size: .7rem; line-height: 1.48; }.integration-kicker { color: var(--muted); font-size: .56rem; font-weight: 700; text-transform: uppercase; letter-spacing: .09em; } .scenario-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 14px; } .scenario-card { display: flex; flex-direction: column; gap: 10px; padding: 18px; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); }