Skip to content
This repository was archived by the owner on May 26, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions kora_cli/web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
# ---------------------------------------------------------------------------
Expand Down
226 changes: 226 additions & 0 deletions tests/kora_cli/test_web_server_mcp_clients.py
Original file line number Diff line number Diff line change
@@ -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)
9 changes: 9 additions & 0 deletions web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
MessageSquare,
OctagonAlert,
Package,
Cable,
Plug,
PowerSquare,
Puzzle,
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -135,6 +137,7 @@ const BUILTIN_ROUTES_CORE: Record<string, ComponentType> = {
"/operational-state": OperationalStatePage,
"/health-rollup": HealthRollupPage,
"/heartbeat": HeartbeatPanel,
"/mcp-clients": MCPClientsPanel,
"/boot-status": BootStatusPage,
"/dr-state": DRStatePage,
"/cost-state": CostStatePage,
Expand Down Expand Up @@ -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",
Expand Down
33 changes: 33 additions & 0 deletions web/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,8 @@ export const api = {
fetchText(`/api/runbooks/${encodeURIComponent(id)}/content`),
getHeartbeatServices: () =>
fetchJSON<HeartbeatServicesResponse>("/api/heartbeat/services"),
getMCPClients: () =>
fetchJSON<MCPClientsListResponse>("/api/mcp/clients/list"),
getSessions: (limit = 20, offset = 0) =>
fetchJSON<PaginatedSessions>(`/api/sessions?limit=${limit}&offset=${offset}`),
getSessionMessages: (id: string) =>
Expand Down Expand Up @@ -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;
}
Loading