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).
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
+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).
- 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`.
- **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`).
+43 -10
View File
@@ -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
+5
View File
@@ -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"
+2
View File
@@ -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):
@@ -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
+3 -1
View File
@@ -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
+42
View File
@@ -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"),
+8 -2
View File
@@ -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:
+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",
"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
},
+33 -9
View File
@@ -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
+1
View File
@@ -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 {
@@ -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",
@@ -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é",
@@ -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",
+21 -1
View File
@@ -209,12 +209,32 @@ export function Automation() {
<div>
<span className="integration-kicker">{t("cards.gatewayKicker")}</span>
<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 className="integration-badge-stack">
{(() => {
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} />;
})()}
{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>
</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-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); }