Find, install and enable MCP servers mid-session. Search tools across installed and marketplace servers, approve installs with full provenance, and manage…

Find, install and enable MCP servers in the middle of a session, on any MCP host. agent-discover is one local daemon that searches the official MCP Registry, npm and PyPI, installs a server after you approve the exact command and its provenance, and then exposes its tools to your host without a config edit or a restart. It also guards what it installs: tool definitions are pinned and a server whose tools change is quarantined, secrets live in the OS keychain, and every action is audited.
Current hosts (Claude Code, Codex, the Anthropic and OpenAI APIs) have their own tool search, so agent-discover is not mainly about saving prompt tokens any more. What a host's tool search cannot do is work with a server you have not installed yet. Adding a new MCP server still means editing the host's config and restarting the session. agent-discover removes that step:
install_server shows you the exact command or URL, the env and header names, the pinned version and the provenance checks (registry namespace, npm mcpName, PyPI, OCI label). Nothing runs until you accept.native mode (the default) every enabled server's tools are listed as <server>__<tool> and agent-discover sends notifications/tools/list_changed on each change, so the host's native tool search, permission prompts and alwaysLoad settings keep working. In proxy mode only eight meta tools are listed and everything goes through call_tool, for hosts with weak or no tool search.| Static MCP config | With agent-discover | |
|---|---|---|
| Discovery | Know the server name in advance | Search the registries by need, with provenance |
| Installation | Edit config, restart the session | One tool call, you approve the exact command |
| Enablement | All configured servers always load | Enable and disable mid-session; the index stays searchable |
| Secrets | Keys in config files or env | OS keychain (or an encrypted file), injected on connect |
| Changed tools | Silent | Pinned hashes, drift quarantines the server until you approve |
| Visibility | Per-host logs | /discover pane, health, per-tool metrics, audit log, OTel |
Install the plugin. It runs the stdio shim as the MCP server, adds the find and install skills and a Claude Code mod (function hooks) with the management UI:
claude plugin marketplace add keshrath/agent-discover
claude plugin install agent-discover@agent-discover

