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:
co-authored by
Claude Sonnet 5
parent
2ae2044e3a
commit
727c19a779
+5
-2
@@ -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
@@ -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 <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`).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
{(() => {
|
||||
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} />;
|
||||
})()}
|
||||
</article>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -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); }
|
||||
|
||||
Reference in New Issue
Block a user