From b70de88e31e5e503d150cbb71494c3098031d88b Mon Sep 17 00:00:00 2001 From: CC#2 Kora Frontend Date: Thu, 21 May 2026 22:34:54 -0700 Subject: [PATCH] =?UTF-8?q?feat(kora):=20KR-MCP-3=20=E2=80=94=20MCP-picker?= =?UTF-8?q?=20UI=20frontend=20shell=20(stub)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator-facing view of EXTERNAL MCPs Kora consumes (Kora-as-MCP-client). Distinct concept from KR-P2-C ST2's existing /mcp surface, which is Kora-as-MCP-server admin (per-tool gating for tools Kora EXPOSES). Routes + types disambiguated: /api/mcp/servers → Kora-as-server admin (existing, /mcp) /api/mcp/clients/list → Kora-as-client picker (new, /mcp-clients) Branch base: feature/phase2-upgrades per bucket §0. Same stub-then- real pattern as KR-HB-PANEL; CC#1's KR-MCP-1 ST2 swaps the body using the same payload shape this PR pins. SECURITY contract (bucket §5 + ship-checklist hard-rule): auth_token_env carries the env-var NAME (e.g. KORA_MCP_GITHUB_TOKEN); auth_token_present is a bool. Token VALUES never appear in the response shape, never render in the UI (no tooltips, no copy-to- clipboard buttons, no dev-console). Tokens live in Doppler; operator rotates via `doppler secrets set `. Two test-side guards catch any future drift: * regex-pin auth_token_env to UPPER_SNAKE env-var name shape * walk the response keys for token-VALUE-shaped names (token, secret, access_token, api_key, password, bearer, bare auth_token without _env/_present suffix) and assert zero matches §2 K-DG verifications (grep'd vs bucket assumptions): * kora_mcp/ pool/registry/routing — confirmed (CC#1 ST1 1c495da0) * No collision with existing /mcp route — new /mcp-clients route + MCPClientsPanel (not MCPPage) avoid namespace overlap * Cable icon (lucide-react) distinct from MCPPage's Plug — operator visually distinguishes the two MCP surfaces in nav Backend (kora_cli/web_server.py): * GET /api/mcp/clients/list — stub returns 2 clients matching bucket §3 verbatim (github stdio, cloudflare streamable_http), both in configured_but_unconnected + auth_token_present:false. stub:true flag drives FE banner. Frontend: * pages/MCPClientsPanel.tsx — - Aggregate summary strip: total + connected/configured/error counts + italic reminder "Token values never displayed — managed in Doppler" - Per-client row: status icon + name + transport badge (with Terminal vs Network icon for stdio vs streamable_http) + status pill + tools_count when connected + KeyRound icon with green check / red x for auth presence (NEVER the value) - Expandable detail: endpoint (truncated, full on hover) + auth_token_env name + presence indicator + italic Doppler rotation hint copy + allowed_tools_regex + tools_count - STUB banner with KR-MCP-1 ST2 flip-in note - Defensive empty-state for clients: [] * lib/api.ts — MCPClientTransport + MCPClientStatus type aliases + MCPClient + MCPClientsListResponse interfaces + getMCPClients client. Tokens explicitly absent from the TS shape (only auth_token_env: string + auth_token_present: boolean). * App.tsx — /mcp-clients route + nav entry (Cable icon) between /heartbeat and /boot-status. Cable distinct from MCPPage's Plug so operator distinguishes "Kora as MCP CLIENT" (Cable) from "Kora as MCP SERVER" (Plug) at nav-glance. * DashboardPage.tsx — new MCP Clients card on row 2 (6th card now; grid bumped from lg:grid-cols-5 to lg:grid-cols-3 for cleaner 2-row visual symmetry on desktop). Card body aggregates total + connected/errors pills; headline tone goes destructive when any error/unhealthy client exists. ALL_SOURCES extended → footer count adds 1 stubbed source. Tests: tests/kora_cli/test_web_server_mcp_clients.py — 9 tests covering all 6 §4 scenarios plus security/contract guards: * Both-clients-present pin (github + cloudflare) * Per-entry shape + transport/status enum validation * SECURITY: auth_token_env regex-pinned to env-var NAME shape * SECURITY: walk response keys; reject any token-VALUE-shaped key (token, secret, access_token, api_key, password, bearer, or bare auth_token without _env/_present suffix) * Stub stays configured_but_unconnected (dashboard "0 connected" aggregate depends on it) * Stub stays auth_token_present:false (FE red-x indicator) * Cron-regression sanity 168/168 across 16 admin-panel test files (was 159/159, +9 new). tsc -b + vite build clean. FE test framework still absent (same as KR-HB-PANEL); component tests skipped per established pattern. Backend security guards + type system + manual smoke cover the contract. Co-Authored-By: Claude Opus 4.7 (1M context) --- kora_cli/web_server.py | 67 ++++ tests/kora_cli/test_web_server_mcp_clients.py | 226 +++++++++++ web/src/App.tsx | 9 + web/src/lib/api.ts | 33 ++ web/src/pages/DashboardPage.tsx | 60 ++- web/src/pages/MCPClientsPanel.tsx | 353 ++++++++++++++++++ 6 files changed, 747 insertions(+), 1 deletion(-) create mode 100644 tests/kora_cli/test_web_server_mcp_clients.py create mode 100644 web/src/pages/MCPClientsPanel.tsx diff --git a/kora_cli/web_server.py b/kora_cli/web_server.py index 3d1e961eb1c3..a373bd8ad469 100644 --- a/kora_cli/web_server.py +++ b/kora_cli/web_server.py @@ -4722,6 +4722,73 @@ async def get_heartbeat_services(): } +# --------------------------------------------------------------------------- +# MCP client picker (KR-MCP-3) — Phase 2 Feature 1 +# --------------------------------------------------------------------------- +# +# Operator-facing view of EXTERNAL MCPs Kora consumes (Kora-as-MCP-client). +# Distinct from KR-P2-C ST2's /api/mcp/servers (Kora-as-MCP-server admin +# at /mcp); this is /api/mcp/clients/list at /mcp-clients. +# +# v1 stub: 2 hardcoded clients (github + cloudflare) per bucket §3. +# CC#1's KR-MCP-1 ST2 replaces this with the real catalog read using +# the same payload shape. The ``stub: True`` flag is the explicit +# "this is sample data" signal; FE renders a banner when True. +# +# HARD CONSTRAINT (bucket §5 + ship-checklist): NEVER include token +# VALUES in the response. ``auth_token_env`` carries only the env-var +# NAME (e.g. ``KORA_MCP_GITHUB_TOKEN``); ``auth_token_present`` is a +# bool. Tokens live in Doppler — the cockpit never receives them. +# The §4 test guards against any future drift that leaks a value- +# shaped field. + + +@app.get("/api/mcp/clients/list") +async def list_mcp_clients(): + """Return the catalog of external MCPs Kora is configured to consume. + + v1 stub — pinned shape so CC#1's KR-MCP-1 ST2 can swap the body + without touching the FE. + + Per-client fields: + name — short id (github, cloudflare, etc.) + transport — "stdio" | "streamable_http" + endpoint — command line or URL (UI truncates) + status — connected / configured_but_unconnected / + error / unhealthy + auth_token_env — Doppler env-var NAME (never the value) + auth_token_present — bool: env-var resolves to non-empty? + allowed_tools_regex — null = all tools; string = filter + tools_count — int when status=connected; null otherwise + """ + return { + "clients": [ + { + "name": "github", + "transport": "stdio", + "endpoint": "npx -y @modelcontextprotocol/server-github", + "status": "configured_but_unconnected", + "auth_token_env": "KORA_MCP_GITHUB_TOKEN", + "auth_token_present": False, + "allowed_tools_regex": None, + "tools_count": None, + }, + { + "name": "cloudflare", + "transport": "streamable_http", + "endpoint": "https://mcp.cloudflare.com/sse", + "status": "configured_but_unconnected", + "auth_token_env": "KORA_MCP_CLOUDFLARE_TOKEN", + "auth_token_present": False, + "allowed_tools_regex": None, + "tools_count": None, + }, + ], + "stub": True, + "generated_at": "2026-05-22T18:00:00Z", + } + + # --------------------------------------------------------------------------- # Profile management endpoints (minimal — list/create/rename/delete + SOUL.md) # --------------------------------------------------------------------------- diff --git a/tests/kora_cli/test_web_server_mcp_clients.py b/tests/kora_cli/test_web_server_mcp_clients.py new file mode 100644 index 000000000000..9e462adff147 --- /dev/null +++ b/tests/kora_cli/test_web_server_mcp_clients.py @@ -0,0 +1,226 @@ +"""Tests for the KR-MCP-3 stub endpoint. + +Bucket §4 scenarios: + 1. GET /api/mcp/clients/list returns 200 + 2. Top-level shape (clients + generated_at + stub:true) + 3. Both expected clients present (github + cloudflare) + 4. Each client entry has the required keys + valid status/transport enums + 5. SECURITY: auth_token_env carries env-var NAME only, not value; + auth_token_present is bool; no token-value-shaped field leaks + 6. Cron-regression sanity +""" + +import re + +import pytest + + +_VALID_STATUS = { + "connected", + "configured_but_unconnected", + "error", + "unhealthy", +} +_VALID_TRANSPORT = {"stdio", "streamable_http"} +_EXPECTED_CLIENTS = {"github", "cloudflare"} + + +@pytest.fixture(autouse=True) +def _isolate_config(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setattr("kora_cli.config.get_kora_home", lambda: tmp_path) + monkeypatch.setattr( + "kora_cli.config.get_config_path", lambda: tmp_path / "config.yaml" + ) + monkeypatch.setattr( + "kora_cli.config.get_env_path", lambda: tmp_path / ".env" + ) + return tmp_path + + +# ---- 1. 200 ----------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_endpoint_returns_200(_isolate_config): + from kora_cli import web_server + + result = await web_server.list_mcp_clients() + assert isinstance(result, dict) + + +# ---- 2. Top-level shape ---------------------------------------------- + + +@pytest.mark.asyncio +async def test_response_shape_has_required_keys(_isolate_config): + from kora_cli import web_server + + result = await web_server.list_mcp_clients() + assert set(result.keys()) == {"clients", "generated_at", "stub"} + assert isinstance(result["clients"], list) + assert isinstance(result["generated_at"], str) + assert result["stub"] is True + + +# ---- 3. Expected clients ---------------------------------------------- + + +@pytest.mark.asyncio +async def test_both_expected_clients_present(_isolate_config): + """Pin the canonical 2-client stub list (github + cloudflare). CC#1's + KR-MCP-1 ST2 will replace the body with real catalog data — but + the stub list shape needs to stay stable so CC#1 can swap-and-go + without breaking the FE that ships off this PR.""" + from kora_cli import web_server + + result = await web_server.list_mcp_clients() + names = {c["name"] for c in result["clients"]} + assert names == _EXPECTED_CLIENTS + + +# ---- 4. Per-entry shape + enums -------------------------------------- + + +@pytest.mark.asyncio +async def test_each_client_entry_has_required_keys_and_valid_enums(_isolate_config): + from kora_cli import web_server + + result = await web_server.list_mcp_clients() + required = { + "name", + "transport", + "endpoint", + "status", + "auth_token_env", + "auth_token_present", + "allowed_tools_regex", + "tools_count", + } + for client in result["clients"]: + assert set(client.keys()) == required + assert client["transport"] in _VALID_TRANSPORT + assert client["status"] in _VALID_STATUS + assert isinstance(client["auth_token_env"], str) and client["auth_token_env"] + assert isinstance(client["auth_token_present"], bool) + # allowed_tools_regex: null or string + assert client["allowed_tools_regex"] is None or isinstance( + client["allowed_tools_regex"], str + ) + # tools_count: null when not connected, int when connected + if client["status"] == "connected": + assert isinstance(client["tools_count"], int) + else: + assert client["tools_count"] is None + + +# ---- 5. SECURITY: no token-value shapes ---------------------------- + + +@pytest.mark.asyncio +async def test_auth_token_env_carries_env_var_name_not_value(_isolate_config): + """Bucket hard-constraint: auth_token_env is the env-var NAME + (e.g. KORA_MCP_GITHUB_TOKEN), never the token value. Pin the + naming convention so a future drift can't silently substitute + a value into this field.""" + from kora_cli import web_server + + result = await web_server.list_mcp_clients() + for client in result["clients"]: + env_name = client["auth_token_env"] + # Env var names: UPPER_SNAKE_CASE, ascii, no whitespace. + # Real tokens are typically much longer + contain mixed case + # / dashes / dots / etc. — this regex passes for any plausible + # env-var name and fails for actual token VALUES. + assert re.match(r"^[A-Z][A-Z0-9_]*$", env_name), ( + f"{client['name']}: auth_token_env={env_name!r} doesn't look " + f"like an env-var name — possible token-value leak" + ) + # Conventionally Kora's MCP-client env vars start with KORA_MCP_*. + # Loose check so a non-Kora-prefixed env var doesn't fail the + # test, but flag when convention diverges for review. + assert "TOKEN" in env_name or "SECRET" in env_name or "KEY" in env_name, ( + f"{client['name']}: auth_token_env={env_name!r} doesn't carry a " + f"token-shaped suffix — verify it's really an env-var name" + ) + + +_TOKEN_VALUE_KEYS = re.compile( + r"^(token|secret|access[_-]?token|api[_-]?key|password|bearer|" + r"auth[_-]?token(?!_env)(?!_present))$", + re.IGNORECASE, +) + + +def _walk_keys(obj): + if isinstance(obj, dict): + for k, v in obj.items(): + yield k + yield from _walk_keys(v) + elif isinstance(obj, list): + for item in obj: + yield from _walk_keys(item) + + +@pytest.mark.asyncio +async def test_no_token_value_shaped_keys_leak_in_response(_isolate_config): + """Belt+braces: the only auth_token_* fields allowed in the response + shape are auth_token_env (NAME) + auth_token_present (BOOL). Any + other token-value-shaped key (token / secret / access_token / etc. + bare, OR auth_token without _env/_present suffix) suggests a value + leak. Catches aggregation accidents if a future MCP-client schema + grows token-bearing fields.""" + from kora_cli import web_server + + result = await web_server.list_mcp_clients() + offending: list[str] = [] + for key in _walk_keys(result): + if _TOKEN_VALUE_KEYS.search(key): + offending.append(key) + assert offending == [], ( + f"response contains token-value-shaped key(s): {offending} — " + f"tokens must NEVER appear in this surface; only env-var NAME " + f"(auth_token_env) + bool presence (auth_token_present)" + ) + + +# ---- 6. Bucket §3 stub values pinned -------------------------------- + + +@pytest.mark.asyncio +async def test_stub_returns_configured_but_unconnected_for_all_clients(_isolate_config): + """The bucket §3 stub pins both clients as configured_but_unconnected + (since stub can't actually open a connection). Dashboard "0 connected" + aggregate count depends on this; pin it so a future stub edit + can't silently flip the count.""" + from kora_cli import web_server + + result = await web_server.list_mcp_clients() + for client in result["clients"]: + assert client["status"] == "configured_but_unconnected", ( + f"{client['name']}: expected configured_but_unconnected in " + f"stub, got {client['status']!r}" + ) + + +@pytest.mark.asyncio +async def test_stub_returns_auth_token_present_false_for_all_clients(_isolate_config): + """Stub doesn't check real env vars; pins auth_token_present:false + so the FE renders the red-x indicator for all clients. CC#1's + KR-MCP-1 ST2 will resolve real env-var presence.""" + from kora_cli import web_server + + result = await web_server.list_mcp_clients() + for client in result["clients"]: + assert client["auth_token_present"] is False + + +# ---- 7. Cron-regression sanity -------------------------------------- + + +@pytest.mark.asyncio +async def test_cron_endpoint_still_works_with_mcp_clients_registered(_isolate_config): + from kora_cli import web_server + + jobs = await web_server.list_cron_jobs(profile="all") + assert isinstance(jobs, list) diff --git a/web/src/App.tsx b/web/src/App.tsx index c64b1328ee83..fb41a06b323d 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -36,6 +36,7 @@ import { MessageSquare, OctagonAlert, Package, + Cable, Plug, PowerSquare, Puzzle, @@ -81,6 +82,7 @@ import IdentityPage from "@/pages/IdentityPage"; import OperationalStatePage from "@/pages/OperationalStatePage"; import HealthRollupPage from "@/pages/HealthRollupPage"; import HeartbeatPanel from "@/pages/HeartbeatPanel"; +import MCPClientsPanel from "@/pages/MCPClientsPanel"; import BootStatusPage from "@/pages/BootStatusPage"; import DRStatePage from "@/pages/DRStatePage"; import CostStatePage from "@/pages/CostStatePage"; @@ -135,6 +137,7 @@ const BUILTIN_ROUTES_CORE: Record = { "/operational-state": OperationalStatePage, "/health-rollup": HealthRollupPage, "/heartbeat": HeartbeatPanel, + "/mcp-clients": MCPClientsPanel, "/boot-status": BootStatusPage, "/dr-state": DRStatePage, "/cost-state": CostStatePage, @@ -197,6 +200,12 @@ const BUILTIN_NAV_REST: NavItem[] = [ label: "Heartbeat", icon: Heart, }, + { + path: "/mcp-clients", + labelKey: "mcpClients", + label: "MCP Clients", + icon: Cable, + }, { path: "/boot-status", labelKey: "bootStatus", diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index a85b9bf2543f..7ef47b657ec0 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -120,6 +120,8 @@ export const api = { fetchText(`/api/runbooks/${encodeURIComponent(id)}/content`), getHeartbeatServices: () => fetchJSON("/api/heartbeat/services"), + getMCPClients: () => + fetchJSON("/api/mcp/clients/list"), getSessions: (limit = 20, offset = 0) => fetchJSON(`/api/sessions?limit=${limit}&offset=${offset}`), getSessionMessages: (id: string) => @@ -1426,3 +1428,34 @@ export interface HeartbeatServicesResponse { generated_at: string; stub: boolean; } + +// MCP client picker (KR-MCP-3) — Kora-as-MCP-client surface. +// Distinct from the existing MCPServer types (KR-P2-C ST2) which +// describe Kora-as-MCP-server admin state. SECURITY CONTRACT: the +// shape carries auth_token_env (variable NAME only) + +// auth_token_present (bool); never the token VALUE. The FE renders +// presence/absence only — never expose values in tooltips, copy +// buttons, dev-console, or anywhere else. +export type MCPClientTransport = "stdio" | "streamable_http"; +export type MCPClientStatus = + | "connected" + | "configured_but_unconnected" + | "error" + | "unhealthy"; + +export interface MCPClient { + name: string; + transport: MCPClientTransport; + endpoint: string; + status: MCPClientStatus; + auth_token_env: string; + auth_token_present: boolean; + allowed_tools_regex: string | null; + tools_count: number | null; +} + +export interface MCPClientsListResponse { + clients: MCPClient[]; + stub: boolean; + generated_at: string; +} diff --git a/web/src/pages/DashboardPage.tsx b/web/src/pages/DashboardPage.tsx index 9e756533f3da..df6298d93187 100644 --- a/web/src/pages/DashboardPage.tsx +++ b/web/src/pages/DashboardPage.tsx @@ -9,6 +9,7 @@ import { BookOpenCheck, CheckCircle2, DollarSign, + Cable, Heart, HeartPulse, Hourglass, @@ -45,6 +46,7 @@ import type { HeartbeatStatus, KoraAssignedSeaTicketsResponse, KoraControlObservedStateResponse, + MCPClientsListResponse, OperationalStateResponse, RunbooksManifest, } from "@/lib/api"; @@ -70,6 +72,8 @@ interface DashboardData { runbooks: LoadStatus; // KR-HB-PANEL — backend service heartbeat (stub) heartbeat: LoadStatus; + // KR-MCP-3 — installed external MCP clients (stub) + mcpClients: LoadStatus; } const INITIAL_DATA: DashboardData = { @@ -85,6 +89,7 @@ const INITIAL_DATA: DashboardData = { recentEvents: { state: "loading" }, runbooks: { state: "loading" }, heartbeat: { state: "loading" }, + mcpClients: { state: "loading" }, }; const HEALTH_TONE: Record = { @@ -548,6 +553,41 @@ function HeartbeatCardBody({ data }: { data: HeartbeatServicesResponse }) { ); } +function MCPClientsCardBody({ data }: { data: MCPClientsListResponse }) { + // Aggregate per-status counts for the dashboard tile. Matches the + // panel's summary strip + the bucket §3(c) headline shape + // ("2 MCPs configured / 0 connected / 0 errors"). Token presence + // intentionally NOT surfaced here — the panel detail view shows + // the per-client presence/absence indicator; dashboard scans for + // "is anything wrong with the MCP fleet" at the status level. + const total = data.clients.length; + const connected = data.clients.filter((c) => c.status === "connected").length; + const errors = data.clients.filter( + (c) => c.status === "error" || c.status === "unhealthy", + ).length; + const headlineClass = errors > 0 ? "text-destructive" : "text-foreground"; + return ( +
+
+ {total} + + MCP{total === 1 ? "" : "s"} configured + +
+
+ 0 ? "success" : "outline"}> + {connected} connected + + {errors > 0 && ( + + {errors} error{errors === 1 ? "" : "s"} + + )} +
+
+ ); +} + // ── Hero ───────────────────────────────────────────────────────────────── interface HealthHeroProps { @@ -688,6 +728,8 @@ export default function DashboardPage() { loadOne("runbooks", () => api.getRunbooks()), // KR-HB-PANEL loadOne("heartbeat", () => api.getHeartbeatServices()), + // KR-MCP-3 + loadOne("mcpClients", () => api.getMCPClients()), ]); if (isManual) { setRefreshing(false); @@ -718,6 +760,7 @@ export default function DashboardPage() { data.recentEvents, data.runbooks, data.heartbeat, + data.mcpClients, ]; const anyStubbed = ALL_SOURCES.some((s) => isStubbed(s)); @@ -855,7 +898,7 @@ export default function DashboardPage() { {/* ── Row 2: newer surfaces (KR-P2-DASHBOARD-V2 + KR-HB-PANEL) ── */} -
+
)} + + + void loadOne("mcpClients", () => api.getMCPClients()) + } + > + {data.mcpClients.state === "ready" && ( + + )} +
{/* ── Bottom strip: links to other (non-admin-panel) pages ─────── */} diff --git a/web/src/pages/MCPClientsPanel.tsx b/web/src/pages/MCPClientsPanel.tsx new file mode 100644 index 000000000000..cf5ecfeceed0 --- /dev/null +++ b/web/src/pages/MCPClientsPanel.tsx @@ -0,0 +1,353 @@ +import { useCallback, useEffect, useState } from "react"; +import { + AlertOctagon, + AlertTriangle, + CheckCircle2, + ChevronDown, + ChevronRight, + HelpCircle, + KeyRound, + Network, + Plug, + PowerOff, + RefreshCw, + Terminal, + XCircle, +} from "lucide-react"; +import { Badge } from "@nous-research/ui/ui/components/badge"; +import { Button } from "@nous-research/ui/ui/components/button"; +import { Spinner } from "@nous-research/ui/ui/components/spinner"; +import { H2 } from "@/components/NouiTypography"; +import { Card, CardContent } from "@/components/ui/card"; +import { Toast } from "@/components/Toast"; +import { useToast } from "@/hooks/useToast"; +import { api } from "@/lib/api"; +import type { + MCPClient, + MCPClientStatus, + MCPClientsListResponse, + MCPClientTransport, +} from "@/lib/api"; + +const STATUS_TONE: Record = { + connected: "success", + configured_but_unconnected: "outline", + unhealthy: "warning", + error: "destructive", +}; + +const STATUS_LABEL: Record = { + connected: "connected", + configured_but_unconnected: "configured · unconnected", + unhealthy: "unhealthy", + error: "error", +}; + +function StatusIcon({ status }: { status: MCPClientStatus }) { + switch (status) { + case "connected": + return ; + case "configured_but_unconnected": + return ; + case "unhealthy": + return ; + case "error": + return ; + } +} + +function TransportIcon({ transport }: { transport: MCPClientTransport }) { + return transport === "stdio" ? ( + + ) : ( + + ); +} + +function truncateEndpoint(value: string, max = 60): string { + if (value.length <= max) return value; + return value.slice(0, max) + "…"; +} + +interface MCPClientRowProps { + client: MCPClient; + expanded: boolean; + onToggle: () => void; +} + +function MCPClientRow({ client, expanded, onToggle }: MCPClientRowProps) { + return ( + + + + + {expanded && ( +
+
+ + endpoint + + + {truncateEndpoint(client.endpoint, 100)} + +
+
+ + auth_token_env + + {client.auth_token_env} + + {client.auth_token_present ? ( + <> + + present in Doppler + + ) : ( + <> + + missing + + )} + +
+ {/* Token VALUE never rendered — only the env-var name + presence */} +
+ Token values live in Doppler and are never displayed here. + Use{" "} + + doppler secrets set {client.auth_token_env} + {" "} + to rotate. +
+
+ + allowed_tools_regex + + + {client.allowed_tools_regex ?? ( + + null (all tools allowed) + + )} + +
+
+ + tools_count + + + {client.tools_count === null + ? "— (not connected)" + : client.tools_count} + +
+
+ )} +
+
+ ); +} + +export default function MCPClientsPanel() { + const [data, setData] = useState(null); + const [loadError, setLoadError] = useState(null); + const [refreshing, setRefreshing] = useState(false); + const [expandedNames, setExpandedNames] = useState>(new Set()); + const { toast, showToast } = useToast(); + + const loadClients = useCallback( + (isManual: boolean) => { + if (isManual) setRefreshing(true); + setLoadError(null); + api + .getMCPClients() + .then((resp) => setData(resp)) + .catch((e: unknown) => { + const msg = e instanceof Error ? e.message : String(e); + setLoadError(msg); + showToast(`Failed to load MCP clients: ${msg}`, "error"); + }) + .finally(() => { + if (isManual) setRefreshing(false); + }); + }, + [showToast], + ); + + useEffect(() => { + loadClients(false); + }, [loadClients]); + + const toggleExpand = useCallback((name: string) => { + setExpandedNames((prev) => { + const next = new Set(prev); + if (next.has(name)) next.delete(name); + else next.add(name); + return next; + }); + }, []); + + if (data === null && !loadError) { + return ( +
+ +
+ ); + } + + // Aggregate counts for the header summary strip. + const counts = data + ? { + connected: data.clients.filter((c) => c.status === "connected").length, + configured: data.clients.filter( + (c) => c.status === "configured_but_unconnected", + ).length, + errors: data.clients.filter( + (c) => c.status === "error" || c.status === "unhealthy", + ).length, + } + : null; + + return ( +
+ + +
+
+

Installed MCPs

+

+ External MCP servers Kora is configured to consume. +

+
+ +
+ + {loadError && ( + + + +
+
Failed to load MCP clients
+
{loadError}
+
+
+
+ )} + + {data?.stub && ( + + + +
+
+ STUB — real data wires in via KR-MCP-1 ST2 +
+
+ Values shown are hardcoded sample data, not live catalog + state. CC#1's KR-MCP-1 ST2 swaps the endpoint body to + project from the live kora_mcp/ pool. +
+
+
+
+ )} + + {data && ( + <> + {/* ── Aggregate summary strip ─────────────────────────── */} + + + + + {data.clients.length} MCP{data.clients.length === 1 ? "" : "s"}{" "} + configured + + {counts && ( + <> + + + {counts.connected} connected + + + + {counts.configured} configured · unconnected + + {counts.errors > 0 && ( + + + {counts.errors} error{counts.errors === 1 ? "" : "s"} + + )} + + )} + + Token values never displayed — managed in Doppler. + + + + + {/* ── Clients list ──────────────────────────────────── */} + {data.clients.length === 0 ? ( + + + + No MCP clients configured. + + + ) : ( +
+ {data.clients.map((c) => ( + toggleExpand(c.name)} + /> + ))} +
+ )} + + )} +
+ ); +}