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 <noreply@anthropic.com>
This commit is contained in:
NuklearRabbit
2026-08-05 13:30:24 +02:00
co-authored by Claude Sonnet 5
parent 2ae2044e3a
commit 727c19a779
17 changed files with 269 additions and 45 deletions
+5 -2
View File
@@ -45,8 +45,11 @@ RAGCORE_API_TOKEN=
# UUID of the RAGcore knowledge space procedures were synced into (see workflow 3). # UUID of the RAGcore knowledge space procedures were synced into (see workflow 3).
RAGCORE_SPACE_ID= 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_REGISTRATION_ENABLED=false
MCP_HUB_BASE_URL=http://itworx-mcp-hub:8000 MCP_HUB_BASE_URL=http://itworx-mcp-hub:8000
MCP_HUB_SERVICE_TOKEN=replace-me-mcp-hub-token MCP_HUB_SERVICE_TOKEN=replace-me-mcp-hub-token
MCP_PROVIDER_ID=mobilityops MCP_PROVIDER_ID=fleet-ops
+53 -4
View File
@@ -57,6 +57,7 @@ M7 — complete. All milestones (M0M7) 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). - 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. - 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 <service>` is required, not just `docker compose build`. - 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 <service>` 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 ## 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. clean; `mypy app` — clean (50 files); `cd frontend && npm run build` — clean.
- Committed `34df66d`, pushed to `origin/feat/fleet-ops-final-integrations`. Not yet - 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. 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 - Deployed to `http://192.168.10.150:1236` (`git archive` + scp + `docker compose up
(MCP Hub — confirm Fleet Ops-side config wiring, tool naming, locale param, correlation --build -d db api web`, following the established deployment convention). Browser-
ID propagation); Batch 5 (AI Operations Brief using the demo knowledge provider since verified live: Attention Queue tier grouping, Today's Movements curated seed mix (2
RAGcore stays off; GUI activity showcase; final regression; evidence write-up). 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`).
+43 -10
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import uuid import uuid
from datetime import date from datetime import date
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Header, Query
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -27,14 +27,30 @@ router = APIRouter(prefix="/api/v1/integrations/mcp", tags=["mcp"])
settings = get_settings() 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( record_audit_event(
db, db,
actor_type="service", actor_type="service",
actor_label=client_id, actor_label=client_id,
action="mcp_tool_request", action="mcp_tool_request",
entity_type="mcp_tool", entity_type="mcp_tool",
correlation_id=uuid.uuid4(), correlation_id=uuid.UUID(correlation_id),
metadata={"tool": tool, "status": status_label}, metadata={"tool": tool, "status": status_label},
) )
db.commit() db.commit()
@@ -44,10 +60,15 @@ def _audit_service_request(db: Session, *, client_id: str, tool: str, status_lab
def operations_summary( def operations_summary(
db: Session = Depends(get_db), db: Session = Depends(get_db),
client_id: str = Depends(require_mcp_service_token), client_id: str = Depends(require_mcp_service_token),
correlation_id: str = Depends(get_correlation_id),
) -> OperationsSummaryOut: ) -> OperationsSummaryOut:
metrics = compute_metrics(db) metrics = compute_metrics(db)
_audit_service_request( _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) return OperationsSummaryOut(tenant=settings.ragcore_tenant, metrics=metrics)
@@ -59,12 +80,17 @@ def attention_vehicles(
limit: int = Query(default=20, ge=1, le=50), limit: int = Query(default=20, ge=1, le=50),
db: Session = Depends(get_db), db: Session = Depends(get_db),
client_id: str = Depends(require_mcp_service_token), client_id: str = Depends(require_mcp_service_token),
correlation_id: str = Depends(get_correlation_id),
) -> list[AttentionVehicleOut]: ) -> list[AttentionVehicleOut]:
results = list_attention_vehicles( results = list_attention_vehicles(
db, minimum_severity=minimum_severity, on_or_before=date_filter, limit=limit db, minimum_severity=minimum_severity, on_or_before=date_filter, limit=limit
) )
_audit_service_request( _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] return [AttentionVehicleOut(**r) for r in results]
@@ -74,14 +100,16 @@ def vehicle_details(
vehicle_ref: str, vehicle_ref: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
client_id: str = Depends(require_mcp_service_token), client_id: str = Depends(require_mcp_service_token),
correlation_id: str = Depends(get_correlation_id),
) -> McpVehicleDetailOut: ) -> McpVehicleDetailOut:
vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == vehicle_ref)) vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == vehicle_ref))
if vehicle is None: if vehicle is None:
_audit_service_request( _audit_service_request(
db, db,
client_id=client_id, client_id=client_id,
tool="mobilityops_get_vehicle_details", tool="fleet_ops_get_vehicle_details",
status_label="not_found", status_label="not_found",
correlation_id=correlation_id,
) )
raise AppError("VEHICLE_NOT_FOUND", "Vehicle not found.", status_code=404) raise AppError("VEHICLE_NOT_FOUND", "Vehicle not found.", status_code=404)
@@ -99,7 +127,11 @@ def vehicle_details(
) )
_audit_service_request( _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( return McpVehicleDetailOut(
public_ref=vehicle.public_ref, public_ref=vehicle.public_ref,
@@ -120,15 +152,16 @@ def search_knowledge(
body: McpKnowledgeSearchRequest, body: McpKnowledgeSearchRequest,
db: Session = Depends(get_db), db: Session = Depends(get_db),
client_id: str = Depends(require_mcp_service_token), client_id: str = Depends(require_mcp_service_token),
correlation_id: str = Depends(get_correlation_id),
) -> GroundedAnswer: ) -> GroundedAnswer:
provider = get_knowledge_provider() provider = get_knowledge_provider()
correlation_id = str(uuid.uuid4()) answer = provider.ask(body.question, correlation_id, language=body.locale)
answer = provider.ask(body.question, correlation_id)
answer.sources = answer.sources[: body.max_sources] answer.sources = answer.sources[: body.max_sources]
_audit_service_request( _audit_service_request(
db, db,
client_id=client_id, client_id=client_id,
tool="mobilityops_search_knowledge", tool="fleet_ops_search_knowledge",
status_label=answer.evidence_state, status_label=answer.evidence_state,
correlation_id=correlation_id,
) )
return answer return answer
+5
View File
@@ -39,6 +39,11 @@ class Settings(BaseSettings):
knowledge_dir: str = "/app/knowledge/procedures" knowledge_dir: str = "/app/knowledge/procedures"
mcp_hub_service_token: str = "replace-me-mcp-hub-token" mcp_hub_service_token: str = "replace-me-mcp-hub-token"
mcp_hub_registration_enabled: bool = False 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" cors_allow_origins: str = "http://localhost:1228"
demo_organization_name: str = "Northstar Mobility" demo_organization_name: str = "Northstar Mobility"
demo_timezone: str = "Europe/Brussels" demo_timezone: str = "Europe/Brussels"
+2
View File
@@ -284,6 +284,7 @@ class McpHubIntegrationStatus(BaseModel):
last_tool: str | None = None last_tool: str | None = None
last_client: str | None = None last_client: str | None = None
last_called_at: datetime | None = None last_called_at: datetime | None = None
hub_reachable: bool | None = None
class IntegrationStatusOut(BaseModel): class IntegrationStatusOut(BaseModel):
@@ -403,6 +404,7 @@ class McpVehicleDetailOut(BaseModel):
class McpKnowledgeSearchRequest(BaseModel): class McpKnowledgeSearchRequest(BaseModel):
question: str = Field(min_length=3, max_length=1000) question: str = Field(min_length=3, max_length=1000)
max_sources: int = Field(default=4, ge=1, le=8) max_sources: int = Field(default=4, ge=1, le=8)
locale: Literal["nl-BE", "en-GB", "fr-BE"] = "en-GB"
class AuditEventOut(BaseModel): class AuditEventOut(BaseModel):
@@ -2,6 +2,7 @@ from __future__ import annotations
from typing import Literal from typing import Literal
import httpx
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -153,6 +154,8 @@ def derive_mcp_hub_status(db: Session) -> McpHubIntegrationStatus:
else: else:
state = "no_evidence" state = "no_evidence"
hub_reachable = _check_hub_reachable()
return McpHubIntegrationStatus( return McpHubIntegrationStatus(
registration_enabled=settings.mcp_hub_registration_enabled, registration_enabled=settings.mcp_hub_registration_enabled,
state=state, state=state,
@@ -160,4 +163,18 @@ def derive_mcp_hub_status(db: Session) -> McpHubIntegrationStatus:
last_tool=last_tool, last_tool=last_tool,
last_client=last_client, last_client=last_client,
last_called_at=last_called_at, 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
+3 -1
View File
@@ -52,7 +52,9 @@ def test_integration_status_lists_all_four_canonical_workflows(ops_client):
"Fleet Ops — Workflow Error Handler", "Fleet Ops — Workflow Error Handler",
} }
ragcore_sync = next(w for w in body["workflows"] if "RAGcore" in w["name"]) 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 assert ragcore_sync["last_seen_at"] is None
+42
View File
@@ -81,6 +81,48 @@ def test_mcp_tool_requests_are_audited(client, ops_client):
assert events[0]["actor_type"] == "service" 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): def test_no_write_endpoints_exist_under_mcp_namespace(client):
for method, path in [ for method, path in [
("post", "/api/v1/integrations/mcp/vehicles/MO-016"), ("post", "/api/v1/integrations/mcp/vehicles/MO-016"),
+8 -2
View File
@@ -185,8 +185,14 @@ def test_seed_today_movements_are_a_credible_mix():
reset_and_seed(db) reset_and_seed(db)
today = datetime.now(UTC).date() today = datetime.now(UTC).date()
bookings = db.scalars(select(Booking)).all() bookings = db.scalars(select(Booking)).all()
departures = [b for b in bookings if b.starts_at.date() == today and b.status in ("reserved", "active")] departures = [
returns = [b for b in bookings if b.ends_at.date() == today and b.status in ("active", "returned")] 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(departures) >= 2
assert len(returns) >= 2 assert len(returns) >= 2
finally: finally:
+14 -8
View File
@@ -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", "provider_id": "mobilityops",
"version": "1.0.0", "version": "1.1.0",
"required_scope": "mobilityops.read", "required_scope": "mobilityops.read",
"tools": [ "tools": [
{ {
"name": "mobilityops_get_operations_summary", "name": "fleet_ops_get_operations_summary",
"description": "Return current high-level vehicle, data-quality and workflow counts for the synthetic MobilityOps demo tenant.", "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, "read_only": true,
"inputSchema": {"type": "object", "additionalProperties": false}, "inputSchema": {"type": "object", "additionalProperties": false},
"endpoint": {"method": "GET", "path": "/api/v1/integrations/mcp/operations-summary"} "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.", "description": "List vehicles that require operational attention, optionally filtered by minimum severity and date.",
"read_only": true, "read_only": true,
"inputSchema": { "inputSchema": {
@@ -26,7 +29,8 @@
"endpoint": {"method": "GET", "path": "/api/v1/integrations/mcp/attention-vehicles"} "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.", "description": "Return a read-only operational view of one vehicle by its stable public reference.",
"read_only": true, "read_only": true,
"inputSchema": { "inputSchema": {
@@ -38,15 +42,17 @@
"endpoint": {"method": "GET", "path": "/api/v1/integrations/mcp/vehicles/{vehicle_ref}"} "endpoint": {"method": "GET", "path": "/api/v1/integrations/mcp/vehicles/{vehicle_ref}"}
}, },
{ {
"name": "mobilityops_search_knowledge", "name": "fleet_ops_search_knowledge",
"description": "Search versioned MobilityOps internal procedures through the dedicated RAGcore workspace and return grounded source references.", "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, "read_only": true,
"inputSchema": { "inputSchema": {
"type": "object", "type": "object",
"required": ["question"], "required": ["question"],
"properties": { "properties": {
"question": {"type": "string", "minLength": 3, "maxLength": 1000}, "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 "additionalProperties": false
}, },
+33 -9
View File
@@ -6,20 +6,44 @@ Publish four read-only MobilityOps capabilities through the existing central ITW
## Provider registration ## Provider registration
- provider ID: `mobilityops` - provider ID: `mobilityops` (registered on the Hub side; the Hub's own registration is
- API base: configurable internal MobilityOps API URL catalog-driven — it reconciles its catalog into the gateway, Fleet Ops never pushes a
- authentication: scoped service token 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 - 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 ## 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` 1. `mobilityops.operations.summary`
2. `mobilityops_list_attention_vehicles` 2. `mobilityops.attention.list`
3. `mobilityops_get_vehicle_details` 3. `mobilityops.vehicle.get`
4. `mobilityops_search_knowledge` 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 ## Routing
+1
View File
@@ -289,6 +289,7 @@ export interface McpHubIntegrationStatus {
last_tool: string | null; last_tool: string | null;
last_client: string | null; last_client: string | null;
last_called_at: string | null; last_called_at: string | null;
hub_reachable: boolean | null;
} }
export interface IntegrationStatus { export interface IntegrationStatus {
@@ -15,7 +15,11 @@
"gatewayKicker": "Tool gateway", "gatewayKicker": "Tool gateway",
"mcpTitle": "MCP Hub", "mcpTitle": "MCP Hub",
"mcpEnabled": "Registration is enabled for this deployment.", "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": { "statusLabels": {
"notConnected": "Not connected", "notConnected": "Not connected",
@@ -15,7 +15,11 @@
"gatewayKicker": "Passerelle d'outils", "gatewayKicker": "Passerelle d'outils",
"mcpTitle": "MCP Hub", "mcpTitle": "MCP Hub",
"mcpEnabled": "L'enregistrement est activé pour ce déploiement.", "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": { "statusLabels": {
"notConnected": "Non connecté", "notConnected": "Non connecté",
@@ -15,7 +15,11 @@
"gatewayKicker": "Tool-gateway", "gatewayKicker": "Tool-gateway",
"mcpTitle": "MCP Hub", "mcpTitle": "MCP Hub",
"mcpEnabled": "Registratie is ingeschakeld voor deze omgeving.", "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": { "statusLabels": {
"notConnected": "Niet gekoppeld", "notConnected": "Niet gekoppeld",
+21 -1
View File
@@ -209,12 +209,32 @@ export function Automation() {
<div> <div>
<span className="integration-kicker">{t("cards.gatewayKicker")}</span> <span className="integration-kicker">{t("cards.gatewayKicker")}</span>
<h2>{t("cards.mcpTitle")}</h2> <h2>{t("cards.mcpTitle")}</h2>
<p>{integrationStatus?.mcp_hub.registration_enabled ? t("cards.mcpEnabled") : t("cards.mcpNotConnected")}</p> <p>
{(() => {
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");
})()}
</p>
{integrationStatus?.mcp_hub.last_called_at && (
<small>{formatDateTime(integrationStatus.mcp_hub.last_called_at)}</small>
)}
</div> </div>
<div className="integration-badge-stack">
{(() => { {(() => {
const meta = integrationStatus ? MCP_STATE_META[integrationStatus.mcp_hub.state] : null; const meta = integrationStatus ? MCP_STATE_META[integrationStatus.mcp_hub.state] : null;
return <StatusBadge status={meta?.statusClass ?? "not_configured"} label={meta ? t(`statusLabels.${meta.labelKey}`) : undefined} />; return <StatusBadge status={meta?.statusClass ?? "not_configured"} label={meta ? t(`statusLabels.${meta.labelKey}`) : undefined} />;
})()} })()}
{integrationStatus?.mcp_hub.hub_reachable !== null && integrationStatus?.mcp_hub.hub_reachable !== undefined && (
<StatusBadge
status={integrationStatus.mcp_hub.hub_reachable ? "available" : "unavailable"}
label={t(integrationStatus.mcp_hub.hub_reachable ? "cards.mcpHubReachable" : "cards.mcpHubUnreachable")}
/>
)}
</div>
</article> </article>
</section> </section>
+3 -1
View File
@@ -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: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); } .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-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); } .scenario-card { display: flex; flex-direction: column; gap: 10px; padding: 18px; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); }