SLOPSHOPPER

agent-discover

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

newpanebandcommandtoaststatus
★ 7v3.2.2MITupdated 2026-10-09keshrath/agent-discover/plugin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-discover
│ ┃ agent-discover ✕ › fix the failing auth test and add an audit log call │ ┃ The agent-discover daemon is not running │ ┃ Nothing answers at http://127.0.0.1:3424. Th ⏺ Read(src/auth.ts) │ ┃ call starts it, or run: ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ agent-discover daemon ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ [ Retry ] ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /discover │ ⎿ agent-discover: daemon not reachable at http://127.0.0.1:3424 │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · agent-discover
The agent-discover daemon is not running Nothing answers at http://127.0.0.1:3424. The next MCP call starts it, or run: agent-discover daemon [ Retry ]
README

agent-discover

License: MIT Node.js [Tests]() [MCP Tools]()

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.


Why

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:

  • Discover. One query searches your installed servers' tool index and the public registries (a local mirror of the official MCP Registry, plus npm and PyPI).
  • Install with consent. 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.
  • Enable and disable at runtime, including servers that were installed a minute ago. Enabling indexes the server first if needed.
  • Feed the host's own tool search. In 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.
  • Stay in control. See Trust layer.
Static MCP configWith agent-discover
DiscoveryKnow the server name in advanceSearch the registries by need, with provenance
InstallationEdit config, restart the sessionOne tool call, you approve the exact command
EnablementAll configured servers always loadEnable and disable mid-session; the index stays searchable
SecretsKeys in config files or envOS keychain (or an encrypted file), injected on connect
Changed toolsSilentPinned hashes, drift quarantines the server until you approve
VisibilityPer-host logs/discover pane, health, per-tool metrics, audit log, OTel

Quick Start

Claude Code

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

The /discover pane docked beside the transcript

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.
  • A status line entry 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.
  • One context block at the start of each conversation tells the model which servers are enabled and to call search_tools before saying a capability is missing.
  • The mod needs Claude Code 2.1.289 or newer. Older builds keep the skills and tools.
  • Remove any hand-written agent-discover entry from ~/.claude.json so tools do not appear twice.

Any other MCP host

{
  "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.

From source

git clone https://github.com/keshrath/agent-discover.git
cd agent-discover
npm install
npm run build
node dist/index.js daemon

How it runs

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.


MCP tools (8)

ToolPurpose
search_serversInstalled servers plus public registry / npm / PyPI matches, for something not installed yet.
install_serverInstall 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_serverExpose a server's tools to the host. A quarantined server asks you to review a diff and re-approve first.
disable_serverStop exposing the tools. The server stays installed and searchable.
server_statusInstalled / indexed / enabled / connected / quarantined, tool counts, flagged tools, health (check_health runs a live probe).
search_toolsBatch search (up to 10 queries) across the tool index of every installed server, enabled or not.
get_toolFull definition of one indexed tool by server and name.
call_toolCall 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.


Trust layer

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.

  • Install consent with provenance. The plan shows the exact command, pinned version and registry namespace / package metadata checks. There is deliberately no agent-supplied confirm argument.
  • Tool pinning and quarantine. The first index of a server pins a hash of every tool's description, input schema and annotations. Any later change, addition or removal quarantines the server until you re-approve it from a readable diff (or the tools revert).
  • Description hygiene. Control, zero-width and bidi characters are stripped, descriptions are capped, and six heuristics flag likely prompt injection. Flags are advisory; pinning is the control.
  • Secrets in the OS keychain, or an AES-256-GCM file when no keychain is available. Never in SQLite, never listed unmasked.
  • Audit log, append-only, readable at GET /api/audit.
  • OpenTelemetry, opt-in via OTEL_EXPORTER_OTLP_ENDPOINT or AGENT_DISCOVER_OTEL=1.
  • Loopback only. Host / Origin allowlist, JSON-only bodies, and a per-launch token on every state-changing REST call.
  • OAuth 2.1 for remote servers (discovery, PKCE, refresh, 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 quality

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).

RankerR@1R@5R@10MRR
plain BM25 (about what hosted BM25 tool search does)0.3050.5220.6180.430
agent-discover 1.x0.3070.5610.6420.446
agent-discover 2.0, zero config0.3380.5760.6610.474
2.0 + multilingual-e5-small (opt-in)0.3810.6180.7220.522

Read this honestly:

  • The zero-config gain over plain BM25 is modest (+4 points R@10). The dense option adds about 6 more.
  • It is retrieval only: no LLM chooses a tool, and the corpus is public servers with their real, uneven descriptions.
  • The e5 row needs 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.
  • A 5-query-per-tool LLM enrichment of descriptions reached R@10 0.922 offline, but that is bench-only and not shipped: it needs an LLM at index time, and the labelled queries were also LLM-written, so treat it as an upper bound.
  • No score floor is applied. No signal separated the unanswerable queries from answerable ones, so 2.0 returns the best matches rather than pretending to reject.

Methodology, per-category results and how to run it: bench/retrieval/README.md. CI runs npm run bench:retrieval -- --check.


Features

  • Local registry in SQLite: servers, indexed tools, per-server secrets, metrics. Indexing is independent of enablement: install probes the server once, persists its tools and disconnects.
  • Registry mirror of the official MCP Registry (v0.1 API) with incremental updated_since sync, deprecated and deleted status tracking, and exact-name installs. A server whose registry entry was deleted is flagged in server_status.
  • Federated search over the registry mirror, npm and PyPI.
  • Transports to upstream servers: stdio, SSE, streamable HTTP, both protocol eras negotiated automatically. Upstream input_required and 2025 elicitation/create requests are relayed to your client.
  • Lazy, pooled connections with reconnect backoff, idle disconnect and real health probes.
  • Three UI surfaces: the Claude Code /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.
  • Declarative setup file (AGENT_DISCOVER_SETUP_FILE) listing servers to ensure installed at daemon start.
  • Per-tool metrics and a call log.

Environment variables

The common ones. The complete list is in docs/API.md.

VariableDefaultDescription
AGENT_DISCOVER_PORT3424Daemon port
AGENT_DISCOVER_HOST127.0.0.1Listen address
AGENT_DISCOVER_DATA_DIRplatform data dirData directory (see Upgrading from 1.x)
AGENT_DISCOVER_DBagent-discover.db in itSQLite file path
AGENT_DISCOVER_MODEnativenative lists enabled servers' tools; proxy lists only the meta tools
AGENT_DISCOVER_IDLE_MS1800000Daemon idle exit (0 = never)
AGENT_DISCOVER_SETUP_FILEunsetDeclarative server list synced at start
AGENT_DISCOVER_EMBEDDING_PROVIDERnonelocal or openai adds semantic ranking
AGENT_DISCOVER_SECRETSautokeyring or file forces the secret backend

Upgrading

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.


Known limitations

  • Claude Code forwards a tool's 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.
  • Docker sandboxing for stdio servers is not included yet (deferred).

Testing

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

Documentation


License

MIT, see LICENSE

Source 3 files
hooks/register.tsx 879 lines
1// 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};
879
hooks/view.tsx 1174 lines
1// 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}
1174
types/index.d.ts 213 lines
1/** 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