New to the pane? The tutorial walks through it with screenshots.
/discover opens the agent-discover pane (docked beside the transcript in the fullscreen layout, above the prompt otherwise; also in the desktop Code tab and VS Code). Keys 1-4 switch tabs, and the focus lands on the next step (Approve, Enable, the search field, a missing secret, Install). Servers: every installed server with its state; open one for its config (command or URL, tags, source and registry status, package; env and header key names only), a secrets editor (set or delete, values never shown), its tools with input schemas and per-tool metrics, health check and error reset, the quarantine diff with Approve / Keep disabled, OAuth sign-in (the authorize URL as a link), and Enable / Disable / Re-index / Uninstall. Browse: search the registries, review the install plan (exact command or URL, provenance, warnings, required keys with secret inputs) and install. Logs (recent proxied calls) and Audit (filter by server and action, paged). Questions upstream servers ask (elicitation) appear on top./discover <what you need> opens Browse with the results.MCP 2/6 enabled · 1 to review (enabled of installed, and how many servers need a look), a toast when a server is quarantined, goes unhealthy or asks a question, and a band above the prompt that shows only while something needs you and the pane is closed.search_tools before saying a capability is missing.agent-discover entry from ~/.claude.json so tools do not appear twice.{
"mcpServers": {
"agent-discover": { "command": "npx", "args": ["-y", "agent-discover@^3"] }
}
}
Hosts with Streamable HTTP can instead point at http://127.0.0.1:3424/mcp when the daemon already runs. Cursor, Codex, VS Code, Claude Desktop and the rest: docs/SETUP.md.
Other hosts use agent-discover through its MCP tools (markdown results; the MCP Apps widget where the host renders it). Approving a quarantined server or installing without an elicitation-capable client happens in Claude Code's /discover pane, or an operator sets AGENT_DISCOVER_ALLOW_UNCONFIRMED_INSTALL=1.
git clone https://github.com/keshrath/agent-discover.git
cd agent-discover
npm install
npm run build
node dist/index.js daemon
One daemon per machine, agent-discover daemon, listens on 127.0.0.1:3424 and serves the REST API (/api/*, what the Claude Code pane talks to) and MCP Streamable HTTP at /mcp. /mcp speaks the 2026-07-28 protocol and the 2025 sessionful protocol on the same endpoint.
The default bin agent-discover is a thin stdio shim: it checks /api/health, starts the daemon if it is not running (lockfile-guarded, detached) and relays stdio to /mcp. Every host and every session shares the one daemon, so connection and enablement state has a single owner. The daemon exits after 30 minutes with no open HTTP exchanges (AGENT_DISCOVER_IDLE_MS).
Details: docs/ARCHITECTURE.md.
| Tool | Purpose |
|---|---|
search_servers | Installed servers plus public registry / npm / PyPI matches, for something not installed yet. |
install_server | Install by exact registry, npm or PyPI name (version pinned) or from a manual command or URL. Asks you to confirm the plan, then indexes. |
enable_server | Expose a server's tools to the host. A quarantined server asks you to review a diff and re-approve first. |
disable_server | Stop exposing the tools. The server stays installed and searchable. |
server_status | Installed / indexed / enabled / connected / quarantined, tool counts, flagged tools, health (check_health runs a live probe). |
search_tools | Batch search (up to 10 queries) across the tool index of every installed server, enabled or not. |
get_tool | Full definition of one indexed tool by server and name. |
call_tool | Call any indexed tool and return the upstream result unchanged. The way to call tools in proxy mode. |
Prompts discover, install and status appear as slash commands in hosts that surface MCP prompts. All tools except call_tool declare an outputSchema and return structuredContent plus a markdown rendering.
MCP Apps widget. Tool results carry ui://agent-discover/app.html, a single widget that renders search results, server cards, install consent and a tool tester. Claude Desktop, claude.ai and VS Code render it. Claude Code does not render MCP Apps yet and shows the markdown instead.
Full schemas: docs/API.md.
agent-discover sits between your host and MCP servers it did not write, so it assumes their descriptions are hostile. Full model: docs/SECURITY.md.
confirm argument.GET /api/audit.OTEL_EXPORTER_OTLP_ENDPOINT or AGENT_DISCOVER_OTEL=1.iss check), with sign-in handed to you as a URL; agent-discover never opens it itself.Not covered: sandboxing upstream stdio servers. They run with your user's privileges.
search_tools ranks with SQLite FTS5 (Porter stemming, fielded BM25 over name, description and argument names), typo repair, server routing and a small usage prior. A local or OpenAI embedding provider is opt-in and adds dense scores.
Measured on the retrieval bench in bench/retrieval/: 50 real MCP servers, 1674 tools, 468 hand-labelled queries (paraphrase, task, cross-server, multi-step, typo, German, and unanswerable). All tuning used the dev split; these numbers are the held-out test split (269 answerable queries).
| Ranker | R@1 | R@5 | R@10 | MRR |
|---|---|---|---|---|
| plain BM25 (about what hosted BM25 tool search does) | 0.305 | 0.522 | 0.618 | 0.430 |
| agent-discover 1.x | 0.307 | 0.561 | 0.642 | 0.446 |
| agent-discover 2.0, zero config | 0.338 | 0.576 | 0.661 | 0.474 |
2.0 + multilingual-e5-small (opt-in) | 0.381 | 0.618 | 0.722 | 0.522 |
Read this honestly:
npm install @huggingface/transformers by hand (about 130 MB model, about 3 minutes to index 1674 tools on one thread) and is not run in CI.Methodology, per-category results and how to run it: bench/retrieval/README.md. CI runs npm run bench:retrieval -- --check.
updated_since sync, deprecated and deleted status tracking, and exact-name installs. A server whose registry entry was deleted is flagged in server_status.input_required and 2025 elicitation/create requests are relayed to your client./discover pane, the MCP Apps widget (Claude Desktop, claude.ai, VS Code) and markdown results for every other host (OpenCode, Cursor, Codex, ...). There is no web dashboard since 3.0.AGENT_DISCOVER_SETUP_FILE) listing servers to ensure installed at daemon start.The common ones. The complete list is in docs/API.md.
| Variable | Default | Description |
|---|---|---|
AGENT_DISCOVER_PORT | 3424 | Daemon port |
AGENT_DISCOVER_HOST | 127.0.0.1 | Listen address |
AGENT_DISCOVER_DATA_DIR | platform data dir | Data directory (see Upgrading from 1.x) |
AGENT_DISCOVER_DB | agent-discover.db in it | SQLite file path |
AGENT_DISCOVER_MODE | native | native lists enabled servers' tools; proxy lists only the meta tools |
AGENT_DISCOVER_IDLE_MS | 1800000 | Daemon idle exit (0 = never) |
AGENT_DISCOVER_SETUP_FILE | unset | Declarative server list synced at start |
AGENT_DISCOVER_EMBEDDING_PROVIDER | none | local or openai adds semantic ranking |
AGENT_DISCOVER_SECRETS | auto | keyring or file forces the secret backend |
From 2.x: 3.0 removes the web dashboard on port 3424 (and its WebSocket, tester, presets and transient-server routes) and agent-desk support. Manage servers in Claude Code with /discover; other hosts keep the MCP tools. Host configs move to agent-discover@^3. Details in CHANGELOG.md.
From 1.x: 2.0 was a breaking release. The single registry tool is replaced by the eight tools above, REST routes were renamed, the setup file key auto_activate is now enabled, secrets move to the keychain, and the default bin is the shim. The database migrates itself and moves from ~/.claude to the per-user data directory (%LOCALAPPDATA%\agent-discover, ~/Library/Application Support/agent-discover, or $XDG_DATA_HOME/agent-discover / ~/.local/share/agent-discover; AGENT_DISCOVER_DATA_DIR overrides) on first start, so stop any running 1.x process first. Full list in CHANGELOG.md.
structuredContent to the model as JSON text instead of the markdown content (upstream behaviour, anthropics/claude-code#55677 closed as not planned, see also #15412). Other hosts and the widget use the markdown / UI.npm test # 218 tests across 27 files (+9 e2e skipped unless AGENT_DISCOVER_E2E=1, +16 pane e2e)
npm run check # typecheck + lint + format + test
npm run bench:retrieval
npm run plugin:check # claude plugin validate + the pane's 9 plugin tests
npm run e2e:claude # the /discover pane in the real Claude Code CLI (inline and docked), screenshots to ~/.claude/tmp/pane-shots
/discover pane step by step, with screenshotsMIT, see LICENSE
hooks/register.tsx 879 lines1// agent-discover inside Claude Code: the /discover pane (servers, server detail, browse
2// and install, logs, audit, upstream questions), a status line entry, toasts, an
3// attention band and one context block telling the model what is enabled. Everything
4// goes through the daemon's REST API; mutations carry its per-launch token. Secret
5// values typed into the pane are sent and never kept in state.
6import { atom, read, update } from 'claude-code';
7import type { EngineInterface, Register } from 'claude-code';
8
9import type {
10 AgentDiscoverAudit,
11 AgentDiscoverBrowse,
12 AgentDiscoverConfigKey,
13 AgentDiscoverDetail,
14 AgentDiscoverElicitation,
15 AgentDiscoverField,
16 AgentDiscoverPlan,
17 AgentDiscoverServer,
18 AgentDiscoverSnapshot,
19 AgentDiscoverTab,
20} from '../types';
21import { AUDIT_PAGE, Band, Pane, maskId, needsAttention, sortServers, type Actions } from './view';
22
23const PANE = 'agent-discover';
24const TICK_MS = 5_000;
25const IDLE_TICKS = 6; // 30 s between polls while the pane is closed
26const VIEW_TICKS = 2; // the open view's data every 10 s
27const LOG_LIMIT = 30;
28const SCHEMA_MAX = 9_000;
29const PANE_ROWS = 40; // inline height wanted: detail views run long
30
31const snapshot = atom({ plugin: 'agent-discover', key: 'snapshot' } as const, null);
32const route = atom({ plugin: 'agent-discover', key: 'route' } as const, {
33 tab: 'servers',
34 server: null,
35});
36const detail = atom({ plugin: 'agent-discover', key: 'detail' } as const, null);
37const tool = atom({ plugin: 'agent-discover', key: 'tool' } as const, null);
38const confirm = atom({ plugin: 'agent-discover', key: 'confirm' } as const, null);
39const browse = atom({ plugin: 'agent-discover', key: 'browse' } as const, null);
40const plan = atom({ plugin: 'agent-discover', key: 'plan' } as const, null);
41const logs = atom({ plugin: 'agent-discover', key: 'logs' } as const, null);
42const audit = atom({ plugin: 'agent-discover', key: 'audit' } as const, null);
43const busy = atom({ plugin: 'agent-discover', key: 'busy' } as const, null);
44const notice = atom({ plugin: 'agent-discover', key: 'notice' } as const, null);
45const dismissed = atom({ plugin: 'agent-discover', key: 'dismissed' } as const, null);
46const masked = atom({ plugin: 'agent-discover', key: 'masked' } as const, {});
47const editing = atom({ plugin: 'agent-discover', key: 'editing' } as const, null);
48const paneOpen = atom({ plugin: 'agent-discover', key: 'paneOpen' } as const, false);
49
50// REST bodies are daemon JSON; read defensively, never trusted for shape.
51// eslint-disable-next-line @typescript-eslint/no-explicit-any
52type Json = Record<string, any>;
53
54const list = (v: unknown): Json[] => (Array.isArray(v) ? (v as Json[]) : []);
55const text = (v: unknown): string | null => (typeof v === 'string' && v !== '' ? v : null);
56const keys = (v: unknown): string[] =>
57 v && typeof v === 'object' && !Array.isArray(v) ? Object.keys(v) : [];
58
59const origin = async ($: EngineInterface) =>
60 `http://127.0.0.1:${(await $.env.get('AGENT_DISCOVER_PORT')) ?? '3424'}`;
61
62/** One /api call. State-changing ones carry the daemon's per-launch token (GET /api/token). */
63async function api(
64 $: EngineInterface,
65 path: string,
66 method = 'GET',
67 body?: unknown,
68): Promise<Json> {
69 const base = await origin($);
70 const headers: Record<string, string> = {};
71 if (method !== 'GET') {
72 const t = await $.http.fetch(`${base}/api/token`);
73 if (!t.ok) throw new Error(`GET /api/token: ${t.status}`);
74 headers['X-Agent-Discover-Token'] = String((JSON.parse(t.text) as Json).token);
75 }
76 if (body !== undefined) headers['Content-Type'] = 'application/json';
77 const res = await $.http.fetch(`${base}${path}`, {
78 method,
79 headers,
80 ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
81 });
82 let data: Json = {};
83 try {
84 data = JSON.parse(res.text) as Json;
85 } catch {
86 /* not JSON: the status says enough */
87 }
88 if (!res.ok) throw new Error(text(data.error) ?? `${method} ${path}: ${res.status}`);
89
90 return data;
91}
92
93const at = (id: number, sub = '') => `/api/servers/${id}${sub}`;
94
95function fields(schema: Json): AgentDiscoverField[] {
96 const required = new Set(list(schema.required).map(String));
97 return Object.entries((schema.properties ?? {}) as Record<string, Json>).map(([name, p]) => ({
98 name,
99 title: String(p.title ?? name),
100 type: ['number', 'integer', 'boolean'].includes(p.type) ? p.type : 'string',
101 options: p.type === 'boolean' ? ['true', 'false'] : list(p.enum).map(String),
102 required: required.has(name),
103 }));
104}
105
106async function poll($: EngineInterface): Promise<AgentDiscoverSnapshot> {
107 const base = await origin($);
108 try {
109 const [status, rows, pending] = await Promise.all([
110 api($, '/api/status'),
111 api($, '/api/servers'),
112 api($, '/api/elicitations').catch((): Json => ({ entries: [] })),
113 ]);
114 const byName = new Map(list(rows).map((r) => [String(r.name), r]));
115 const servers = list(status.servers).map((s): AgentDiscoverServer => {
116 const row = byName.get(String(s.name)) ?? {};
117 return {
118 id: Number(row.id ?? 0),
119 name: String(s.name),
120 description: String(s.description ?? ''),
121 transport: String(s.transport ?? ''),
122 enabled: Boolean(s.enabled),
123 quarantined: Boolean(s.quarantined),
124 tool_count: Number(s.tool_count ?? 0),
125 health_status: String(s.health_status ?? 'unknown'),
126 error_count: Number(s.error_count ?? 0),
127 registry_status: text(s.registry_status),
128 registry_name: text(row.registry_name),
129 package_name: text(row.package_name),
130 };
131 });
132 const elicitations = list(pending.entries).map(
133 (q): AgentDiscoverElicitation => ({
134 id: String(q.id),
135 server: String(q.serverName ?? ''),
136 message: String(q.message ?? ''),
137 fields: fields((q.requestedSchema ?? {}) as Json),
138 }),
139 );
140
141 return {
142 isUp: true,
143 origin: base,
144 servers,
145 elicitations,
146 attention: servers.filter(needsAttention).map((s) => s.name),
147 };
148 } catch {
149 return { isUp: false, origin: base, servers: [], elicitations: [], attention: [] };
150 }
151}
152
153const summary = (s: AgentDiscoverSnapshot) =>
154 `MCP ${s.servers.filter((x) => x.enabled).length}/${s.servers.length} enabled${s.attention.length ? ` · ${s.attention.length} to review` : ''}`;
155
156/** The context block the model starts with: what is enabled and to search before giving up. */
157function contextText(s: AgentDiscoverSnapshot) {
158 const on = s.servers.filter((x) => x.enabled).map((x) => x.name);
159 const shown = on.slice(0, 8).join(', ') + (on.length > 8 ? `, +${on.length - 8}` : '');
160 return (
161 `${on.length} of ${s.servers.length} installed MCP servers are enabled${on.length ? ` (${shown})` : ''}. ` +
162 'When a task needs a capability you do not have, call search_tools (or search_servers for uninstalled ones) before saying it is unavailable.'
163 );
164}
165
166const signature = (s: AgentDiscoverSnapshot) =>
167 `${s.attention.join(',')}|${s.elicitations.map((q) => q.id).join(',')}`;
168
169async function refresh($: EngineInterface) {
170 const next = await poll($);
171 const prev = await read($, snapshot);
172 await update($, snapshot, () => next);
173 $.ui.status(next.isUp ? summary(next) : undefined);
174 // The daemon came or went: every control the ring could be on was redrawn.
175 if (prev && prev.isUp !== next.isUp) void land($);
176 if (prev?.isUp) {
177 for (const name of next.attention.filter((n) => !prev.attention.includes(n))) {
178 const quarantined = next.servers.find((s) => s.name === name)?.quarantined;
179 $.ui.toast(
180 `agent-discover: ${name} ${quarantined ? 'quarantined, review before use' : 'is unhealthy'}`,
181 );
182 }
183 const asked = next.elicitations.filter((q) => !prev.elicitations.some((p) => p.id === q.id));
184 for (const q of asked) $.ui.toast(`agent-discover: ${q.server} asks a question (/discover)`);
185 }
186
187 return next;
188}
189
190async function serverId($: EngineInterface, name: string): Promise<number> {
191 const found = (await read($, snapshot))?.servers.find((s) => s.name === name);
192 if (!found) throw new Error(`${name} is not installed`);
193
194 return found.id;
195}
196
197function describeChange(c: Json): string {
198 const parts: string[] = [];
199 if (c.description) parts.push('description');
200 const s = c.input_schema as Json | undefined;
201 if (s) {
202 for (const [label, names] of [
203 ['+', s.added],
204 ['-', s.removed],
205 ['~', s.changed],
206 ] as const) {
207 for (const n of list(names)) parts.push(`${label}${String(n)}`);
208 }
209 }
210 if (c.annotations) parts.push('annotations');
211
212 return parts.join(' ') || 'definition';
213}
214
215/** Env vars and headers with where their value comes from; OAuth state is not a config key. */
216function configKeys(row: Json, secrets: Json[]): AgentDiscoverConfigKey[] {
217 const stored = new Map(
218 secrets
219 .map((s) => String(s.key))
220 .filter((k) => !k.startsWith('oauth:'))
221 .map((k) => [k.toLowerCase(), k]),
222 );
223 const missing = new Set(list(row.missing_secrets).map(String));
224 const declared = [
225 ...keys(row.env).map((key) => ({ key, kind: 'env' as const })),
226 ...keys(row.headers).map((key) => ({ key, kind: 'header' as const })),
227 ];
228 const out: AgentDiscoverConfigKey[] = declared.map(({ key, kind }) => ({
229 key,
230 kind,
231 source: stored.delete(key.toLowerCase()) ? 'secret' : missing.has(key) ? 'missing' : 'value',
232 }));
233 for (const key of stored.values()) out.push({ key, kind: 'secret', source: 'secret' });
234
235 return out;
236}
237
238async function loadDetail($: EngineInterface, name: string) {
239 const id = await serverId($, name);
240 const [row, secrets, trust, metrics] = await Promise.all([
241 api($, at(id)),
242 api($, at(id, '/secrets')),
243 api($, at(id, '/trust')),
244 api($, at(id, '/metrics')),
245 ]);
246 const isRemote = row.transport !== 'stdio' && Boolean(row.url);
247 const auth = isRemote ? await api($, at(id, '/auth')).catch(() => null) : null;
248 // `unknown` means no sign-in was ever asked for (a static header, or no auth at all).
249 const signIn = auth && auth.status !== 'unknown' ? auth : null;
250 const prev = await read($, detail);
251 const drift = trust.drift as Json | undefined;
252 const next: AgentDiscoverDetail = {
253 id,
254 name,
255 description: String(row.description ?? ''),
256 transport: String(row.transport ?? ''),
257 command: text(row.command),
258 args: list(row.args).map(String),
259 url: text(row.url),
260 tags: list(row.tags).map(String),
261 source: String(row.source ?? ''),
262 registry_name: text(row.registry_name),
263 registry_status:
264 (await read($, snapshot))?.servers.find((s) => s.name === name)?.registry_status ?? null,
265 package_name: text(row.package_name),
266 package_version: text(row.package_version),
267 enabled: Boolean(row.enabled),
268 quarantined: Boolean(row.quarantined),
269 connected: Boolean(row.connected),
270 health_status: String(row.health_status ?? 'unknown'),
271 last_health_check: text(row.last_health_check),
272 error_count: Number(row.error_count ?? 0),
273 config: configKeys(row, list(secrets)),
274 tools: list(row.tools).map((t) => ({
275 name: String(t.name),
276 description: String(t.description ?? '').slice(0, 300),
277 })),
278 metrics: list(metrics).map((m) => ({
279 tool: String(m.tool_name),
280 calls: Number(m.call_count ?? 0),
281 errors: Number(m.error_count ?? 0),
282 avg_ms: Number(m.avg_latency_ms ?? 0),
283 })),
284 drift: drift
285 ? {
286 changed: list(drift.changed).map((c) => ({
287 tool: String(c.tool),
288 what: describeChange(c),
289 description: c.description
290 ? { before: String(c.description.before), after: String(c.description.after) }
291 : null,
292 })),
293 added: list(drift.added).map(String),
294 removed: list(drift.removed).map(String),
295 }
296 : null,
297 hashes: list(trust.hashes).map(String),
298 auth: signIn
299 ? {
300 status: String(signIn.status),
301 // Shown as a link for the person to open; never opened by the plugin.
302 authorize_url: /^https?:\/\//i.test(String(signIn.authorize_url ?? ''))
303 ? String(signIn.authorize_url)
304 : null,
305 }
306 : null,
307 health: prev?.name === name ? prev.health : null,
308 };
309 await update($, detail, () => next);
310}
311
312async function loadLogs($: EngineInterface) {
313 const r = await api($, `/api/logs?limit=${LOG_LIMIT}`);
314 await update($, logs, () => ({
315 total: Number(r.total ?? 0),
316 entries: list(r.entries).map((e) => ({
317 id: Number(e.id),
318 time: String(e.timestamp ?? ''),
319 server: String(e.server ?? ''),
320 tool: String(e.tool ?? ''),
321 ms: Number(e.latency_ms ?? 0),
322 error: e.success
323 ? null
324 : String(e.response ?? '')
325 .replace(/\s+/g, ' ')
326 .slice(0, 200),
327 })),
328 }));
329}
330
331async function checkHealth($: EngineInterface, name: string) {
332 const r = await api($, at(await serverId($, name), '/health'), 'POST');
333 const health = {
334 status: String(r.status),
335 latency_ms: Number(r.latency_ms ?? 0),
336 error: text(r.error),
337 };
338 await update($, detail, (d) => (d && d.name === name ? { ...d, health } : d));
339
340 return health;
341}
342
343type AuditQuery = Pick<AgentDiscoverAudit, 'server' | 'action' | 'cursors' | 'before'>;
344const firstAudit: AuditQuery = { server: '', action: '', cursors: [], before: null };
345
346async function loadAudit($: EngineInterface, q: AuditQuery) {
347 const params = new URLSearchParams({ limit: String(AUDIT_PAGE) });
348 if (q.before !== null) params.set('before', String(q.before));
349 if (q.server) params.set('server', q.server);
350 if (q.action) params.set('action', q.action);
351 const r = await api($, `/api/audit?${params}`);
352 await update($, audit, () => ({
353 server: q.server,
354 action: q.action,
355 cursors: q.cursors,
356 before: q.before,
357 total: Number(r.total ?? 0),
358 entries: list(r.entries).map((e) => ({
359 id: Number(e.id),
360 ts: String(e.ts ?? ''),
361 action: String(e.action ?? ''),
362 server: text(e.server),
363 tool: text(e.tool),
364 isError: e.is_error === true,
365 ms: typeof e.duration_ms === 'number' ? e.duration_ms : null,
366 })),
367 }));
368}
369
370/** Reloads what the open view shows. */
371async function loadView($: EngineInterface) {
372 const r = await read($, route);
373 if (r.tab === 'servers' && r.server) await loadDetail($, r.server);
374 if (r.tab === 'logs') await loadLogs($);
375 if (r.tab === 'audit') await loadAudit($, (await read($, audit)) ?? firstAudit);
376}
377
378async function find($: EngineInterface, query: string): Promise<AgentDiscoverBrowse> {
379 const snap = await read($, snapshot);
380 const installed = new Map(
381 snap?.servers.flatMap((s) =>
382 [s.name, s.registry_name, s.package_name].filter(Boolean).map((k) => [k, s.name] as const),
383 ),
384 );
385 const market = await api($, `/api/browse?query=${encodeURIComponent(query)}&limit=15`).catch(
386 (err: Error): Json => ({ error: err.message }),
387 );
388 const errors = [text(market.error), ...Object.values(market.errors ?? {}).map(text)].filter(
389 (e): e is string => e !== null,
390 );
391 const result: AgentDiscoverBrowse = {
392 query,
393 results: list(market.servers).map((m) => ({
394 source: String(m.source ?? 'registry'),
395 name: String(m.name),
396 description: String(m.description ?? '').slice(0, 300),
397 version: String(m.version ?? ''),
398 status: String(m.status ?? 'active'),
399 installed: installed.get(String(m.name)) ?? null,
400 })),
401 error: errors.length ? errors.join('; ') : null,
402 };
403 await update($, browse, () => result);
404
405 return result;
406}
407
408/** Values typed for the install plan's requirements: sent with the install, never drawn. */
409const planValues = new Map<string, string>();
410/** Answers typed for upstream questions, by question id: sent on Accept, never drawn. */
411const answers = new Map<string, Record<string, string>>();
412/** What was typed into each masked field, by field id; the field draws only its length. */
413const typed = new Map<string, string>();
414
415/**
416 * A masked field draws its value as bullets, so the text it reports is those bullets with
417 * the person's edit applied: the kept bullets stand for the kept characters, the rest is new.
418 */
419async function typeMasked($: EngineInterface, id: string, shown: string) {
420 const prev = typed.get(id) ?? '';
421 let kept = 0;
422 while (kept < shown.length && kept < prev.length && shown[kept] === '•') kept += 1;
423 const next = prev.slice(0, kept) + shown.slice(kept).replace(/•/g, '');
424 typed.set(id, next);
425 await update($, masked, (m) => ({ ...m, [id]: next.length }));
426}
427
428/** Takes a masked field's value out (it is sent once, then forgotten). */
429async function takeMasked($: EngineInterface, id: string): Promise<string> {
430 const value = typed.get(id) ?? '';
431 typed.delete(id);
432 await update($, masked, (m) => Object.fromEntries(Object.entries(m).filter(([k]) => k !== id)));
433
434 return value;
435}
436
437async function forgetTyped($: EngineInterface) {
438 typed.clear();
439 await update($, masked, () => ({}));
440 await update($, editing, () => null);
441}
442
443async function fillPlan($: EngineInterface, key: string, value: string) {
444 if (value) planValues.set(key, value);
445 else planValues.delete(key);
446 await update($, plan, (p) => p && { ...p, filled: [...planValues.keys()] });
447}
448
449async function act($: EngineInterface, label: string, fn: () => Promise<string>) {
450 await update($, busy, () => label);
451 await update($, notice, () => null);
452 try {
453 const outcome = await fn();
454 await update($, notice, () => (outcome ? `✓ ${outcome}` : null));
455 } catch (err) {
456 await update(
457 $,
458 notice,
459 () => `✗ ${label} failed: ${err instanceof Error ? err.message : String(err)}`,
460 );
461 }
462 await update($, busy, () => null);
463 await refresh($);
464 await loadView($).catch(() => {});
465}
466
467/** Where the focus ring lands after a move: the control the person most likely wants next. */
468async function landing($: EngineInterface, from: string | null): Promise<string | null> {
469 if (!(await read($, snapshot))?.isUp) return 'retry';
470 const r = await read($, route);
471 if (r.tab === 'servers' && r.server) {
472 const d = await read($, detail);
473 return d?.quarantined ? 'approve' : 'toggle';
474 }
475 if (r.tab === 'servers') {
476 const first = sortServers((await read($, snapshot))?.servers ?? [])[0];
477 const to = from ?? first?.name;
478 return to ? `open:${to}` : 'empty-browse';
479 }
480 if (r.tab === 'browse') {
481 const p = await read($, plan);
482 if (!p) {
483 // A field takes the digits: results already shown keep the tab keys working.
484 const hit = (await read($, browse))?.results.find((x) => !x.installed);
485 return hit ? `plan:${hit.source}:${hit.name}` : 'search';
486 }
487 const todo = p.requirements.find((q) => !q.present && !p.filled.includes(q.key));
488 return todo ? `reqset:${todo.key}` : p.blocked ? 'plan-cancel' : 'install-enable';
489 }
490 // Never the audit's Select: holding the ring drops its list open and it takes the digits.
491 return r.tab === 'logs' ? 'logs-reload' : 'audit-reload';
492}
493
494/** Moves the ring when the pane holds the keys; otherwise the engine refuses and nothing moves. */
495async function land($: EngineInterface, from: string | null = null) {
496 const key = await landing($, from);
497 if (key) await $.ui.focus({ requestId: PANE, key }).catch(() => undefined);
498}
499
500async function go($: EngineInterface, tab: AgentDiscoverTab, server: string | null = null) {
501 const from = (await read($, route)).server;
502 await update($, route, () => ({ tab, server }));
503 await update($, confirm, () => null);
504 if (server !== (await read($, detail))?.name) await update($, tool, () => null);
505 await update($, notice, () => null);
506 await forgetTyped($);
507 await loadView($).catch(async (err: Error) => update($, notice, () => `✗ ${err.message}`));
508 void land($, from);
509 // An enabled server is connected anyway: check it so the detail opens with a real answer.
510 const d = server ? await read($, detail) : null;
511 if (d?.name === server && d.enabled && !d.quarantined && !d.health)
512 await checkHealth($, d.name).catch(() => {});
513}
514
515/** A plain sidebar, not a dialog: Escape returns the keys and leaves it open. */
516async function openPane($: EngineInterface) {
517 const opened = await $.ui.open({
518 id: PANE,
519 title: 'agent-discover',
520 focus: true,
521 rows: PANE_ROWS,
522 });
523 await update($, paneOpen, () => opened.isPlaced);
524
525 return opened;
526}
527
528function actions($: EngineInterface): Actions {
529 const run = (label: string, fn: () => Promise<string>) => void act($, label, fn);
530 const call = async (name: string, sub: string, method = 'POST', body?: unknown) =>
531 api($, at(await serverId($, name), sub), method, body);
532 const saveSecret = (name: string, key: string, value: string) =>
533 run(`save secret ${key}`, async () => {
534 await call(name, `/secrets/${encodeURIComponent(key)}`, 'PUT', { value });
535 return `secret ${key} of ${name} saved`;
536 });
537
538 return {
539 refresh: () => run('refresh', async () => ''),
540 go: (tab) => void go($, tab),
541 open: (name) => void go($, 'servers', name),
542 enable: (name) =>
543 run(`enable ${name}`, async () => {
544 const r = await call(name, '/enable');
545 return `enabled ${name} (${r.tool_count ?? 0} tools)`;
546 }),
547 disable: (name) =>
548 run(`disable ${name}`, async () => {
549 await call(name, '/disable');
550 return `disabled ${name}`;
551 }),
552 reindex: (name) =>
553 run(`re-index ${name}`, async () => {
554 const r = await call(name, '/index');
555 return `re-indexed ${name}: +${r.added?.length ?? 0} ~${r.changed?.length ?? 0} -${r.removed?.length ?? 0}`;
556 }),
557 health: (name) =>
558 run(`health check ${name}`, async () => {
559 const health = await checkHealth($, name);
560 return `${name}: ${health.status} in ${health.latency_ms} ms`;
561 }),
562 resetErrors: (name) =>
563 run(`reset errors ${name}`, async () => {
564 await call(name, '/reset-errors');
565 return `error count of ${name} reset`;
566 }),
567 approve: (name) =>
568 run(`approve ${name}`, async () => {
569 const d = await read($, detail);
570 if (d?.name !== name) throw new Error('review the changes first');
571 await call(name, '/approve', 'POST', { hashes: d.hashes });
572 return `approved ${name}: quarantine lifted`;
573 }),
574 keepDisabled: (name) =>
575 run(`keep ${name} disabled`, async () => {
576 await call(name, '/disable');
577 return `${name} stays disabled and quarantined`;
578 }),
579 signIn: (name) =>
580 run(`sign in ${name}`, async () => {
581 const r = await call(name, '/auth');
582 if (r.status === 'authorized') return `${name}: signed in`;
583 return `${name}: open the sign-in page below, then Refresh`;
584 }),
585 askUninstall: (name) => void update($, confirm, () => `uninstall:${name}`),
586 cancel: () => void update($, confirm, () => null),
587 uninstall: (name) =>
588 run(`uninstall ${name}`, async () => {
589 await call(name, '', 'DELETE');
590 await update($, confirm, () => null);
591 await update($, route, () => ({ tab: 'servers' as const, server: null }));
592 await update($, detail, () => null);
593 return `uninstalled ${name}`;
594 }),
595 mask: (id, shown) => void typeMasked($, id, shown),
596 setSecret: (name, key) =>
597 void (async () => {
598 const value = await takeMasked($, maskId(`secret:${name}`, key));
599 if (!value) return;
600 await update($, editing, () => null);
601 saveSecret(name, key, value);
602 })(),
603 editSecret: (key) => void update($, editing, () => key),
604 addSecret: (key) => {
605 const k = key.trim();
606 if (k) void update($, editing, () => k);
607 },
608 deleteSecret: (name, key) =>
609 run(`delete secret ${key}`, async () => {
610 await call(name, `/secrets/${encodeURIComponent(key)}`, 'DELETE');
611 return `secret ${key} of ${name} deleted`;
612 }),
613 toggleTool: (name, toolName) =>
614 void (async () => {
615 const open = await read($, tool);
616 if (open?.server === name && open.tool === toolName) {
617 await update($, tool, () => null);
618 return;
619 }
620 const row = await api($, at(await serverId($, name)));
621 const def = list(row.tools).find((t) => t.name === toolName);
622 const schema = JSON.stringify(def?.input_schema ?? {}, null, 2);
623 await update($, tool, () => ({
624 server: name,
625 tool: toolName,
626 schema: schema.length > SCHEMA_MAX ? `${schema.slice(0, SCHEMA_MAX)}\n…` : schema,
627 }));
628 })().catch((err: Error) => update($, notice, () => err.message)),
629 search: (query) => {
630 if (!query.trim()) return;
631 run(`search "${query}"`, async () => {
632 await update($, plan, () => null);
633 await find($, query.trim());
634 void land($);
635 return ''; // the results say how many
636 });
637 },
638 syncRegistry: () =>
639 run('sync the registry mirror', async () => {
640 const r = await api($, '/api/registry/sync', 'POST');
641 return `registry mirror: ${r.mode} sync, ${r.fetched} entries`;
642 }),
643 showPlan: (entry) =>
644 run(`plan ${entry.name}`, async () => {
645 planValues.clear();
646 const params = new URLSearchParams({ source: entry.source, name: entry.name });
647 if (entry.version) params.set('version', entry.version);
648 const p = await api($, `/api/install/plan?${params}`);
649 const prov = (p.provenance ?? {}) as Json;
650 const next: AgentDiscoverPlan = {
651 source: entry.source,
652 name: entry.name,
653 version: entry.version || null,
654 server: String(p.server),
655 transport: String(p.transport ?? ''),
656 command: text(p.command),
657 args: list(p.args).map(String),
658 url: text(p.url),
659 pinned: Boolean(prov.pinned),
660 publisher: text(prov.registry?.publisher),
661 registry_status: text(prov.registry?.status),
662 repository: text(prov.repository),
663 checks: list(prov.checks).map((c) => ({
664 id: String(c.id),
665 status: String(c.status),
666 detail: String(c.detail ?? ''),
667 })),
668 warnings: list(p.warnings).map(String),
669 blocked: text(p.blocked),
670 requirements: list(p.requirements).map((q) => ({
671 key: String(q.key),
672 kind: String(q.kind),
673 required: Boolean(q.required),
674 secret: Boolean(q.secret),
675 present: Boolean(q.present),
676 })),
677 filled: [],
678 };
679 await update($, plan, () => next);
680 void land($);
681 return '';
682 }),
683 fill: (key, value) => void fillPlan($, key, value).then(() => land($)),
684 fillSecret: (key) =>
685 void (async () => {
686 await fillPlan($, key, await takeMasked($, maskId('plan', key)));
687 await land($);
688 })(),
689 cancelPlan: () => {
690 planValues.clear();
691 void forgetTyped($);
692 void update($, plan, () => null);
693 },
694 install: (enable) =>
695 run('install', async () => {
696 const p = await read($, plan);
697 if (!p) throw new Error('no plan to install');
698 const r = await api($, '/api/install', 'POST', {
699 source: p.source,
700 name: p.name,
701 ...(p.version ? { version: p.version } : {}),
702 enable,
703 secrets: Object.fromEntries(planValues),
704 });
705 planValues.clear();
706 await update($, plan, () => null);
707 await refresh($); // the detail looks the new server up in the snapshot
708 await go($, 'servers', String(r.name));
709 const indexed = r.index_error ? `, indexing failed: ${r.index_error}` : '';
710 return `installed ${r.name} (${r.tool_count ?? 0} tools${enable ? ', enabled' : ''})${indexed}`;
711 }),
712 reloadLogs: () => run('reload logs', async () => ''),
713 reloadAudit: () => run('reload audit', async () => ''),
714 filterAudit: (field, value) =>
715 run('filter audit', async () => {
716 const prev = (await read($, audit)) ?? firstAudit;
717 await loadAudit($, { ...prev, [field]: value, cursors: [], before: null });
718 return '';
719 }),
720 olderAudit: () =>
721 run('older entries', async () => {
722 const prev = await read($, audit);
723 const last = prev?.entries.at(-1);
724 if (prev && last)
725 await loadAudit($, {
726 ...prev,
727 cursors: [...prev.cursors, prev.before ?? 0],
728 before: last.id,
729 });
730 return '';
731 }),
732 newerAudit: () =>
733 run('newer entries', async () => {
734 const prev = await read($, audit);
735 if (prev?.cursors.length) {
736 const before = prev.cursors.at(-1) ?? 0;
737 await loadAudit($, {
738 ...prev,
739 cursors: prev.cursors.slice(0, -1),
740 before: before === 0 ? null : before,
741 });
742 }
743 return '';
744 }),
745 answer: (id, field, value) => answers.set(id, { ...answers.get(id), [field]: value }),
746 respond: (id, action) =>
747 run(`${action} question`, async () => {
748 const q = (await read($, snapshot))?.elicitations.find((x) => x.id === id);
749 const typed = answers.get(id) ?? {};
750 const content = Object.fromEntries(
751 (q?.fields ?? [])
752 .filter((f) => typed[f.name] !== undefined)
753 .map((f) => [
754 f.name,
755 f.type === 'boolean'
756 ? typed[f.name] === 'true'
757 : f.type === 'string'
758 ? typed[f.name]
759 : Number(typed[f.name]),
760 ]),
761 );
762 await api($, `/api/elicitations/${encodeURIComponent(id)}/respond`, 'POST', {
763 action,
764 ...(action === 'accept' ? { content } : {}),
765 });
766 answers.delete(id);
767 return `answered ${q?.server ?? 'the server'}: ${action}`;
768 }),
769 openFromBand: () =>
770 void (async () => {
771 await openPane($);
772 await go($, 'servers', (await read($, snapshot))?.attention[0] ?? null);
773 })(),
774 dismiss: async () => {
775 const snap = await read($, snapshot);
776 if (snap) await update($, dismissed, () => signature(snap));
777 },
778 };
779}
780
781export const register: Register = (on) => {
782 let isPolling = false;
783 let ticks = 0;
784 on('session.start', async ($, e, next) => {
785 await $.command.register({
786 name: 'discover',
787 description: 'Manage MCP servers: installed servers, browse and install, logs, audit',
788 argumentHint: '[search the registry for]',
789 });
790 void refresh($);
791 $.clock.every(TICK_MS, async () => {
792 ticks += 1;
793 const isOpen = (await $.ui.panes()).some((p) => p.id === PANE && p.isPlaced);
794 if ((await read($, paneOpen)) !== isOpen) await update($, paneOpen, () => isOpen);
795 if (isPolling || !(isOpen || ticks % IDLE_TICKS === 0)) return;
796 isPolling = true;
797 try {
798 await refresh($);
799 if (isOpen && ticks % VIEW_TICKS === 0) await loadView($).catch(() => {});
800 } finally {
801 isPolling = false;
802 }
803 });
804
805 return next(e);
806 });
807
808 on('command.run', { command: 'discover' }, async ($, e) => {
809 const opened = await openPane($);
810 const snap = await refresh($);
811 const lines = [snap.isUp ? summary(snap) : `daemon not reachable at ${snap.origin}`];
812 if (!opened.isPlaced) lines.push(`pane not shown: ${opened.reason}`);
813 const query = e.args.trim();
814 if (snap.isUp && query) {
815 await update($, plan, () => null);
816 await update($, route, () => ({ tab: 'browse' as const, server: null }));
817 const r = await find($, query);
818 lines.push(
819 `${r.results.length} ${r.results.length === 1 ? 'result' : 'results'} for "${query}" in the pane`,
820 );
821 if (r.error) lines.push(`search error: ${r.error}`);
822 } else if (snap.isUp) await go($, 'servers');
823
824 return { text: lines.join('\n') };
825 });
826
827 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
828 const el = $.ui.resolve(e);
829 const snap = await read($, snapshot);
830 if (!snap) return <el.Text dimColor>Loading...</el.Text>;
831
832 return (
833 <Pane
834 el={el}
835 snap={snap}
836 route={await read($, route)}
837 detail={await read($, detail)}
838 tool={await read($, tool)}
839 confirm={await read($, confirm)}
840 browse={await read($, browse)}
841 plan={await read($, plan)}
842 logs={await read($, logs)}
843 audit={await read($, audit)}
844 busy={await read($, busy)}
845 notice={await read($, notice)}
846 isFocused={e.props.isFocused}
847 columns={e.props.bodyColumns}
848 masked={await read($, masked)}
849 editing={await read($, editing)}
850 on={actions($)}
851 />
852 );
853 });
854
855 on('prompt.context', async ($, e, next) => {
856 const r = await next(e);
857 const snap = (await read($, snapshot)) ?? (await refresh($));
858 if (!snap.isUp) return r;
859
860 return { ...r, blocks: [...r.blocks, { name: 'agent-discover', text: contextText(snap) }] };
861 });
862
863 // The pane shows the same and more: the band is for when it is closed.
864 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
865 const snap = await read($, snapshot);
866 if (
867 e.props.hasSurvey ||
868 (await read($, paneOpen)) ||
869 !snap?.isUp ||
870 (snap.attention.length === 0 && snap.elicitations.length === 0) ||
871 (await read($, dismissed)) === signature(snap)
872 ) {
873 return next(e);
874 }
875
876 return <Band el={$.ui.resolve(e)} snap={snap} on={actions($)} />;
877 });
878};
879hooks/view.tsx 1174 lines1// Trees for the /discover pane and the attention band. Pure: state in, elements out;
2// the actions come from register.tsx. Laid out for the docked sidebar first (about 45
3// to 70 columns): a one-line tab bar, two-line rows, labels beside wrapping values.
4// Wider panes only get more room per line; nothing depends on it.
5import type { Elements } from 'claude-code';
6
7import type {
8 AgentDiscoverAudit,
9 AgentDiscoverBrowse,
10 AgentDiscoverConfigKey,
11 AgentDiscoverDetail,
12 AgentDiscoverElicitation,
13 AgentDiscoverEntry,
14 AgentDiscoverLogs,
15 AgentDiscoverPlan,
16 AgentDiscoverRoute,
17 AgentDiscoverServer,
18 AgentDiscoverSnapshot,
19 AgentDiscoverTab,
20 AgentDiscoverTool,
21} from '../types';
22
23/** The elements the pane draws with; mobile has no `Input` or `Select`. */
24export type El = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button' | 'Link' | 'Code'> &
25 Partial<Pick<Elements['terminal'], 'Input' | 'Select'>>;
26
27export type Actions = {
28 refresh: () => void;
29 go: (tab: AgentDiscoverTab) => void;
30 open: (name: string) => void;
31 enable: (name: string) => void;
32 disable: (name: string) => void;
33 reindex: (name: string) => void;
34 health: (name: string) => void;
35 resetErrors: (name: string) => void;
36 approve: (name: string) => void;
37 keepDisabled: (name: string) => void;
38 signIn: (name: string) => void;
39 askUninstall: (name: string) => void;
40 cancel: () => void;
41 uninstall: (name: string) => void;
42 /** A masked field reported `shown` (bullets plus the person's edit). */
43 mask: (id: string, shown: string) => void;
44 /** Saves what was typed into the masked field of `key` as a secret of `name`. */
45 setSecret: (name: string, key: string) => void;
46 /** Opens (or with null closes) the masked field for one config key. */
47 editSecret: (key: string | null) => void;
48 /** Starts a secret under a new key name. */
49 addSecret: (key: string) => void;
50 deleteSecret: (name: string, key: string) => void;
51 toggleTool: (name: string, tool: string) => void;
52 search: (query: string) => void;
53 syncRegistry: () => void;
54 showPlan: (entry: AgentDiscoverEntry) => void;
55 fill: (key: string, value: string) => void;
56 /** Fills a secret requirement from its masked field. */
57 fillSecret: (key: string) => void;
58 cancelPlan: () => void;
59 install: (enable: boolean) => void;
60 reloadLogs: () => void;
61 reloadAudit: () => void;
62 filterAudit: (field: 'server' | 'action', value: string) => void;
63 olderAudit: () => void;
64 newerAudit: () => void;
65 answer: (id: string, field: string, value: string) => void;
66 respond: (id: string, action: 'accept' | 'decline' | 'cancel') => void;
67 openFromBand: () => void;
68 dismiss: () => void;
69};
70
71export const needsAttention = (s: AgentDiscoverServer) =>
72 s.quarantined || (s.enabled && s.health_status === 'unhealthy');
73
74type State = 'quarantined' | 'unhealthy' | 'enabled' | 'installed';
75
76const state = (s: { quarantined: boolean; enabled: boolean; health_status: string }): State =>
77 s.quarantined
78 ? 'quarantined'
79 : s.enabled && s.health_status === 'unhealthy'
80 ? 'unhealthy'
81 : s.enabled
82 ? 'enabled'
83 : 'installed';
84
85// Theme keys, so the pane follows the person's theme (light, dark, colorblind).
86const OK = 'success';
87const BAD = 'error';
88const WARN = 'warning';
89const ACCENT = 'suggestion';
90
91const TONE: Record<State, string | undefined> = {
92 quarantined: BAD,
93 unhealthy: BAD,
94 enabled: OK,
95 installed: undefined,
96};
97const MARK: Record<State, string> = {
98 quarantined: '!',
99 unhealthy: '✗',
100 enabled: '●',
101 installed: '○',
102};
103const ORDER: State[] = ['quarantined', 'unhealthy', 'enabled', 'installed'];
104
105/** The list's order: what needs a look first, then enabled, then the rest; by name within. */
106export const sortServers = (servers: AgentDiscoverServer[]) =>
107 [...servers].sort(
108 (a, b) => ORDER.indexOf(state(a)) - ORDER.indexOf(state(b)) || a.name.localeCompare(b.name),
109 );
110const SOURCE: Record<AgentDiscoverConfigKey['source'], string> = {
111 secret: 'in the keychain',
112 value: 'in the config',
113 missing: 'missing',
114};
115const SOURCE_TONE: Record<AgentDiscoverConfigKey['source'], string | undefined> = {
116 secret: OK,
117 value: undefined,
118 missing: WARN,
119};
120const CHECK: Record<string, [string, string | undefined]> = {
121 pass: ['✓', OK],
122 fail: ['✗', BAD],
123 error: ['?', WARN],
124 skipped: ['–', undefined],
125};
126
127/** Optional props are left out, never passed as undefined: a remote surface refuses undefined. */
128const color = (c: string | false | undefined) => (c ? { color: c } : {});
129const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`;
130const pad2 = (n: number) => String(n).padStart(2, '0');
131const human = (id: string) => id.replace(/_/g, ' ');
132
133/**
134 * Daemon timestamps are UTC (ISO, or SQLite's `YYYY-MM-DD HH:MM:SS`); drawn local:
135 * HH:MM:SS today, MM-DD HH:MM before.
136 */
137export function time(ts: string, now = new Date()): string {
138 const d = new Date(/(?:[zZ]|[+-]\d\d:?\d\d)$/.test(ts) ? ts : `${ts.replace(' ', 'T')}Z`);
139 if (Number.isNaN(d.getTime())) return ts;
140 const clock = `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
141 return d.toDateString() === now.toDateString()
142 ? `${clock}:${pad2(d.getSeconds())}`
143 : `${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${clock}`;
144}
145
146const quote = (a: string) => (/[\s"'`$]/.test(a) || a === '' ? `"${a.replace(/"/g, '\\"')}"` : a);
147
148type ViewProps = { el: El; on: Actions };
149
150/** Id of a masked field: `plan` or `secret:<server>`, then the key. */
151export const maskId = (scope: string, key: string) => `${scope}:${key}`;
152
153type MaskedProps = ViewProps & {
154 id: string;
155 field: string;
156 label: string;
157 length: number;
158 submitLabel: string;
159 onSubmit: () => void;
160};
161
162/** An Input that only ever draws bullets: the typed value stays in register.tsx. */
163function Masked({ el, on, id, field, label, length, submitLabel, onSubmit }: MaskedProps) {
164 const { Input } = el;
165 return Input ? (
166 <Input
167 key={field}
168 label={label}
169 placeholder="value, kept in the keychain"
170 value={'•'.repeat(length)}
171 submitLabel={submitLabel}
172 onInput={(v) => on.mask(id, v)}
173 onSubmit={onSubmit}
174 />
175 ) : null;
176}
177
178/** A section heading: bold, with an optional dim count or aside. */
179function Heading({ el, title, aside }: { el: El; title: string; aside?: string }) {
180 const { Text } = el;
181 return (
182 <Text wrap="truncate">
183 <Text bold>{title}</Text>
184 {aside ? <Text dimColor> {aside}</Text> : null}
185 </Text>
186 );
187}
188
189/** A label column beside a value that wraps under itself. */
190function Fact({ el, label, children }: { el: El; label: string; children: unknown }) {
191 const { Box, Text } = el;
192 return (
193 <Box columnGap={1}>
194 <Box width={8} flexShrink={0}>
195 <Text dimColor>{label}</Text>
196 </Box>
197 <Box flexGrow={1} flexShrink={1}>
198 {children as never}
199 </Box>
200 </Box>
201 );
202}
203
204/** A one-glyph mark beside text that wraps under itself, not under the mark. */
205function Marked({
206 el,
207 mark,
208 tone,
209 text,
210}: {
211 el: El;
212 mark: string;
213 tone: string | undefined;
214 text: string;
215}) {
216 const { Box, Text } = el;
217 return (
218 <Box>
219 <Box width={2} flexShrink={0}>
220 <Text {...color(tone)}>{mark}</Text>
221 </Box>
222 <Box flexShrink={1}>
223 <Text {...(tone === undefined ? { dimColor: true } : {})}>{text}</Text>
224 </Box>
225 </Box>
226 );
227}
228
229/** "‹ label": the way back to the list a view was opened from. */
230function Back({
231 el,
232 k,
233 label,
234 onPress,
235}: {
236 el: El;
237 k: string;
238 label: string;
239 onPress: () => void;
240}) {
241 const { Button } = el;
242 return <Button key={k} label={`‹ ${label}`} plain dimColor onPress={onPress} />;
243}
244
245export type PaneProps = ViewProps & {
246 snap: AgentDiscoverSnapshot;
247 route: AgentDiscoverRoute;
248 detail: AgentDiscoverDetail | null;
249 tool: AgentDiscoverTool | null;
250 confirm: string | null;
251 browse: AgentDiscoverBrowse | null;
252 plan: AgentDiscoverPlan | null;
253 logs: AgentDiscoverLogs | null;
254 audit: AgentDiscoverAudit | null;
255 busy: string | null;
256 notice: string | null;
257 /** The pane holds the keyboard (Tab walks it), as the Pane site reports. */
258 isFocused: boolean;
259 /** The pane body's width (`bodyColumns`); lines stop at MAX_COLUMNS on wider panes. */
260 columns: number;
261 masked: Record<string, number>;
262 editing: string | null;
263};
264
265/** Tab, label and the hotkey that opens it while the pane holds the keyboard. */
266const TABS: [AgentDiscoverTab, string, string][] = [
267 ['servers', 'Servers', '1'],
268 ['browse', 'Browse', '2'],
269 ['logs', 'Logs', '3'],
270 ['audit', 'Audit', '4'],
271];
272
273/** Every `AuditAction` (src/domain/trust/audit.ts), for the filter. */
274const AUDIT_ACTIONS = [
275 'install',
276 'uninstall',
277 'enable',
278 'disable',
279 'call_tool',
280 'approve',
281 'deny',
282 'quarantine',
283 'release',
284 'flag',
285 'secret-set',
286 'secret-delete',
287 'shutdown',
288 'sampling',
289];
290export const AUDIT_PAGE = 20;
291/** Past this a row's name and its state drift too far apart to read as one line. */
292const MAX_COLUMNS = 100;
293
294/** `act` in register.tsx leads a failed outcome with ✗. */
295const isFailure = (notice: string) => notice.startsWith('✗');
296
297export function Pane(p: PaneProps) {
298 const { Box, Text, Button, Code } = p.el;
299 const { snap, route, on } = p;
300 const width = Math.min(p.columns, MAX_COLUMNS);
301 // Every tab needs the daemon: no tabs then, just what to do about it.
302 if (!snap.isUp)
303 return (
304 <Box flexDirection="column" gap={1} width={width}>
305 <Box flexDirection="column">
306 <Text color={BAD} bold>
307 The agent-discover daemon is not running
308 </Text>
309 <Text dimColor>
310 Nothing answers at {snap.origin}. The next MCP call starts it, or run:
311 </Text>
312 </Box>
313 <Code source="agent-discover daemon" language="shell" />
314 <Button key="retry" label="Retry" variant="primary" onPress={on.refresh} />
315 </Box>
316 );
317 let body;
318 if (route.tab === 'servers' && route.server)
319 body = p.detail ? (
320 <Detail
321 el={p.el}
322 on={on}
323 d={p.detail}
324 tool={p.tool}
325 confirm={p.confirm}
326 masked={p.masked}
327 editing={p.editing}
328 />
329 ) : (
330 <Text dimColor>Loading {route.server}…</Text>
331 );
332 else if (route.tab === 'servers') body = <Servers el={p.el} on={on} snap={snap} />;
333 else if (route.tab === 'browse')
334 body = <Browse el={p.el} on={on} browse={p.browse} plan={p.plan} masked={p.masked} />;
335 else if (route.tab === 'logs') body = <Logs el={p.el} on={on} logs={p.logs} />;
336 else body = <Audit el={p.el} on={on} audit={p.audit} />;
337
338 return (
339 <Box flexDirection="column" gap={1} width={width}>
340 <Box flexDirection="column">
341 <Box flexWrap="wrap" columnGap={2}>
342 {TABS.map(([tab, label, hotkey]) =>
343 route.tab === tab && !route.server ? (
344 <Text key={`tab:${tab}`}>
345 <Text color={ACCENT}>{hotkey}: </Text>
346 <Text bold underline>
347 {label}
348 </Text>
349 </Text>
350 ) : (
351 <Button
352 key={`tab:${tab}`}
353 label={label}
354 hotkey={hotkey}
355 plain
356 {...(route.tab === tab ? {} : { dimColor: true })}
357 onPress={() => on.go(tab)}
358 />
359 ),
360 )}
361 <Button key="refresh" label="Refresh" hotkey="r" plain dimColor onPress={on.refresh} />
362 </Box>
363 {/* One status line under the tabs: the action in flight, else its outcome, else the
364 keys. Nothing above the view grows or shrinks as actions come and go. */}
365 {p.busy ? (
366 <Text color={WARN} wrap="truncate">
367 {p.busy}…
368 </Text>
369 ) : p.notice ? (
370 <Text {...color(isFailure(p.notice) ? BAD : OK)}>{p.notice}</Text>
371 ) : (
372 <Text dimColor wrap="truncate">
373 {p.isFocused
374 ? 'Tab moves · Enter presses · ↑↓ scroll · Esc to the prompt'
375 : 'ctrl+x tab to work this pane from the keyboard'}
376 </Text>
377 )}
378 </Box>
379 {snap.elicitations.map((q) => (
380 <Question key={`q:${q.id}`} el={p.el} on={on} q={q} />
381 ))}
382 {body}
383 </Box>
384 );
385}
386
387function Question({ el, on, q }: ViewProps & { q: AgentDiscoverElicitation }) {
388 const { Box, Text, Button, Input, Select } = el;
389 return (
390 <Box flexDirection="column" borderStyle="round" borderColor={WARN} paddingX={1}>
391 <Text color={WARN} bold>
392 {q.server} asks
393 </Text>
394 <Text>{q.message}</Text>
395 {q.fields.map((f) =>
396 f.options.length && Select ? (
397 <Select
398 key={`q:${q.id}:${f.name}`}
399 label={f.title}
400 options={f.options.map((o) => ({ value: o, label: o }))}
401 onSelect={(v) => on.answer(q.id, f.name, v)}
402 />
403 ) : (
404 Input && (
405 <Input
406 key={`q:${q.id}:${f.name}`}
407 label={`${f.title}${f.required ? ' *' : ''}`}
408 submitLabel="set"
409 onSubmit={(v) => on.answer(q.id, f.name, v)}
410 />
411 )
412 ),
413 )}
414 <Box flexWrap="wrap" columnGap={1}>
415 <Button
416 key={`q:${q.id}:accept`}
417 label="Accept"
418 variant="primary"
419 onPress={() => on.respond(q.id, 'accept')}
420 />
421 <Button
422 key={`q:${q.id}:decline`}
423 label="Decline"
424 onPress={() => on.respond(q.id, 'decline')}
425 />
426 <Button
427 key={`q:${q.id}:cancel`}
428 label="Cancel"
429 dimColor
430 onPress={() => on.respond(q.id, 'cancel')}
431 />
432 </Box>
433 </Box>
434 );
435}
436
437function Servers({ el, on, snap }: ViewProps & { snap: AgentDiscoverSnapshot }) {
438 const { Box, Text, Button } = el;
439 const servers = snap.servers;
440 if (servers.length === 0)
441 return (
442 <Box flexDirection="column" gap={1}>
443 <Text>No MCP servers installed yet.</Text>
444 <Button
445 key="empty-browse"
446 label="Browse for one"
447 variant="primary"
448 onPress={() => on.go('browse')}
449 />
450 </Box>
451 );
452 const enabled = servers.filter((s) => s.enabled).length;
453 const sorted = sortServers(servers);
454 return (
455 <Box flexDirection="column" gap={1}>
456 <Text wrap="truncate">
457 <Text bold>{plural(servers.length, 'MCP server')}</Text>
458 <Text dimColor> · {enabled} enabled</Text>
459 {snap.attention.length > 0 && (
460 <Text color={WARN}> · {snap.attention.length} to review</Text>
461 )}
462 </Text>
463 <Box flexDirection="column">
464 {sorted.map((s) => {
465 const st = state(s);
466 const facts = [
467 plural(s.tool_count, 'tool'),
468 st === 'quarantined' ? 'its tools changed: review them' : '',
469 st === 'unhealthy' ? 'failing its health check' : '',
470 s.error_count ? plural(s.error_count, 'error') : '',
471 s.registry_status === 'deleted' ? 'removed from the registry' : '',
472 s.description,
473 ].filter(Boolean);
474 return (
475 <Box key={`row:${s.name}`} flexDirection="column">
476 <Box columnGap={1}>
477 <Text {...color(TONE[st])}>{MARK[st]}</Text>
478 <Box flexGrow={1} flexShrink={1}>
479 <Button
480 key={`open:${s.name}`}
481 label={s.name}
482 plain
483 onPress={() => on.open(s.name)}
484 />
485 </Box>
486 <Box flexShrink={0}>
487 <Text {...color(TONE[st])} {...(st === 'installed' ? { dimColor: true } : {})}>
488 {st}
489 </Text>
490 </Box>
491 </Box>
492 <Box paddingLeft={2}>
493 <Text dimColor wrap="truncate">
494 {facts.join(' · ')}
495 </Text>
496 </Box>
497 </Box>
498 );
499 })}
500 </Box>
501 </Box>
502 );
503}
504
505type DetailProps = ViewProps & {
506 d: AgentDiscoverDetail;
507 tool: AgentDiscoverTool | null;
508 confirm: string | null;
509 masked: Record<string, number>;
510 editing: string | null;
511};
512
513function Detail({ el, on, d, tool, confirm, masked, editing }: DetailProps) {
514 const { Box, Text, Button, Input, Link, Code } = el;
515 const st = state(d);
516 const where = d.command
517 ? [d.command, ...d.args].map(quote).join(' ')
518 : d.url
519 ? `${d.url} (${d.transport})`
520 : null;
521 const pkg = d.package_name
522 ? `${d.package_name}${d.package_version ? `@${d.package_version}` : ''}`
523 : null;
524 const healthStatus = d.health?.status ?? d.health_status;
525 const health = d.health
526 ? `${d.health.status} in ${d.health.latency_ms} ms${d.health.error ? `: ${d.health.error}` : ''}`
527 : d.health_status === 'unknown'
528 ? 'not checked yet'
529 : `${d.health_status}${d.last_health_check ? `, checked ${time(d.last_health_check)}` : ''}`;
530 const calls = d.metrics.reduce((n, m) => n + m.calls, 0);
531 const failed = d.metrics.reduce((n, m) => n + m.errors, 0);
532 const avg = calls ? d.metrics.reduce((n, m) => n + m.avg_ms * m.calls, 0) / calls : 0;
533 const usage = calls
534 ? `${plural(calls, 'call')}${failed ? `, ${failed} failed` : ''}, ${Math.round(avg)} ms avg`
535 : 'no calls yet';
536 const toolWidth = Math.min(22, Math.max(8, ...d.tools.map((t) => t.name.length + 2)));
537 const missing = d.config.filter((c) => c.source === 'missing');
538 const source = d.registry_name ? `${d.source} · ${d.registry_name}` : d.source;
539 const value = (text: string, tone?: string) => (
540 <Text {...color(tone)} wrap="wrap">
541 {text}
542 </Text>
543 );
544
545 return (
546 <Box flexDirection="column" gap={1}>
547 <Box flexDirection="column">
548 <Back el={el} k="back" label="Servers" onPress={() => on.go('servers')} />
549 <Text wrap="truncate">
550 <Text bold>{d.name}</Text>
551 <Text {...color(TONE[st])}>
552 {' '}
553 {MARK[st]} {st}
554 </Text>
555 {d.connected && <Text dimColor> · connected</Text>}
556 </Text>
557 {d.description && <Text dimColor>{d.description}</Text>}
558 </Box>
559
560 {d.drift && (
561 <Box flexDirection="column" borderStyle="round" borderColor={BAD} paddingX={1}>
562 <Text color={BAD} bold>
563 Its tools changed since you approved them
564 </Text>
565 <Text dimColor>It stays off until you approve the new definitions.</Text>
566 {d.drift.changed.map((c) => (
567 <Box key={`chg:${c.tool}`} flexDirection="column" marginTop={1}>
568 <Text>
569 <Text color={WARN}>~ </Text>
570 <Text bold>{c.tool}</Text>
571 <Text dimColor> {c.what}</Text>
572 </Text>
573 {c.description && (
574 <Box flexDirection="column" paddingLeft={2}>
575 <Text color={BAD}>- {c.description.before}</Text>
576 <Text color={OK}>+ {c.description.after}</Text>
577 </Box>
578 )}
579 </Box>
580 ))}
581 {d.drift.added.map((t) => (
582 <Text key={`add:${t}`}>
583 <Text color={OK}>+ </Text>
584 <Text bold>{t}</Text>
585 <Text dimColor> new tool</Text>
586 </Text>
587 ))}
588 {d.drift.removed.map((t) => (
589 <Text key={`rm:${t}`}>
590 <Text color={BAD}>- </Text>
591 <Text bold>{t}</Text>
592 <Text dimColor> removed</Text>
593 </Text>
594 ))}
595 <Box flexWrap="wrap" columnGap={1} marginTop={1}>
596 <Button
597 key="approve"
598 label="Approve"
599 variant="primary"
600 onPress={() => on.approve(d.name)}
601 />
602 <Button key="keep" label="Keep disabled" onPress={() => on.keepDisabled(d.name)} />
603 </Box>
604 </Box>
605 )}
606
607 <Box flexWrap="wrap" columnGap={1}>
608 {!d.quarantined && (
609 <Button
610 key="toggle"
611 label={d.enabled ? 'Disable' : 'Enable'}
612 {...(d.enabled ? {} : { variant: 'primary' as const })}
613 onPress={() => (d.enabled ? on.disable(d.name) : on.enable(d.name))}
614 />
615 )}
616 <Button key="health" label="Check health" onPress={() => on.health(d.name)} />
617 <Button key="reindex" label="Re-index" onPress={() => on.reindex(d.name)} />
618 {d.error_count > 0 && (
619 <Button key="reset" label="Reset errors" onPress={() => on.resetErrors(d.name)} />
620 )}
621 {confirm === `uninstall:${d.name}` ? null : (
622 <Button
623 key="uninstall"
624 label="Uninstall"
625 dimColor
626 onPress={() => on.askUninstall(d.name)}
627 />
628 )}
629 </Box>
630 {confirm === `uninstall:${d.name}` && (
631 <Box flexDirection="column">
632 <Text color={WARN}>Uninstall {d.name}? Its config and secrets are removed.</Text>
633 <Box columnGap={1}>
634 <Button
635 key="uninstall-yes"
636 label="Uninstall"
637 variant="primary"
638 onPress={() => on.uninstall(d.name)}
639 />
640 <Button key="uninstall-no" label="Keep it" onPress={on.cancel} />
641 </Box>
642 </Box>
643 )}
644
645 <Box flexDirection="column">
646 {where && (
647 <Fact el={el} label="Runs">
648 {value(where)}
649 </Fact>
650 )}
651 <Fact el={el} label="Source">
652 {value(source)}
653 </Fact>
654 {d.registry_status === 'deleted' && (
655 <Fact el={el} label="Registry">
656 {value('taken down by the MCP Registry', BAD)}
657 </Fact>
658 )}
659 {pkg && (
660 <Fact el={el} label="Package">
661 {value(pkg)}
662 </Fact>
663 )}
664 {d.tags.length > 0 && (
665 <Fact el={el} label="Tags">
666 {value(d.tags.join(', '))}
667 </Fact>
668 )}
669 <Fact el={el} label="Health">
670 {value(
671 health,
672 healthStatus === 'healthy' ? OK : healthStatus === 'unhealthy' ? BAD : undefined,
673 )}
674 </Fact>
675 <Fact el={el} label="Usage">
676 {value(usage)}
677 </Fact>
678 {d.error_count > 0 && (
679 <Fact el={el} label="Errors">
680 {value(`${d.error_count} since the last reset`, BAD)}
681 </Fact>
682 )}
683 </Box>
684
685 {d.auth && (
686 <Box flexDirection="column">
687 <Text>
688 <Text bold>Sign-in </Text>
689 <Text color={d.auth.status === 'authorized' ? OK : WARN}>
690 {d.auth.status === 'authorized' ? 'signed in' : 'required'}
691 </Text>
692 </Text>
693 {d.auth.status !== 'authorized' && (
694 <Button
695 key="signin"
696 label="Sign in"
697 variant="primary"
698 onPress={() => on.signIn(d.name)}
699 />
700 )}
701 {d.auth.authorize_url && (
702 <Link href={d.auth.authorize_url} label="Open the sign-in page in your browser" />
703 )}
704 </Box>
705 )}
706
707 <Box flexDirection="column">
708 <Heading el={el} title="Tools" aside={`(${d.tools.length})`} />
709 {d.tools.length === 0 && <Text dimColor>Not indexed yet: Re-index lists them.</Text>}
710 {d.tools.map((t) => {
711 const m = d.metrics.find((x) => x.tool === t.name);
712 const isOpen = tool?.server === d.name && tool.tool === t.name;
713 return (
714 <Box key={`tool:${t.name}`} flexDirection="column">
715 <Box columnGap={1}>
716 <Box width={toolWidth} flexShrink={0}>
717 <Button
718 key={`toolbtn:${t.name}`}
719 label={`${isOpen ? '▾' : '▸'} ${t.name}`}
720 plain
721 onPress={() => on.toggleTool(d.name, t.name)}
722 />
723 </Box>
724 <Box flexGrow={1} flexShrink={1}>
725 <Text dimColor wrap="truncate">
726 {t.description.split('\n')[0]}
727 </Text>
728 </Box>
729 {m && (
730 <Box flexShrink={0}>
731 <Text {...(m.errors ? { color: BAD } : { dimColor: true })}>
732 {m.errors ? `${m.errors}/${m.calls} failed` : plural(m.calls, 'call')}
733 </Text>
734 </Box>
735 )}
736 </Box>
737 {isOpen && (
738 <Box flexDirection="column" paddingLeft={2} marginBottom={1}>
739 <Text dimColor>{t.description}</Text>
740 <Code source={tool.schema} language="json" />
741 </Box>
742 )}
743 </Box>
744 );
745 })}
746 </Box>
747
748 <Box flexDirection="column">
749 <Heading el={el} title="Configuration" aside="(values are never shown)" />
750 {d.config.length === 0 && <Text dimColor>No env vars, headers or secrets.</Text>}
751 {d.config.map((c) => (
752 <Box key={`cfg:${c.kind}:${c.key}`} flexWrap="wrap" columnGap={1}>
753 <Text wrap="truncate">
754 <Text bold>{c.key}</Text>
755 <Text dimColor> {c.kind} · </Text>
756 <Text {...color(SOURCE_TONE[c.source])}>{SOURCE[c.source]}</Text>
757 </Text>
758 {c.source !== 'missing' && (
759 <Button
760 key={`secedit:${c.key}`}
761 label={c.source === 'secret' ? 'Replace' : 'Store as secret'}
762 dimColor
763 onPress={() => on.editSecret(c.key)}
764 />
765 )}
766 {c.source === 'secret' && (
767 <Button
768 key={`secdel:${c.key}`}
769 label="Delete"
770 dimColor
771 onPress={() => on.deleteSecret(d.name, c.key)}
772 />
773 )}
774 </Box>
775 ))}
776 {[
777 ...missing.map((c) => c.key),
778 ...(editing && !missing.some((c) => c.key === editing) ? [editing] : []),
779 ].map((key) => {
780 const id = maskId(`secret:${d.name}`, key);
781 return (
782 <Masked
783 key={`secset:${key}`}
784 el={el}
785 on={on}
786 id={id}
787 field={`secset:${key}`}
788 label={key}
789 length={masked[id] ?? 0}
790 submitLabel="save"
791 onSubmit={() => on.setSecret(d.name, key)}
792 />
793 );
794 })}
795 {editing && (
796 <Button
797 key="secedit-cancel"
798 label="Cancel"
799 plain
800 dimColor
801 onPress={() => on.editSecret(null)}
802 />
803 )}
804 {Input && !editing && (
805 <Input
806 key="secadd"
807 label="+ Secret"
808 placeholder="KEY_NAME, then its value"
809 submitLabel="next"
810 onSubmit={on.addSecret}
811 />
812 )}
813 </Box>
814 </Box>
815 );
816}
817
818type BrowseProps = ViewProps & {
819 browse: AgentDiscoverBrowse | null;
820 plan: AgentDiscoverPlan | null;
821 masked: Record<string, number>;
822};
823
824function Browse({ el, on, browse, plan, masked }: BrowseProps) {
825 const { Box, Text, Button, Input } = el;
826 if (plan) return <Plan el={el} on={on} plan={plan} masked={masked} />;
827 return (
828 <Box flexDirection="column" gap={1}>
829 <Box flexDirection="column">
830 {Input && (
831 <Input
832 key="search"
833 label="Search"
834 placeholder="what do you need? e.g. postgres"
835 value={browse?.query ?? ''}
836 submitLabel="search"
837 onSubmit={on.search}
838 />
839 )}
840 {!browse && (
841 <Text dimColor>
842 The MCP Registry, npm and PyPI. A result shows exactly what it runs before anything is
843 installed.
844 </Text>
845 )}
846 {browse && !browse.error && (
847 <Text dimColor>
848 {browse.results.length
849 ? `${plural(browse.results.length, 'result')} for “${browse.query}” · Enter shows the install plan`
850 : `Nothing found for “${browse.query}”.`}
851 </Text>
852 )}
853 {browse?.error && <Text color={BAD}>{browse.error}</Text>}
854 <Button
855 key="sync"
856 label="Sync the MCP Registry mirror"
857 plain
858 dimColor
859 onPress={on.syncRegistry}
860 />
861 </Box>
862 {browse && browse.results.length > 0 && (
863 <Box flexDirection="column">
864 {browse.results.map((r) => (
865 <Box key={`res:${r.source}:${r.name}`} flexDirection="column">
866 <Box columnGap={1}>
867 <Box flexShrink={1}>
868 {r.installed ? (
869 <Button
870 key={`installed:${r.installed}`}
871 label={`✓ ${r.name}`}
872 plain
873 onPress={() => r.installed && on.open(r.installed)}
874 />
875 ) : (
876 <Button
877 key={`plan:${r.source}:${r.name}`}
878 label={`▸ ${r.name}`}
879 plain
880 onPress={() => on.showPlan(r)}
881 />
882 )}
883 </Box>
884 <Box flexShrink={0}>
885 <Text dimColor>
886 {[r.version, r.source, r.status !== 'active' ? r.status : '']
887 .filter(Boolean)
888 .join(' · ')}
889 </Text>
890 {r.installed && <Text color={OK}> · installed</Text>}
891 </Box>
892 </Box>
893 <Box paddingLeft={2}>
894 <Text dimColor wrap="truncate">
895 {r.description}
896 </Text>
897 </Box>
898 </Box>
899 ))}
900 </Box>
901 )}
902 </Box>
903 );
904}
905
906function Plan({
907 el,
908 on,
909 plan,
910 masked,
911}: ViewProps & { plan: AgentDiscoverPlan; masked: Record<string, number> }) {
912 const { Box, Text, Button, Input, Code } = el;
913 const runs = plan.command
914 ? [plan.command, ...plan.args].map(quote).join(' ')
915 : `${plan.url ?? ''} (${plan.transport})`;
916 const facts: [string, string | undefined, string][] = [
917 plan.pinned ? ['✓', OK, 'version pinned'] : ['!', WARN, 'version not pinned: it can change'],
918 ...(plan.publisher
919 ? [
920 ['✓', OK, `publisher ${plan.publisher}, verified by the MCP Registry`] as [
921 string,
922 string,
923 string,
924 ],
925 ]
926 : []),
927 ...(plan.registry_status && plan.registry_status !== 'active'
928 ? [['!', WARN, `registry status ${plan.registry_status}`] as [string, string, string]]
929 : []),
930 ...plan.checks.map((c): [string, string | undefined, string] => {
931 const [mark, tone] = CHECK[c.status] ?? ['·', undefined];
932 return [mark, tone, `${human(c.id)}${c.detail ? `: ${c.detail}` : ''}`];
933 }),
934 ...(plan.repository
935 ? [['·', undefined, `source ${plan.repository}`] as [string, undefined, string]]
936 : []),
937 ];
938 const missing = plan.requirements.filter(
939 (r) => r.required && !r.present && !plan.filled.includes(r.key),
940 );
941 return (
942 <Box flexDirection="column" gap={1}>
943 <Box flexDirection="column">
944 <Back el={el} k="plan-back" label="Results" onPress={on.cancelPlan} />
945 <Text bold wrap="truncate">
946 Install {plan.server}
947 {plan.version ? ` ${plan.version}` : ''}?
948 </Text>
949 <Text dimColor wrap="truncate">
950 {plan.name} · {plan.source}
951 </Text>
952 </Box>
953 <Box flexDirection="column">
954 <Text dimColor>{plan.command ? 'Runs on this machine' : 'Connects to'}</Text>
955 <Code source={runs} language="shell" />
956 </Box>
957 <Box flexDirection="column">
958 <Heading el={el} title="Checks" />
959 {facts.map(([mark, tone, text]) => (
960 <Marked key={`fact:${text}`} el={el} mark={mark} tone={tone} text={text} />
961 ))}
962 {plan.warnings.map((w) => (
963 <Marked key={`warn:${w}`} el={el} mark="!" tone={WARN} text={w} />
964 ))}
965 {plan.blocked && (
966 <Marked el={el} mark="✗" tone={BAD} text={`Cannot install: ${plan.blocked}`} />
967 )}
968 </Box>
969 {plan.requirements.length > 0 && (
970 <Box flexDirection="column">
971 <Heading el={el} title="Needs" />
972 {plan.requirements.map((r) => {
973 const isTyped = plan.filled.includes(r.key);
974 const isSet = r.present || isTyped;
975 return (
976 <Box key={`req:${r.key}`} flexDirection="column">
977 <Text wrap="truncate">
978 <Text {...color(isSet ? OK : r.required ? WARN : undefined)}>
979 {isSet ? '✓' : r.required ? '!' : '·'}{' '}
980 </Text>
981 <Text bold>{r.key}</Text>
982 <Text dimColor>
983 {' '}
984 {[
985 r.kind,
986 r.required ? 'required' : 'optional',
987 r.secret && 'secret',
988 r.present ? 'already set' : isTyped && 'typed',
989 ]
990 .filter(Boolean)
991 .join(' · ')}
992 </Text>
993 </Text>
994 {!r.present &&
995 (r.secret ? (
996 <Masked
997 key={`reqset:${r.key}`}
998 el={el}
999 on={on}
1000 id={maskId('plan', r.key)}
1001 field={`reqset:${r.key}`}
1002 label={` ${r.key}`}
1003 length={masked[maskId('plan', r.key)] ?? 0}
1004 submitLabel="set"
1005 onSubmit={() => on.fillSecret(r.key)}
1006 />
1007 ) : (
1008 Input && (
1009 <Input
1010 key={`reqset:${r.key}`}
1011 label={` ${r.key}`}
1012 placeholder="value"
1013 submitLabel="set"
1014 onSubmit={(v) => on.fill(r.key, v)}
1015 />
1016 )
1017 ))}
1018 </Box>
1019 );
1020 })}
1021 </Box>
1022 )}
1023 {!plan.blocked && missing.length > 0 && (
1024 <Text color={WARN}>Set {missing.map((r) => r.key).join(', ')} to install.</Text>
1025 )}
1026 <Box flexWrap="wrap" columnGap={1}>
1027 {!plan.blocked && missing.length === 0 && (
1028 <Button
1029 key="install-enable"
1030 label="Install and enable"
1031 variant="primary"
1032 onPress={() => on.install(true)}
1033 />
1034 )}
1035 {!plan.blocked && missing.length === 0 && (
1036 <Button key="install" label="Install only" onPress={() => on.install(false)} />
1037 )}
1038 <Button key="plan-cancel" label="Cancel" dimColor onPress={on.cancelPlan} />
1039 </Box>
1040 </Box>
1041 );
1042}
1043
1044function Logs({ el, on, logs }: ViewProps & { logs: AgentDiscoverLogs | null }) {
1045 const { Box, Text, Button } = el;
1046 const callWidth = Math.max(
1047 0,
1048 ...(logs?.entries ?? []).map((e) => e.server.length + e.tool.length + 1),
1049 );
1050 return (
1051 <Box flexDirection="column" gap={1}>
1052 <Box columnGap={2}>
1053 <Heading
1054 el={el}
1055 title="Recent calls"
1056 aside={logs ? `${logs.total} kept · newest first` : undefined}
1057 />
1058 <Button key="logs-reload" label="Reload" plain dimColor onPress={on.reloadLogs} />
1059 </Box>
1060 <Box flexDirection="column">
1061 {!logs && <Text dimColor>Loading…</Text>}
1062 {logs?.entries.length === 0 && (
1063 <Text dimColor>No calls yet. Tool calls through agent-discover show here.</Text>
1064 )}
1065 {logs?.entries.map((e) => (
1066 <Box key={`log:${e.id}`} flexDirection="column">
1067 <Text wrap="truncate">
1068 <Text dimColor>{time(e.time)} </Text>
1069 <Text {...color(e.error ? BAD : undefined)}>
1070 {e.error ? '✗' : '✓'} {`${e.server}/${e.tool}`.padEnd(callWidth)}
1071 </Text>
1072 <Text dimColor> {`${e.ms} ms`.padStart(8)}</Text>
1073 </Text>
1074 {e.error && (
1075 <Box paddingLeft={2}>
1076 <Text color={BAD} wrap="truncate">
1077 {e.error}
1078 </Text>
1079 </Box>
1080 )}
1081 </Box>
1082 ))}
1083 </Box>
1084 </Box>
1085 );
1086}
1087
1088function Audit({ el, on, audit }: ViewProps & { audit: AgentDiscoverAudit | null }) {
1089 const { Box, Text, Button, Input, Select } = el;
1090 const actionWidth = Math.max(0, ...(audit?.entries ?? []).map((e) => e.action.length));
1091 /** Entries on this page and every newer one: the cursors are the pages above. */
1092 const seen = audit ? audit.cursors.length * AUDIT_PAGE + audit.entries.length : 0;
1093 return (
1094 <Box flexDirection="column" gap={1}>
1095 <Box flexDirection="column">
1096 <Box columnGap={2}>
1097 <Heading el={el} title="Audit log" aside="installs, approvals, secrets, calls" />
1098 <Button key="audit-reload" label="Reload" plain dimColor onPress={on.reloadAudit} />
1099 </Box>
1100 {Select && (
1101 <Select
1102 key="audit-action"
1103 label="Action"
1104 value={audit?.action ?? ''}
1105 options={[
1106 { value: '', label: 'all' },
1107 ...AUDIT_ACTIONS.map((a) => ({ value: a, label: a })),
1108 ]}
1109 onSelect={(v) => on.filterAudit('action', v)}
1110 />
1111 )}
1112 {Input && (
1113 <Input
1114 key="audit-server"
1115 label="Server"
1116 placeholder="all"
1117 value={audit?.server ?? ''}
1118 submitLabel="filter"
1119 onSubmit={(v) => on.filterAudit('server', v.trim())}
1120 />
1121 )}
1122 </Box>
1123 <Box flexDirection="column">
1124 {!audit && <Text dimColor>Loading…</Text>}
1125 {audit?.entries.length === 0 && <Text dimColor>No entries.</Text>}
1126 {audit?.entries.map((e) => (
1127 <Text key={`audit:${e.id}`} wrap="truncate">
1128 <Text dimColor>{time(e.ts)} </Text>
1129 <Text {...color(e.isError ? BAD : undefined)}>{e.action.padEnd(actionWidth)} </Text>
1130 <Text>
1131 {e.server ?? ''}
1132 {e.tool ? `/${e.tool}` : ''}
1133 </Text>
1134 {e.ms !== null && <Text dimColor> {e.ms} ms</Text>}
1135 </Text>
1136 ))}
1137 </Box>
1138 {audit && (
1139 <Box flexWrap="wrap" columnGap={2}>
1140 <Text dimColor>
1141 {audit.entries.length
1142 ? `${seen - audit.entries.length + 1}-${seen} of ${audit.total}`
1143 : `${audit.total} total`}
1144 </Text>
1145 {audit.cursors.length > 0 && (
1146 <Button key="audit-newer" label="‹ Newer" plain onPress={on.newerAudit} />
1147 )}
1148 {seen < audit.total && (
1149 <Button key="audit-older" label="Older ›" plain onPress={on.olderAudit} />
1150 )}
1151 </Box>
1152 )}
1153 </Box>
1154 );
1155}
1156
1157export function Band({ el, snap, on }: ViewProps & { snap: AgentDiscoverSnapshot }) {
1158 const { Box, Text, Button } = el;
1159 const what = [
1160 snap.attention.length > 0 &&
1161 `${snap.attention.join(', ')} ${snap.attention.length === 1 ? 'needs' : 'need'} a look`,
1162 snap.elicitations.length > 0 &&
1163 `${plural(snap.elicitations.length, 'question')} waiting for you`,
1164 ].filter(Boolean);
1165
1166 return (
1167 <Box columnGap={1} flexWrap="wrap">
1168 <Text color={WARN}>MCP: {what.join(' · ')}</Text>
1169 <Button key="open" label="Review" variant="primary" onPress={on.openFromBand} />
1170 <Button key="dismiss" label="Dismiss" dimColor onPress={on.dismiss} />
1171 </Box>
1172 );
1173}
1174types/index.d.ts 213 lines1/** One installed server: GET /api/status merged with its GET /api/servers row. */
2export type AgentDiscoverServer = {
3 id: number;
4 name: string;
5 description: string;
6 transport: string;
7 enabled: boolean;
8 quarantined: boolean;
9 tool_count: number;
10 health_status: string;
11 error_count: number;
12 /** `deleted` when its MCP Registry entry was taken down; null when not from the registry. */
13 registry_status: string | null;
14 registry_name: string | null;
15 /** The npm or PyPI package it was installed from; Browse matches results by it. */
16 package_name: string | null;
17};
18
19/** One field of an upstream elicitation form (requestedSchema property). */
20export type AgentDiscoverField = {
21 name: string;
22 title: string;
23 type: 'string' | 'number' | 'integer' | 'boolean';
24 /** Allowed values (enum), drawn as a Select. */
25 options: string[];
26 required: boolean;
27};
28
29/** An upstream server's question waiting for the person (GET /api/elicitations). */
30export type AgentDiscoverElicitation = {
31 id: string;
32 server: string;
33 message: string;
34 fields: AgentDiscoverField[];
35};
36
37/** What the daemon looked like at the last poll. `isUp` false: nothing else is meaningful. */
38export type AgentDiscoverSnapshot = {
39 isUp: boolean;
40 /** Daemon origin, e.g. http://127.0.0.1:3424 */
41 origin: string;
42 servers: AgentDiscoverServer[];
43 elicitations: AgentDiscoverElicitation[];
44 /** Names of servers that need a look (quarantined, or enabled and unhealthy). */
45 attention: string[];
46};
47
48export type AgentDiscoverTab = 'servers' | 'browse' | 'logs' | 'audit';
49
50/** Which view the pane shows; `server` set on the servers tab is that server's detail. */
51export type AgentDiscoverRoute = { tab: AgentDiscoverTab; server: string | null };
52
53export type AgentDiscoverConfigKey = {
54 key: string;
55 kind: 'env' | 'header' | 'secret';
56 source: 'secret' | 'value' | 'missing';
57};
58
59/** A server's detail view. Env and header values are never carried, only their keys. */
60export type AgentDiscoverDetail = {
61 id: number;
62 name: string;
63 description: string;
64 transport: string;
65 command: string | null;
66 args: string[];
67 url: string | null;
68 tags: string[];
69 source: string;
70 registry_name: string | null;
71 registry_status: string | null;
72 package_name: string | null;
73 package_version: string | null;
74 enabled: boolean;
75 quarantined: boolean;
76 connected: boolean;
77 health_status: string;
78 last_health_check: string | null;
79 error_count: number;
80 /**
81 * Every env var and header the server is started with, plus stored secrets: where its
82 * value comes from (`secret` in the keychain, a plain `value` in the config, or
83 * `missing`: a declared header nothing fills). Values themselves never enter state.
84 */
85 config: AgentDiscoverConfigKey[];
86 tools: { name: string; description: string }[];
87 metrics: { tool: string; calls: number; errors: number; avg_ms: number }[];
88 /** Present while quarantined: what changed since the last approval. */
89 drift: {
90 /** `description`: the approved text and the one the server reports now. */
91 changed: {
92 tool: string;
93 what: string;
94 description: { before: string; after: string } | null;
95 }[];
96 added: string[];
97 removed: string[];
98 } | null;
99 /** The tool hashes the trust report showed; an approval echoes exactly these. */
100 hashes: string[];
101 /** OAuth state of a remote server; null for stdio. */
102 auth: { status: string; authorize_url: string | null } | null;
103 /** The last health check run from the pane (on open for an enabled server, or pressed). */
104 health: { status: string; latency_ms: number; error: string | null } | null;
105};
106
107/** A tool whose input schema is unfolded in the detail view. */
108export type AgentDiscoverTool = { server: string; tool: string; schema: string };
109
110export type AgentDiscoverEntry = {
111 source: string;
112 name: string;
113 description: string;
114 version: string;
115 status: string;
116 /** The installed server this result is (matched by name, registry name or package). */
117 installed: string | null;
118};
119
120export type AgentDiscoverBrowse = {
121 query: string;
122 results: AgentDiscoverEntry[];
123 error: string | null;
124};
125
126/** The install plan shown for consent. Requirement values typed in stay out of state. */
127export type AgentDiscoverPlan = {
128 source: string;
129 name: string;
130 version: string | null;
131 server: string;
132 transport: string;
133 command: string | null;
134 args: string[];
135 url: string | null;
136 pinned: boolean;
137 publisher: string | null;
138 registry_status: string | null;
139 repository: string | null;
140 checks: { id: string; status: string; detail: string }[];
141 warnings: string[];
142 blocked: string | null;
143 requirements: {
144 key: string;
145 kind: string;
146 required: boolean;
147 secret: boolean;
148 present: boolean;
149 }[];
150 /** Keys the person typed a value for. */
151 filled: string[];
152};
153
154export type AgentDiscoverLogs = {
155 entries: {
156 id: number;
157 time: string;
158 server: string;
159 tool: string;
160 ms: number;
161 error: string | null;
162 }[];
163 total: number;
164};
165
166export type AgentDiscoverAudit = {
167 entries: {
168 id: number;
169 ts: string;
170 action: string;
171 server: string | null;
172 tool: string | null;
173 isError: boolean;
174 /** How long a call_tool took; null for other actions. */
175 ms: number | null;
176 }[];
177 total: number;
178 server: string;
179 action: string;
180 /** `before` cursors of the pages above this one; empty on the newest page. */
181 cursors: number[];
182 before: number | null;
183};
184
185declare module 'claude-code' {
186 interface PluginState {
187 'agent-discover': {
188 snapshot: AgentDiscoverSnapshot | null;
189 route: AgentDiscoverRoute;
190 detail: AgentDiscoverDetail | null;
191 tool: AgentDiscoverTool | null;
192 /** An action waiting for a second press ("uninstall:github"). */
193 confirm: string | null;
194 browse: AgentDiscoverBrowse | null;
195 plan: AgentDiscoverPlan | null;
196 logs: AgentDiscoverLogs | null;
197 audit: AgentDiscoverAudit | null;
198 /** The action in flight ("enable fixture"), shown instead of its outcome. */
199 busy: string | null;
200 /** The last action's outcome, one line. */
201 notice: string | null;
202 /** Attention signature the person dismissed from the band; it returns when that changes. */
203 dismissed: string | null;
204 /** Length of what was typed into each masked field (secret values), by field id. */
205 masked: Record<string, number>;
206 /** The config key whose secret is being set in the server detail. */
207 editing: string | null;
208 /** The pane is open and drawn: the attention band stays out of its way. */
209 paneOpen: boolean;
210 };
211 }
212}
213