From bab5c7a3e32ef60d08c6017e5a49837f6ba1f854 Mon Sep 17 00:00:00 2001 From: Alexander Date: Sat, 23 May 2026 19:35:12 -0400 Subject: [PATCH] feat(dash): ADR-0013 per-agent MCP clients view (#263) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #206 (wire MCP page to real backend) and #263. - ui/src/api/endpoints.ts: agentMcpClients (list) + agentMcpClient(name). - ui/src/api/hooks/useAgentMcpClients.ts: useAgentMcpClients with a baked-in mock fallback so the dashboard renders against #287 + pre-#293 builds without 404 noise. Mock matches ADR-0013 §2 worked example (Hermes with hal0-admin + hal0-memory + filesystem + opt-in github). - ui/src/dash/mcp.jsx: mode toggle Servers | Clients at the top of the McpView. Clients view = one card per installed agent, each showing every [mcp.servers.*] with a green/yellow/red health dot, bundled/disabled chips, the three-tier tool classification chips (allow=ok, gated=amber, blocked=err), and an auth chip surfacing bearer-from-env status without ever rendering the token value itself. - ui/tests/e2e/specs/mcp-clients-v3.spec.ts: 4 skip-marked Playwright cases (mode toggle, hermes card, chip render, no-token-leak); unskip when the v3 spec wave goes live. ADR-0013 §8: read-only in v0.3 alpha; v0.3 stable adds the editor. Verified: npm run build + npm run typecheck both clean. Depends on #293 (backend) — open. Co-Authored-By: Claude Opus 4.7 (1M context) --- ui/src/api/endpoints.ts | 5 + ui/src/api/hooks/index.ts | 1 + ui/src/api/hooks/useAgentMcpClients.ts | 141 +++++++++++++++++++++ ui/src/dash/mcp.jsx | 148 +++++++++++++++++++++- ui/tests/e2e/specs/mcp-clients-v3.spec.ts | 85 +++++++++++++ 5 files changed, 376 insertions(+), 4 deletions(-) create mode 100644 ui/src/api/hooks/useAgentMcpClients.ts create mode 100644 ui/tests/e2e/specs/mcp-clients-v3.spec.ts diff --git a/ui/src/api/endpoints.ts b/ui/src/api/endpoints.ts index 5c20a8e..fad4d2c 100644 --- a/ui/src/api/endpoints.ts +++ b/ui/src/api/endpoints.ts @@ -53,6 +53,11 @@ export const ENDPOINTS = { // ── Hardware ───────────────────────────────────────────────────── hardware: '/api/hardware', + // ── Agents — MCP-client allow-list (ADR-0013) ──────────────────── + agentMcpClients: '/api/agents/mcp/clients', + agentMcpClient: (name: string) => + `/api/agents/mcp/clients/${encodeURIComponent(name)}`, + // ── Logs (HTTP historical + SSE tail + WS lemond) ──────────────── logs: '/api/logs', logsStream: '/api/logs/stream', diff --git a/ui/src/api/hooks/index.ts b/ui/src/api/hooks/index.ts index 1b589b3..767a5d9 100644 --- a/ui/src/api/hooks/index.ts +++ b/ui/src/api/hooks/index.ts @@ -14,3 +14,4 @@ export * from './useLogs' export * from './useUpdates' export * from './useSecrets' export * from './useFirstRun' +export * from './useAgentMcpClients' diff --git a/ui/src/api/hooks/useAgentMcpClients.ts b/ui/src/api/hooks/useAgentMcpClients.ts new file mode 100644 index 0000000..4732a83 --- /dev/null +++ b/ui/src/api/hooks/useAgentMcpClients.ts @@ -0,0 +1,141 @@ +// hal0 v3 dashboard — per-agent MCP allow-list hooks (ADR-0013 §8). +// +// v0.3 alpha is read-only per ADR-0013 §8; v0.3 stable will add a PUT +// hook + an MCPServerConfig editor. The endpoint shape mirrors the +// schemas in src/hal0/config/schema.py (see #287 + #293). +// +// The list endpoint returns every agent that has a TOML on disk under +// /etc/hal0/agents/.toml; the per-agent endpoint returns that +// agent's full AgentConfig + a live health dot per server. +// +// Until the backend route ships, the hooks fall back to a baked-in +// mock so the dashboard panel renders without 404s. The mock matches +// the ADR-0013 §2 worked example (Hermes with hal0-admin + hal0-memory +// + filesystem + opt-in github). + +import { useQuery } from '@tanstack/react-query' +import { apiGet, Hal0Error } from '../client' +import { ENDPOINTS } from '../endpoints' + +export type ToolClassification = 'allow' | 'gated' | 'blocked' + +export interface ToolPolicy { + allow: string[] + gated: string[] + blocked: string[] +} + +export interface MCPClientAuth { + kind: 'none' | 'bearer-from-env' + env: string | null + /** Presence of the token at startup — surfaced without ever rendering the value. */ + tokenStatus: 'present' | 'missing' | 'not-needed' +} + +export interface MCPClientServer { + name: string + url: string | null + enabled: boolean + builtin: boolean + auth: MCPClientAuth + tools: ToolPolicy + /** Live ping dot — green = reachable, yellow = degraded, red = unreachable. */ + health: 'green' | 'yellow' | 'red' | 'unknown' +} + +export interface AgentMCPClientView { + name: string + display: string + workspace: string + servers: MCPClientServer[] +} + +export interface AgentMCPClientList { + agents: AgentMCPClientView[] +} + +// ── Mock fallback (matches ADR-0013 §2 worked example) ───────────── + +const MOCK_LIST: AgentMCPClientList = { + agents: [ + { + name: 'hermes', + display: 'Hermes-Agent', + workspace: '/var/lib/hal0/agents/hermes/workspace', + servers: [ + { + name: 'hal0-admin', + url: null, + enabled: true, + builtin: true, + auth: { kind: 'none', env: null, tokenStatus: 'not-needed' }, + tools: { allow: [], gated: [], blocked: [] }, + health: 'green', + }, + { + name: 'hal0-memory', + url: null, + enabled: true, + builtin: true, + auth: { kind: 'none', env: null, tokenStatus: 'not-needed' }, + tools: { allow: [], gated: [], blocked: [] }, + health: 'green', + }, + { + name: 'filesystem', + url: 'stdio:///usr/lib/hal0/mcp/filesystem-server', + enabled: true, + builtin: false, + auth: { kind: 'none', env: null, tokenStatus: 'not-needed' }, + tools: { + allow: ['read_file', 'list_directory', 'search_files'], + gated: ['write_file'], + blocked: [], + }, + health: 'green', + }, + { + name: 'github', + url: 'https://api.github.com/mcp', + enabled: false, + builtin: false, + auth: { + kind: 'bearer-from-env', + env: 'HAL0_AGENT_HERMES_GITHUB_TOKEN', + tokenStatus: 'missing', + }, + tools: { + allow: ['list_issues', 'get_pr', 'search_code'], + gated: ['create_pr', 'post_issue_comment'], + blocked: ['delete_repo', 'delete_branch'], + }, + health: 'unknown', + }, + ], + }, + ], +} + +async function fetchOrMock(): Promise { + try { + return await apiGet(ENDPOINTS.agentMcpClients) + } catch (err) { + if (err instanceof Hal0Error && err.status === 404) { + // Backend route lands as a v0.3 follow-up; until then the + // dashboard renders the mock so the read-only alpha works + // against #287 builds + a stale Hermes install. + return MOCK_LIST + } + throw err + } +} + +const POLL_MS = 30_000 + +export function useAgentMcpClients() { + return useQuery({ + queryKey: ['agents', 'mcp', 'clients'], + queryFn: fetchOrMock, + refetchInterval: POLL_MS, + }) +} diff --git a/ui/src/dash/mcp.jsx b/ui/src/dash/mcp.jsx index 0210652..2a0eeed 100644 --- a/ui/src/dash/mcp.jsx +++ b/ui/src/dash/mcp.jsx @@ -3,6 +3,8 @@ // carries a live "call timeline" — 60s of tool calls scrolling right→left, // recent ones glowing amber. Page feels like a monitor, not a list. +import { useAgentMcpClients } from '@/api/hooks/useAgentMcpClients' + const { useState: useStateM, useEffect: useEffectM, useRef: useRefM, useMemo: useMemoM, useCallback: useCallbackM } = React; // ─── Live activity bus ─────────────────────────────────────────────── @@ -383,6 +385,10 @@ function PlusIcon() { // ─── Main view ────────────────────────────────────────────────────── function McpView() { + // ADR-0013 §8 — top-level mode toggle: servers (existing) | clients + // (new per-agent view). Defaults to "servers" so existing nav stays + // unchanged. + const [mode, setMode] = useStateM("servers"); const [servers, setServers] = useStateM(MCP_SERVERS); const [filter, setFilter] = useStateM("all"); const [menuId, setMenuId] = useStateM(null); @@ -426,13 +432,40 @@ function McpView() {
Agents · v0.3 -

MCP Servers

+

{mode === "servers" ? "MCP Servers" : "MCP Clients"}

- hal0 hosts an arbitrary number of MCP servers · clients connect over {MCP_HOST_BASE}/mcp/* - - + {mode === "servers" + ? hal0 hosts an arbitrary number of MCP servers · clients connect over {MCP_HOST_BASE}/mcp/* + : per-agent allow-lists · ADR-0013 · read-only in v0.3 alpha + } + {mode === "servers" && } + {mode === "servers" && }
+ {/* ADR-0013 §8 mode switch — Servers (what we host) | Clients + (what our bundled agents are allowed to reach out to). */} +
+
+ + +
+
+ + {mode === "clients" ? : null} + {mode !== "servers" ? null : ( + <> {/* KPI strip */} @@ -534,10 +567,117 @@ function McpView() { {/* How-to-connect modal */} setTeachOpen(false)} /> + + )} +
+ ); +} + +// ─── ADR-0013 §8 per-agent Clients view (read-only alpha) ────────────── +// +// One card per installed agent (hermes, pi-coder, …). Each card lists +// the [mcp.servers.*] entries from the agent's TOML, the three-color +// chip per server, the auth.kind + token status (no token rendering), +// and the per-tool classification chips. +function McpClientsView() { + const list = useAgentMcpClients(); + if (list.isLoading) { + return
Loading agent allow-lists…
; + } + if (list.isError) { + return ( +
+ Could not load agent allow-lists: {String(list.error?.message || "unknown")} +
+ ); + } + const agents = list.data?.agents || []; + if (agents.length === 0) { + return ( +
+ No agents installed. Install Hermes via hal0 agent install hermes to see this view populated. +
+ ); + } + return ( +
+ {agents.map(a => )} +
+ ); +} + +function AgentMcpCard({ agent }) { + return ( +
+
+ {agent.display || agent.name} + {agent.name} + + workspace: {agent.workspace} + +
+
+ {agent.servers.map(s => )} +
+
+ ); +} + +function AgentMcpServerRow({ server }) { + const healthColor = { + green: "var(--ok, #6c6)", + yellow: "var(--warn, #cb6)", + red: "var(--err, #c66)", + unknown: "var(--fg-4)", + }[server.health] || "var(--fg-4)"; + return ( +
+
+ + {server.name} + {server.builtin && builtin} + {!server.enabled && disabled} + {server.url && {server.url}} + + + +
+
+ {server.tools.allow.map(t => ( + {t} + ))} + {server.tools.gated.map(t => ( + {t} + ))} + {server.tools.blocked.map(t => ( + {t} + ))} + {server.tools.allow.length + server.tools.gated.length + server.tools.blocked.length === 0 && ( + + no tools listed — default-deny means nothing callable + + )} +
); } +function AuthChip({ auth }) { + if (auth.kind === "none") { + return no-auth; + } + const tone = auth.tokenStatus === "present" ? "ok" : auth.tokenStatus === "missing" ? "err" : ""; + return ( + + bearer · {auth.tokenStatus} + + ); +} + // ─── Empty-clients teaching state ─────────────────────────────────── function NoClientsState({ onTeach }) { return ( diff --git a/ui/tests/e2e/specs/mcp-clients-v3.spec.ts b/ui/tests/e2e/specs/mcp-clients-v3.spec.ts new file mode 100644 index 0000000..10769b8 --- /dev/null +++ b/ui/tests/e2e/specs/mcp-clients-v3.spec.ts @@ -0,0 +1,85 @@ +/** + * mcp-clients-v3 — ADR-0013 §8 per-agent view on /agents/mcp. + * + * Pins (skip-marked matching the v3 pattern): + * - mode toggle Servers | Clients renders + switches. + * - Clients view shows the hermes card with the four sample servers. + * - allow / gated / blocked chips render with the right verdict. + * - bearer-from-env tokens never render the actual value. + */ +import { test, expect, json } from '../fixtures/apiMock' + +const MOCK_LIST = { + agents: [ + { + name: 'hermes', + display: 'Hermes-Agent', + workspace: '/var/lib/hal0/agents/hermes/workspace', + servers: [ + { + name: 'hal0-admin', + url: null, + enabled: true, + builtin: true, + auth: { kind: 'none', env: null, tokenStatus: 'not-needed' }, + tools: { allow: [], gated: [], blocked: [] }, + health: 'green', + }, + { + name: 'github', + url: 'https://api.github.com/mcp', + enabled: false, + builtin: false, + auth: { + kind: 'bearer-from-env', + env: 'HAL0_AGENT_HERMES_GITHUB_TOKEN', + tokenStatus: 'missing', + }, + tools: { + allow: ['list_issues'], + gated: ['create_pr'], + blocked: ['delete_repo'], + }, + health: 'unknown', + }, + ], + }, + ], +} + +test.describe('MCP Clients view (ADR-0013 §8)', () => { + test.skip('mode toggle renders both Servers and Clients', async ({ page }) => { + await page.route('**/api/agents/mcp/clients', (route) => json(route, MOCK_LIST)) + await page.goto('/#agents/mcp') + await expect(page.locator('.mcp-tab', { hasText: 'Servers' })).toBeVisible() + await expect(page.locator('.mcp-tab', { hasText: 'Clients' })).toBeVisible() + }) + + test.skip('clients tab shows hermes card + servers', async ({ page }) => { + await page.route('**/api/agents/mcp/clients', (route) => json(route, MOCK_LIST)) + await page.goto('/#agents/mcp') + await page.locator('.mcp-tab', { hasText: 'Clients' }).click() + await expect(page.locator('.view')).toContainText('Hermes-Agent') + await expect(page.locator('.view')).toContainText('hal0-admin') + await expect(page.locator('.view')).toContainText('github') + }) + + test.skip('tool chips render with allow/gated/blocked verdicts', async ({ page }) => { + await page.route('**/api/agents/mcp/clients', (route) => json(route, MOCK_LIST)) + await page.goto('/#agents/mcp') + await page.locator('.mcp-tab', { hasText: 'Clients' }).click() + await expect(page.locator('.chip', { hasText: 'list_issues' })).toBeVisible() + await expect(page.locator('.chip', { hasText: 'create_pr' })).toBeVisible() + await expect(page.locator('.chip', { hasText: 'delete_repo' })).toBeVisible() + }) + + test.skip('bearer auth shown without rendering token value', async ({ page }) => { + await page.route('**/api/agents/mcp/clients', (route) => json(route, MOCK_LIST)) + await page.goto('/#agents/mcp') + await page.locator('.mcp-tab', { hasText: 'Clients' }).click() + // Token value never appears; just the env-var name (in title attr) + // and the status word. + await expect(page.locator('.view')).toContainText('bearer · missing') + await expect(page.locator('.view')).not.toContainText(/ghp_\w+/) + }) +})