diff --git a/kora_cli/web_server.py b/kora_cli/web_server.py index f217ec7fd87f..360d83f45ee4 100644 --- a/kora_cli/web_server.py +++ b/kora_cli/web_server.py @@ -4662,63 +4662,70 @@ async def _iter(): async def get_heartbeat_services(): """Return per-service heartbeat status for Joshua's backend stack. - v1 stub. Replace body with a projection of the live - HeartbeatPoller state once KR-FEAT-HEARTBEAT lands. - - Service status enum: ``healthy`` | ``degraded`` | ``unhealthy``. - Each service surfaces a small ``details`` dict — shape varies per - service (e.g. Sentry carries ``unresolved_issues``; Supabase - carries ``connections_pct``); FE renders as expandable key/value. + KR-FEAT-HEARTBEAT ST2: flipped from stub to live read via + :func:`kora_cli.heartbeat_probes.current_service_snapshots`. + The heartbeat scheduler populates the snapshot cache every + ``KORA_HEARTBEAT_PROBE_INTERVAL_SEC`` seconds (default 300). + + Two-branch shape: + + - Live path: ``stub=False`` + ``cache_warming=False`` + + ``services`` projected from the snapshot cache. + - Cache-warming path: ``stub=False`` + ``cache_warming=True`` + + ``services=[]``. Returned when the daemon has just + started and the first probe cycle hasn't completed — + FE renders "Probes warming up..." instead of an empty + state. Suppresses any false "all services down" alert + heuristic. + + Service status enum: ``healthy`` | ``degraded`` | ``unhealthy`` + | ``unknown`` (the latter added in this flip; see TS + ``HeartbeatStatus`` in ``web/src/lib/api.ts``). """ + from datetime import datetime, timezone + + from kora_cli.heartbeat_probes import current_service_snapshots + + snapshots = current_service_snapshots() + now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + if not snapshots: + return { + "services": [], + "generated_at": now_iso, + "stub": False, + "cache_warming": True, + } + + # Stable ordering so FE doesn't re-shuffle cards between + # refreshes: render in the default-probe registration order + # (vercel → sentry → doppler → supabase → fly), then any + # extras (operator-added probes via a future config-driven + # extension) by alphabetical name. + canonical_order = ("vercel", "sentry", "doppler", "supabase", "fly") + ordered_names = [n for n in canonical_order if n in snapshots] + sorted( + name for name in snapshots if name not in canonical_order + ) + + services: list[dict[str, Any]] = [] + for name in ordered_names: + snapshot = snapshots[name] + services.append({ + "name": snapshot.name, + "status": snapshot.status, + "last_check_at": snapshot.last_check_at.strftime( + "%Y-%m-%dT%H:%M:%SZ" + ), + "latency_ms": snapshot.latency_ms, + "details": dict(snapshot.details), + "error": snapshot.error, + }) + return { - "services": [ - { - "name": "vercel", - "status": "healthy", - "last_check_at": "2026-05-22T18:00:00Z", - "latency_ms": 142, - "details": { - "deployments_last_24h": 8, - "error_rate_24h": 0.0, - }, - }, - { - "name": "sentry", - "status": "degraded", - "last_check_at": "2026-05-22T18:00:00Z", - "latency_ms": 230, - "details": {"unresolved_issues": 12}, - }, - { - "name": "doppler", - "status": "healthy", - "last_check_at": "2026-05-22T18:00:00Z", - "latency_ms": 95, - "details": { - "projects_total": 3, - "oldest_secret_age_days": 47, - }, - }, - { - "name": "supabase", - "status": "healthy", - "last_check_at": "2026-05-22T18:00:00Z", - "latency_ms": 38, - "details": {"connections_pct": 14}, - }, - { - "name": "fly", - "status": "healthy", - "last_check_at": "2026-05-22T18:00:00Z", - "latency_ms": 88, - "details": { - "apps_running": 2, - "deploys_last_24h": 1, - }, - }, - ], - "generated_at": "2026-05-22T18:00:05Z", - "stub": True, + "services": services, + "generated_at": now_iso, + "stub": False, + "cache_warming": False, } diff --git a/kora_docs/15_status_and_roadmap/kora_runtime_doppler_env_mapping.md b/kora_docs/15_status_and_roadmap/kora_runtime_doppler_env_mapping.md index 8a9554434137..b84f95edcc9b 100644 --- a/kora_docs/15_status_and_roadmap/kora_runtime_doppler_env_mapping.md +++ b/kora_docs/15_status_and_roadmap/kora_runtime_doppler_env_mapping.md @@ -66,6 +66,34 @@ the project — never even as a stale fallback. | `SLACK_SIGNING_SECRET` | Legacy Slack Bolt gateway | Same gating as SLACK_APP_TOKEN. **Same value as `KORA_SLACK_SIGNING_SECRET`** — both come from the same Slack app's Basic Information page; the env-var split exists because the legacy gateway and the new webhook listener consume the secret via different code paths. Operator sets both to the same value until a follow-on refactor consolidates them. | Same as `KORA_SLACK_SIGNING_SECRET` | | `SLACK_GATEWAY_ENABLED` | Toggle for the legacy Bolt gateway | Defaults to `true` in `docker/entrypoint.sh`. Set to `false` if the legacy gateway should stay dormant (e.g. running webhook-only on the daemon). | `true` / `false` | +#### Phase 2 Feature 2 — Heartbeat probes (KR-FEAT-HEARTBEAT) + +The daemon's heartbeat scheduler probes 5 backend services every +`KORA_HEARTBEAT_PROBE_INTERVAL_SEC` (default 300s). Each probe needs +a Doppler-injected service token. A probe with its auth env unset +degrades gracefully (status: `unknown` in the panel + zero outbound +calls) — these secrets are NOT deploy-blocking, but the heartbeat +dashboard will show "auth env unset" until they're configured. + +All 5 live in `kora-runtime-gateways` (same project as the legacy +gateway tokens — gateways = "tokens the runtime uses to reach +outbound services on Joshua's behalf"). + +| Secret | Probe | Mint via | Scope | Example shape | +|---|---|---|---|---| +| `KORA_VERCEL_API_TOKEN` | Vercel — recent deployments + error rate | | Read-only scope sufficient (lists `/v6/deployments`). | `<32+ char opaque>` | +| `KORA_SENTRY_API_TOKEN` | Sentry — unresolved issue count | | `org:read` scope minimum (`event:read` if probe extension wants project breakdown later). | `<64-hex>` | +| `KORA_SENTRY_ORG` | Sentry — org slug for the issues query | Operator-known org slug (e.g. `stormhaven`). | — | `stormhaven` | +| `KORA_DOPPLER_API_TOKEN` | Doppler — workplace reachability | → **Service Token** (NOT a project token). Workplace read-only scope. Mint a dedicated service token for the probe — keep separate from any per-project tokens. | Workplace read-only | `dp.st..` | +| `KORA_SUPABASE_ANON_KEY` | Supabase — PostgREST endpoint reachability | Supabase project → Settings → API → **anon key** (NOT the service_role key). | `anon` (public) | `` | +| `KORA_SUPABASE_URL` | Supabase — project URL | Same Project Settings page. | — | `https://.supabase.co` | +| `KORA_FLY_API_TOKEN` | Fly — `kora-runtime` machines state | `flyctl auth token` (operator workstation, deploy token) or Fly dashboard org tokens page. | Read access to the kora-runtime app(s). | `fly_` | +| `KORA_FLY_STAGING_APP_NAME` | Fly — optional staging app name | Optional. Set if the operator wants the probe to ALSO check the staging app. Leave unset to probe prod only. | — | `kora-runtime-staging` | + +**Validation tip**: after setting these, restart the daemon (or wait +≤5 min for the next probe cycle); `GET /api/heartbeat/services` +should flip each service from `unknown` to `healthy` / `degraded`. + --- ## fly.toml `[env]` values (NOT in Doppler) @@ -97,6 +125,8 @@ does not own them. |---|---|---| | `KORA_WEBHOOK_RATE_LIMIT` | `60/minute` | Tighten via Doppler-gateways if dead-letter rate spikes suggest a flood. slowapi syntax. | | `KORA_HEALTH_PROBE_CADENCE_SECONDS` | `300` | Lower if dashboard freshness suffers under default 5min cadence. | +| `KORA_HEARTBEAT_PROBE_INTERVAL_SEC` | `300` | KR-FEAT-HEARTBEAT — backend-service probe cadence (Vercel/Sentry/Doppler/Supabase/Fly). Distinct from `KORA_HEALTH_PROBE_CADENCE_SECONDS` (the MCP-client health-check task); both default to 5min, registered as DISTINCT scheduler tasks so one slow cycle doesn't block the other. | +| `KORA_MCP_HEALTH_CHECK_INTERVAL_SEC` | `300` | KR-MCP-CONSUMPTION ST2 — MCP-client-pool health check cadence. Same default as above; same isolation rationale. | | `KORA_LOG_LEVEL` | `INFO` | `DEBUG` for first-deploy investigation; revert to `INFO` afterwards. | | `KORA_DEV` | unset | Set to `1` ONLY for local-dev `kora daemon` invocation (bypasses Doppler wrap + lets `KORA_DEPLOY_ENV` default to `dev`). Never set in Fly. | @@ -127,6 +157,18 @@ for SECRET in KORA_MCP_BEARER_TOKEN KORA_SLACK_SIGNING_SECRET SLACK_APP_TOKEN SL || echo "MISS gateways:$SECRET" done +# KR-FEAT-HEARTBEAT probe tokens — NOT deploy-blocking. The +# heartbeat panel surfaces "auth env unset" on missing probes +# rather than failing the boot. Run this section opt-in to verify +# the heartbeat-panel data path is fully configured. +for SECRET in KORA_VERCEL_API_TOKEN KORA_SENTRY_API_TOKEN KORA_SENTRY_ORG \ + KORA_DOPPLER_API_TOKEN KORA_SUPABASE_ANON_KEY KORA_SUPABASE_URL \ + KORA_FLY_API_TOKEN; do + doppler secrets get "$SECRET" -p kora-runtime-gateways -c "$CONFIG" --plain >/dev/null \ + && echo "OK gateways:$SECRET (heartbeat probe)" \ + || echo "MISS gateways:$SECRET (heartbeat probe — panel shows unknown)" +done + # Anti-secret check — these MUST be absent. for ANTI in ANTHROPIC_API_KEY ANTHROPIC_AUTH_TOKEN; do doppler secrets get "$ANTI" -p kora-runtime-anthropic -c "$CONFIG" --plain 2>/dev/null \ diff --git a/tests/kora_cli/test_web_server_heartbeat.py b/tests/kora_cli/test_web_server_heartbeat.py index 3dab70341b03..46d3cd69650f 100644 --- a/tests/kora_cli/test_web_server_heartbeat.py +++ b/tests/kora_cli/test_web_server_heartbeat.py @@ -1,18 +1,29 @@ -"""Tests for the KR-HB-PANEL stub endpoint. +"""Tests for the /api/heartbeat/services endpoint. -Bucket §4 scenarios: +Originally landed by CC#2's KR-HB-PANEL (PR #103) as a hardcoded +stub. CC#1's KR-FEAT-HEARTBEAT ST2 swaps the body for a live read +from ``kora_cli.heartbeat_probes.current_service_snapshots()``. + +Scenarios: 1. GET /api/heartbeat/services returns 200 - 2. Top-level shape (services + generated_at + stub:true) - 3. All 5 expected services present - 4. Each service entry has the required keys + valid status enum - 5. status:degraded sample matches the bucket §3 documented stub - 6. Cron-regression sanity + 2. Top-level shape — services + generated_at + stub + cache_warming + 3. Empty cache → cache_warming=True + services=[] + 4. Populated cache → services projected in canonical order + 5. Each service entry has the required keys + valid status enum + (4-value: healthy/degraded/unhealthy/unknown) + 6. Latency / last_check_at / error fields nullable per the + TS contract extension + 7. Cron-regression sanity """ +from __future__ import annotations + +from datetime import datetime, timezone + import pytest -_VALID_STATUS = {"healthy", "degraded", "unhealthy"} +_VALID_STATUS = {"healthy", "degraded", "unhealthy", "unknown"} _EXPECTED_SERVICES = {"vercel", "sentry", "doppler", "supabase", "fly"} @@ -26,7 +37,70 @@ def _isolate_config(tmp_path, monkeypatch): monkeypatch.setattr( "kora_cli.config.get_env_path", lambda: tmp_path / ".env" ) - return tmp_path + # Reset the probe snapshot cache between tests + from kora_cli.heartbeat_probes.runner import _clear_snapshot_cache + + _clear_snapshot_cache() + yield tmp_path + _clear_snapshot_cache() + + +def _seed_snapshot( + name: str, + *, + status: str = "healthy", + latency_ms: int | None = 50, + details: dict | None = None, + error: str | None = None, + last_check_at: datetime | None = None, +) -> None: + from kora_cli.heartbeat_probes.runner import _snapshot_cache + from kora_cli.heartbeat_probes.types import ServiceHealthSnapshot + + _snapshot_cache[name] = ServiceHealthSnapshot( + name=name, + status=status, + latency_ms=latency_ms, + last_check_at=last_check_at or datetime.now(timezone.utc), + details=details or {}, + error=error, + ) + + +def _seed_five_healthy_services() -> None: + _seed_snapshot( + "vercel", + status="healthy", + latency_ms=140, + details={"deployments_last_24h": 8, "error_rate_24h": 0.0}, + ) + _seed_snapshot( + "sentry", + status="degraded", + latency_ms=230, + details={"unresolved_issues": 12}, + ) + _seed_snapshot( + "doppler", + status="healthy", + latency_ms=95, + details={ + "projects_total": "unknown", + "oldest_secret_age_days": "unknown", + }, + ) + _seed_snapshot( + "supabase", + status="healthy", + latency_ms=38, + details={"connections_pct": "unknown"}, + ) + _seed_snapshot( + "fly", + status="healthy", + latency_ms=88, + details={"apps_running": 1, "deploys_last_24h": "unknown"}, + ) # ---- 1. 200 ----------------------------------------------------------- @@ -44,24 +118,48 @@ async def test_endpoint_returns_200(_isolate_config): @pytest.mark.asyncio -async def test_response_shape_has_required_keys(_isolate_config): +async def test_response_shape_has_required_keys_when_warming(_isolate_config): + """Empty cache (just-started daemon) → warming branch. Same + top-level keys whether warming or not — FE renders the same + schema.""" from kora_cli import web_server result = await web_server.get_heartbeat_services() - assert set(result.keys()) == {"services", "generated_at", "stub"} - assert isinstance(result["services"], list) + assert set(result.keys()) == { + "services", + "generated_at", + "stub", + "cache_warming", + } + assert result["stub"] is False + assert result["cache_warming"] is True + assert result["services"] == [] assert isinstance(result["generated_at"], str) - assert result["stub"] is True -# ---- 3. All 5 expected services present ------------------------------ +@pytest.mark.asyncio +async def test_response_shape_has_required_keys_when_populated(_isolate_config): + _seed_five_healthy_services() + from kora_cli import web_server + + result = await web_server.get_heartbeat_services() + assert set(result.keys()) == { + "services", + "generated_at", + "stub", + "cache_warming", + } + assert result["stub"] is False + assert result["cache_warming"] is False + assert len(result["services"]) == 5 + + +# ---- 3. All 5 expected services present (post-flip from snapshots) ----- @pytest.mark.asyncio -async def test_all_five_expected_services_present(_isolate_config): - """Pin the canonical 5-service list (Vercel / Sentry / Doppler / - Supabase / Fly). A future stub edit that drops one would silently - break the dashboard aggregate count test, so catch it here.""" +async def test_all_five_expected_services_present_when_populated(_isolate_config): + _seed_five_healthy_services() from kora_cli import web_server result = await web_server.get_heartbeat_services() @@ -69,92 +167,155 @@ async def test_all_five_expected_services_present(_isolate_config): assert names == _EXPECTED_SERVICES +@pytest.mark.asyncio +async def test_canonical_order_preserved(_isolate_config): + """Services render in the registration order (vercel → sentry → + doppler → supabase → fly) regardless of insertion order. FE + cards stay stable between refreshes.""" + # Seed in reverse to verify ordering is enforced server-side + _seed_snapshot("fly", status="healthy") + _seed_snapshot("supabase", status="healthy") + _seed_snapshot("doppler", status="healthy") + _seed_snapshot("sentry", status="healthy") + _seed_snapshot("vercel", status="healthy") + + from kora_cli import web_server + + result = await web_server.get_heartbeat_services() + order = [s["name"] for s in result["services"]] + assert order == ["vercel", "sentry", "doppler", "supabase", "fly"] + + # ---- 4. Per-entry shape + status enum -------------------------------- @pytest.mark.asyncio async def test_each_service_entry_has_required_keys(_isolate_config): + _seed_five_healthy_services() from kora_cli import web_server + required = { + "name", + "status", + "last_check_at", + "latency_ms", + "details", + # KR-FEAT-HEARTBEAT ST2 additive + "error", + } result = await web_server.get_heartbeat_services() for service in result["services"]: - assert set(service.keys()) == { - "name", - "status", - "last_check_at", - "latency_ms", - "details", - } + assert set(service.keys()) == required assert isinstance(service["name"], str) and service["name"] - assert service["status"] in _VALID_STATUS, ( - f"{service['name']}: status={service['status']!r} not in " - f"{_VALID_STATUS}" - ) - assert isinstance(service["latency_ms"], int) - assert service["latency_ms"] >= 0 - assert isinstance(service["last_check_at"], str) + assert service["status"] in _VALID_STATUS assert isinstance(service["details"], dict) -# ---- 5. Bucket §3 documented stub values pinned ---------------------- +@pytest.mark.asyncio +async def test_unknown_status_in_valid_set(_isolate_config): + """Auth-missing / probe-timeout snapshots surface as + ``status="unknown"``. FE renders distinct from "unhealthy".""" + _seed_snapshot( + "vercel", + status="unknown", + latency_ms=None, + error="auth env unset or empty: 'KORA_VERCEL_API_TOKEN'", + ) + from kora_cli import web_server + + result = await web_server.get_heartbeat_services() + vercel = next(s for s in result["services"] if s["name"] == "vercel") + assert vercel["status"] == "unknown" + assert vercel["status"] in _VALID_STATUS @pytest.mark.asyncio -async def test_sentry_is_degraded_in_stub_per_spec(_isolate_config): - """The bucket §3 stub pins sentry as the one degraded service (with - 12 unresolved_issues). The dashboard card's "1 degraded" aggregate - depends on this — pin it so a future stub edit can't silently - flip the count.""" +async def test_latency_ms_nullable_on_unknown(_isolate_config): + """An unknown probe has no roundtrip → latency_ms is null.""" + _seed_snapshot("vercel", status="unknown", latency_ms=None) from kora_cli import web_server result = await web_server.get_heartbeat_services() - sentry = next(s for s in result["services"] if s["name"] == "sentry") - assert sentry["status"] == "degraded" - assert sentry["details"]["unresolved_issues"] == 12 + vercel = next(s for s in result["services"] if s["name"] == "vercel") + assert vercel["latency_ms"] is None @pytest.mark.asyncio -async def test_other_four_services_healthy_in_stub(_isolate_config): - """Counterpart to the sentry-degraded pin: the other 4 are healthy - per bucket §3. Dashboard aggregate: 4 healthy / 1 degraded / 0 - unhealthy.""" +async def test_error_field_populated_on_unhealthy(_isolate_config): + _seed_snapshot( + "vercel", status="unhealthy", error="HTTP 503", latency_ms=140 + ) from kora_cli import web_server result = await web_server.get_heartbeat_services() - for service in result["services"]: - if service["name"] != "sentry": - assert service["status"] == "healthy", ( - f"{service['name']}: expected healthy in stub, got " - f"{service['status']!r}" - ) + vercel = next(s for s in result["services"] if s["name"] == "vercel") + assert vercel["error"] == "HTTP 503" @pytest.mark.asyncio -async def test_details_payloads_match_documented_shape(_isolate_config): - """Spot-check each service's documented detail keys are present - (without pinning exact values — values are stub data that the - follow-on real-poller PR will overwrite).""" +async def test_error_field_null_on_healthy(_isolate_config): + _seed_snapshot("vercel", status="healthy", error=None) from kora_cli import web_server - expected_keys: dict[str, set[str]] = { - "vercel": {"deployments_last_24h", "error_rate_24h"}, - "sentry": {"unresolved_issues"}, - "doppler": {"projects_total", "oldest_secret_age_days"}, - "supabase": {"connections_pct"}, - "fly": {"apps_running", "deploys_last_24h"}, - } + result = await web_server.get_heartbeat_services() + vercel = next(s for s in result["services"] if s["name"] == "vercel") + assert vercel["error"] is None + + +# ---- 5. cache_warming branch contract --------------------------------- + + +@pytest.mark.asyncio +async def test_cache_warming_returns_empty_services_list(_isolate_config): + """Pin the warming-branch shape: services=[] + cache_warming=True + + stub=False. FE renders "Probes warming up...".""" + from kora_cli import web_server + + result = await web_server.get_heartbeat_services() + assert result["services"] == [] + assert result["cache_warming"] is True + assert result["stub"] is False + + +@pytest.mark.asyncio +async def test_cache_warming_false_when_any_snapshot_present(_isolate_config): + """A single snapshot is enough to flip cache_warming=False — + operator sees partial data rather than the warming placeholder.""" + _seed_snapshot("vercel", status="healthy") + from kora_cli import web_server + + result = await web_server.get_heartbeat_services() + assert result["cache_warming"] is False + assert len(result["services"]) == 1 + + +# ---- 6. Sanitization spot-check (security carry-forward) -------------- + + +@pytest.mark.asyncio +async def test_error_string_passthrough_from_snapshot(_isolate_config): + """The endpoint trusts the snapshot's error field — sanitization + is the probe's job (sanitize_error in heartbeat_probes/base.py). + Pin that the endpoint doesn't accidentally inject token values + of its own.""" + snapshot_error = "auth env unset or empty: 'KORA_VERCEL_API_TOKEN'" + _seed_snapshot("vercel", status="unknown", latency_ms=None, error=snapshot_error) from kora_cli import web_server result = await web_server.get_heartbeat_services() - by_name = {s["name"]: s for s in result["services"]} - for name, keys in expected_keys.items(): - assert keys <= set(by_name[name]["details"].keys()), ( - f"{name}: missing detail key(s) " - f"{keys - set(by_name[name]['details'].keys())}" - ) + vercel = next(s for s in result["services"] if s["name"] == "vercel") + # No token-value-shape characters added by endpoint projection + assert vercel["error"] == snapshot_error + # Defense in depth — pin that response payload has no Bearer / ghp_ + # prefixes anywhere (would indicate token leak via dependency). + import json + + serialized = json.dumps(result) + assert "Bearer " not in serialized + assert "ghp_" not in serialized -# ---- 6. Cron-regression sanity -------------------------------------- +# ---- 7. Cron-regression sanity --------------------------------------- @pytest.mark.asyncio diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index e7da443cab08..d17d14289c6b 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -1417,20 +1417,43 @@ export function diagBundleHref(): string { // connections_pct, etc.) — surfaced as an opaque Record so each FE // renderer can read the keys it knows about; unknown keys render as // plain key/value pairs. -export type HeartbeatStatus = "healthy" | "degraded" | "unhealthy"; +// KR-FEAT-HEARTBEAT ST2: the live probe path adds "unknown" status +// (auth-env missing / probe timeout / probe-loop crash). FE renders +// this as a yellow "no data" badge — distinct from "unhealthy" +// (active failure) so operators don't mistake a configuration gap +// for a real outage. +export type HeartbeatStatus = + | "healthy" + | "degraded" + | "unhealthy" + | "unknown"; export interface HeartbeatService { name: string; status: HeartbeatStatus; - last_check_at: string; - latency_ms: number; + // last_check_at is nullable because an "unknown" snapshot from a + // probe that never completed a roundtrip has no meaningful + // timestamp; FE renders "—" in that case. + last_check_at: string | null; + // Likewise nullable — auth-missing / timeout cases never measure + // latency. + latency_ms: number | null; details: Record; + // Operator-readable failure string (sanitized — never includes the + // auth token). Null on healthy paths. + error: string | null; } export interface HeartbeatServicesResponse { services: HeartbeatService[]; generated_at: string; stub: boolean; + // KR-FEAT-HEARTBEAT ST2: ``true`` when the snapshot cache is empty + // (daemon just started; first probe cycle hasn't completed yet). + // FE renders "Probes warming up..." instead of an empty state + + // suppresses any "all services down" alerting heuristic until the + // first cycle lands. + cache_warming: boolean; } // MCP client picker (KR-MCP-3) — Kora-as-MCP-client surface.