agent-from-zero 0.35.1 → 0.35.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/mcp.js CHANGED
@@ -5,7 +5,7 @@ import os from "node:os"; // home directory for token persistence
5
5
  import path from "node:path"; // path joining
6
6
  import fs from "node:fs"; // token file persistence
7
7
  import chalk from "chalk"; // status lines
8
- import { CONFIG } from "./config.js"; // configured servers
8
+ import { CONFIG, GLOBAL_SETTINGS_PATH, PROJECT_SETTINGS_PATH, mcpConfigPath, readMcpServers } from "./config.js"; // configured servers + hot reload
9
9
  import { registerExternalTool, unregisterExternalTool } from "./tools.js"; // expose discovered tools
10
10
  import { emit } from "./telemetry.js"; // observability
11
11
  // MCP (Model Context Protocol) lets the agent borrow tools from external
@@ -82,6 +82,7 @@ class StdioTransport {
82
82
  });
83
83
  this.child.stderr.on("data", () => { }); // the server's stderr is its own logging — never our stdout
84
84
  }
85
+ onNotification;
85
86
  onMessage(line) {
86
87
  let msg;
87
88
  try {
@@ -90,6 +91,15 @@ class StdioTransport {
90
91
  catch {
91
92
  return; // ignore anything that isn't valid JSON-RPC
92
93
  }
94
+ if (msg.method) {
95
+ if (msg.id === undefined)
96
+ return this.onNotification?.(msg.method, msg.params); // e.g. tools/list_changed
97
+ // A server→client request. We answer ping (the spec's liveness check) and
98
+ // refuse the rest — an unanswered request would leave the server waiting.
99
+ const reply = msg.method === "ping" ? { result: {} } : { error: { code: -32601, message: `Method not found: ${msg.method}` } };
100
+ this.child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id: msg.id, ...reply }) + "\n");
101
+ return;
102
+ }
93
103
  const resolver = this.pending.get(msg.id);
94
104
  if (resolver) {
95
105
  this.pending.delete(msg.id);
@@ -141,6 +151,8 @@ class HttpTransport {
141
151
  url;
142
152
  headers;
143
153
  sessionId; // assigned by the server on initialize, echoed on every later request
154
+ listening; // the standalone GET stream, if open
155
+ onNotification;
144
156
  constructor(def, token) {
145
157
  this.url = def.url;
146
158
  this.headers = { ...def.headers };
@@ -171,17 +183,82 @@ class HttpTransport {
171
183
  return null; // a notification expects no body (202 Accepted)
172
184
  const raw = await res.text();
173
185
  const payloads = res.headers.get("content-type")?.includes("text/event-stream") ? parseSseData(raw) : [raw];
186
+ let response = null;
174
187
  for (const p of payloads) {
188
+ // An SSE response may carry server notifications BEFORE the response we
189
+ // came for (Streamable HTTP allows it) — deliver those, keep looking.
190
+ if (this.deliver(p))
191
+ continue;
175
192
  try {
176
193
  const msg = JSON.parse(p);
177
- if (msg && (msg.result !== undefined || msg.error !== undefined))
178
- return msg; // the response we came for
194
+ if (!response && msg && (msg.result !== undefined || msg.error !== undefined))
195
+ response = msg; // the response we came for
179
196
  }
180
197
  catch {
181
198
  /* skip non-JSON frames (SSE comments, keep-alives) */
182
199
  }
183
200
  }
184
- return null;
201
+ return response;
202
+ }
203
+ // Hand one raw payload to onNotification if it is a server notification.
204
+ // Returns true when it was one (so the caller doesn't treat it as a response).
205
+ deliver(raw) {
206
+ try {
207
+ const msg = JSON.parse(raw);
208
+ if (!msg?.method || msg.id !== undefined)
209
+ return false;
210
+ this.onNotification?.(msg.method, msg.params);
211
+ return true;
212
+ }
213
+ catch {
214
+ return false;
215
+ }
216
+ }
217
+ // Streamable HTTP's server→client channel: a long-lived GET that the server
218
+ // writes SSE frames into whenever it wants — that's where list_changed
219
+ // arrives between our requests. A server without one answers 405; then
220
+ // notifications can still ride along on POST responses (above). A dropped
221
+ // stream reconnects with backoff; one that keeps dying quickly gives up.
222
+ listen() {
223
+ if (this.listening)
224
+ return;
225
+ const ctrl = (this.listening = new AbortController());
226
+ const run = async (attempt) => {
227
+ const started = Date.now();
228
+ try {
229
+ const res = await fetch(this.url, {
230
+ method: "GET",
231
+ headers: { accept: "text/event-stream", ...(this.sessionId ? { "mcp-session-id": this.sessionId } : {}), ...this.headers },
232
+ signal: ctrl.signal,
233
+ });
234
+ if (!res.ok || !res.body || !res.headers.get("content-type")?.includes("text/event-stream"))
235
+ return; // no standalone stream (405 etc.) — not an error
236
+ const reader = res.body.getReader();
237
+ const decoder = new TextDecoder();
238
+ let buf = "";
239
+ for (;;) {
240
+ const { value, done } = await reader.read();
241
+ if (done)
242
+ break;
243
+ buf += decoder.decode(value, { stream: true });
244
+ const frames = buf.split(/\r?\n\r?\n/);
245
+ buf = frames.pop() ?? ""; // an incomplete frame waits for the next chunk
246
+ for (const frame of frames)
247
+ for (const data of parseSseData(frame))
248
+ this.deliver(data);
249
+ }
250
+ }
251
+ catch {
252
+ if (ctrl.signal.aborted)
253
+ return; // closed on purpose
254
+ }
255
+ if (ctrl.signal.aborted)
256
+ return;
257
+ const next = Date.now() - started > 30_000 ? 0 : attempt + 1; // a stream that lived a while resets the backoff
258
+ if (next < 5)
259
+ setTimeout(() => void run(next), 1000 * 2 ** next).unref(); // never keeps the process alive
260
+ };
261
+ void run(0);
185
262
  }
186
263
  async request(payload, timeoutMs) {
187
264
  try {
@@ -202,6 +279,7 @@ class HttpTransport {
202
279
  this.headers["authorization"] = `Bearer ${token}`; // a refreshed token; the session id stays valid
203
280
  }
204
281
  close() {
282
+ this.listening?.abort(); // stop the event stream first
205
283
  // Best-effort: tell the server to drop the session. No await, no error care.
206
284
  if (this.sessionId) {
207
285
  void fetch(this.url, { method: "DELETE", headers: { "mcp-session-id": this.sessionId, ...this.headers } }).catch(() => { });
@@ -217,8 +295,15 @@ function makeTransport(def, token) {
217
295
  class McpClient {
218
296
  transport;
219
297
  nextId = 1; // JSON-RPC request id counter
298
+ onToolsChanged; // set by the registry: re-fetch this server's tools
299
+ protocolVersion; // what the server answered in the handshake (the /mcp panel shows it)
300
+ capabilities = []; // e.g. ["tools", "resources"] — the top-level keys the server declared
220
301
  constructor(def, token) {
221
302
  this.transport = makeTransport(def, token);
303
+ this.transport.onNotification = (method) => {
304
+ if (method === "notifications/tools/list_changed")
305
+ this.onToolsChanged?.();
306
+ };
222
307
  }
223
308
  request(method, params, timeoutMs) {
224
309
  return this.transport.request({ jsonrpc: "2.0", id: this.nextId++, method, params }, timeoutMs);
@@ -228,8 +313,17 @@ class McpClient {
228
313
  const init = await this.request("initialize", { protocolVersion: PROTOCOL_VERSION, capabilities: {}, clientInfo: CLIENT_INFO }, INIT_TIMEOUT_MS);
229
314
  if (init.error)
230
315
  throw new Error(init.error.message); // server refused to initialize
316
+ this.protocolVersion = init.result?.protocolVersion;
317
+ this.capabilities = Object.keys(init.result?.capabilities ?? {});
231
318
  this.transport.notify("notifications/initialized", {}); // tell the server we're ready
232
- const list = await this.request("tools/list", {}, INIT_TIMEOUT_MS); // ask what it offers
319
+ // A server that promises tools/list_changed may change its tools later —
320
+ // open the channel it will announce that on (HTTP's event stream).
321
+ if (init.result?.capabilities?.tools?.listChanged)
322
+ this.transport.listen?.();
323
+ return this.listTools(); // ask what it offers
324
+ }
325
+ async listTools() {
326
+ const list = await this.request("tools/list", {}, INIT_TIMEOUT_MS);
233
327
  if (list.error)
234
328
  throw new Error(list.error.message);
235
329
  return list.result?.tools ?? [];
@@ -595,8 +689,10 @@ function markNeedsAuth(name, challenge) {
595
689
  // Connect a single configured server and register its tools. Updates the
596
690
  // registry with the live status. Used by connectMcpServers() at startup and by
597
691
  // /mcp reconnect / enable / auth.
598
- async function connectOne(name, retried = false) {
692
+ async function connectOne(name, retried = false, quiet = false) {
599
693
  const entry = entryFor(name);
694
+ const attempt = {}; // this connect's identity (see the guard after the handshake)
695
+ entry.attempt = attempt;
600
696
  // Unregister any stale tools from a previous connection.
601
697
  for (const n of entry.toolNames)
602
698
  unregisterExternalTool(n);
@@ -612,21 +708,31 @@ async function connectOne(name, retried = false) {
612
708
  }
613
709
  const client = new McpClient(entry.def, stored?.accessToken);
614
710
  try {
711
+ client.onToolsChanged = () => void refreshServerTools(name, client);
615
712
  const specs = await client.start(); // handshake + discover
713
+ // Connecting runs in the background now, so the world may have moved on
714
+ // while we waited: the user disabled it, reconnected it (a newer attempt),
715
+ // or a settings reload removed it. Then this result is stale — drop it.
716
+ if (registry.get(name) !== entry || entry.attempt !== attempt || entry.status === "disabled")
717
+ return client.kill();
616
718
  entry.client = client;
617
719
  entry.tools = registerTools(name, specs, client);
618
720
  entry.toolNames = specs.map((s) => `mcp__${name}__${s.name}`);
721
+ entry.toolSpecs = specs;
619
722
  entry.status = "connected";
620
723
  entry.error = undefined;
621
724
  emit("agent_mcp_connected", { server: name, tools: specs.length, transport: entry.def.url ? "http" : "stdio" });
622
- console.log(chalk.dim(`(mcp: ${name} — ${specs.length} tools${entry.def.url ? " over http" : ""})`)); // visible at startup / on reconnect
725
+ if (!quiet)
726
+ console.log(chalk.dim(`(mcp: ${name} — ${specs.length} tools${entry.def.url ? " over http" : ""})`)); // visible on reconnect; a background startup stays silent (the status bar reports problems)
623
727
  }
624
728
  catch (err) {
625
729
  client.kill(); // don't leak the process/session
730
+ if (registry.get(name) !== entry || entry.attempt !== attempt || entry.status === "disabled")
731
+ return; // stale attempt — its failure no longer matters
626
732
  if (err instanceof NeedsAuthError) {
627
733
  // A stale token (expired early, revoked): one refresh + retry before giving up.
628
734
  if (!retried && stored?.refreshToken && (await refreshAccessToken(name)))
629
- return connectOne(name, true);
735
+ return connectOne(name, true, quiet);
630
736
  markNeedsAuth(name, err);
631
737
  }
632
738
  else {
@@ -634,22 +740,167 @@ async function connectOne(name, retried = false) {
634
740
  entry.error = err.message;
635
741
  }
636
742
  emit("agent_mcp_failed", { server: name });
637
- console.error(chalk.yellow(` [mcp] ${name} ${entry.status === "needs-auth" ? `needs authentication (run /mcp auth ${name})` : "failed to start"}: ${err.message}`));
743
+ if (!quiet)
744
+ console.error(chalk.yellow(` [mcp] ${name} ${entry.status === "needs-auth" ? `needs authentication (run /mcp auth ${name})` : "failed to start"}: ${err.message}`));
745
+ }
746
+ }
747
+ // The server said its tools changed (notifications/tools/list_changed): ask
748
+ // again and swap the set in place — drop every mcp__<server>__ tool, register
749
+ // the fresh ones, the same swap Claude Code does. The next model call sees the
750
+ // new list (toolDefinitions() is rebuilt per call). A burst of notifications
751
+ // coalesces: one refresh runs, and at most one more follows it.
752
+ const refreshing = new Map();
753
+ async function refreshServerTools(name, client) {
754
+ const running = refreshing.get(name);
755
+ if (running)
756
+ return void (running.again = true); // already fetching — fetch once more when done
757
+ const state = { again: false };
758
+ refreshing.set(name, state);
759
+ try {
760
+ do {
761
+ state.again = false;
762
+ const entry = registry.get(name);
763
+ if (!entry || entry.client !== client)
764
+ return; // reconnected, disabled or removed since — this client is stale
765
+ let specs;
766
+ try {
767
+ specs = await client.listTools();
768
+ }
769
+ catch {
770
+ return; // keep the tools we have; the next notification or a reconnect will retry
771
+ }
772
+ if (entry.client !== client)
773
+ return;
774
+ for (const n of entry.toolNames)
775
+ unregisterExternalTool(n);
776
+ entry.tools = registerTools(name, specs, client);
777
+ entry.toolNames = specs.map((s) => `mcp__${name}__${s.name}`);
778
+ entry.toolSpecs = specs;
779
+ emit("agent_mcp_tools_changed", { server: name, tools: specs.length });
780
+ console.log(chalk.dim(`(mcp: ${name} tools changed — ${specs.length} tools)`));
781
+ } while (state.again);
782
+ }
783
+ finally {
784
+ refreshing.delete(name);
638
785
  }
639
786
  }
640
787
  // Connect to every configured MCP server, register their tools, and return a
641
788
  // cleanup function. Failures are isolated: a server that won't start is logged
642
789
  // and skipped — it never stops the agent or the other servers.
643
- export async function connectMcpServers() {
790
+ //
791
+ // Two modes. The interactive UI passes background: true — the Claude Code way:
792
+ // every server is marked "pending" and connected in PARALLEL without blocking
793
+ // startup; the prompt is usable at once, each server's tools appear the moment
794
+ // it connects, and a failure shows up in the status bar instead of a log line.
795
+ // Print mode (-p) and the readline REPL await everything first: a one-shot
796
+ // task must see all its tools on the very first model call.
797
+ export async function connectMcpServers(options = {}) {
644
798
  const entries = Object.keys(CONFIG.mcpServers); // configured server names
645
- for (const name of entries) {
646
- await connectOne(name); // sequential: a slow start doesn't starve the next one
799
+ if (options.background) {
800
+ for (const name of entries)
801
+ entryFor(name).status = "pending";
802
+ for (const name of entries)
803
+ void connectOne(name, false, true); // fire and forget; connectOne never throws
804
+ }
805
+ else {
806
+ for (const name of entries)
807
+ await connectOne(name); // sequential: a slow start doesn't starve the next one
647
808
  }
648
809
  return () => {
649
810
  for (const entry of registry.values())
650
811
  entry.client?.kill(); // cleanup on exit
651
812
  };
652
813
  }
814
+ // ---- hot reload: pick up settings.json edits without a restart ----------------
815
+ // Adding a server used to mean quitting and relaunching. Now a settings change
816
+ // is DIFFED against what is running and only the difference is touched:
817
+ // added → connect it and register its tools
818
+ // removed → kill it and unregister its tools
819
+ // changed → reconnect it with the new definition
820
+ // same → left alone (an unrelated settings edit reconnects nothing)
821
+ // The tool list sent to the model is rebuilt every turn (toolDefinitions()), so
822
+ // the next model call simply sees the new set. A tool call already in flight to
823
+ // a server being removed fails with a readable error, like any dropped server.
824
+ //
825
+ // Claude Code swaps MCP tools the same way (drop every mcp__<server>__ tool,
826
+ // append the fresh ones), but it only re-reads MCP config on explicit triggers
827
+ // like /reload-plugins. We also watch the files, so a save is enough.
828
+ // Key-order-independent JSON, so reformatting an entry doesn't count as a change.
829
+ const stableJson = (v) => JSON.stringify(v, (_k, val) => (val && typeof val === "object" && !Array.isArray(val) ? Object.fromEntries(Object.entries(val).sort(([a], [b]) => a.localeCompare(b))) : val));
830
+ // Reloads run one at a time: a second save while the first reload is still
831
+ // connecting queues behind it instead of racing it over the same registry.
832
+ let reloadChain = Promise.resolve();
833
+ // Apply a new mcpServers map. `next` is the test seam; by default both settings
834
+ // files are re-read. Returns one human-readable line per change ([] = nothing
835
+ // changed), or a single warning line when the files can't be parsed right now.
836
+ export function reloadMcpServers(next) {
837
+ const run = async () => {
838
+ const servers = next ?? readMcpServers();
839
+ if (!servers)
840
+ return ["settings.json is not valid JSON right now — MCP servers left unchanged"];
841
+ const previous = CONFIG.mcpServers;
842
+ CONFIG.mcpServers = servers; // entryFor / listMcpServers read the new map from here on
843
+ const changes = [];
844
+ for (const name of Object.keys(previous)) {
845
+ if (name in servers)
846
+ continue;
847
+ const entry = registry.get(name);
848
+ if (entry) {
849
+ for (const n of entry.toolNames)
850
+ unregisterExternalTool(n);
851
+ entry.client?.kill();
852
+ registry.delete(name); // gone from the config → gone from /mcp too
853
+ }
854
+ changes.push(`removed ${name}`);
855
+ }
856
+ for (const [name, def] of Object.entries(servers)) {
857
+ const old = previous[name];
858
+ if (old && stableJson(old) === stableJson(def))
859
+ continue; // untouched
860
+ const entry = registry.get(name);
861
+ if (entry)
862
+ entry.def = def; // entryFor only builds new entries, so swap the def by hand
863
+ if (entry?.status === "disabled") {
864
+ changes.push(`updated ${name} (still disabled)`); // the user turned it off this session — respect that
865
+ continue;
866
+ }
867
+ await connectOne(name); // replaces any stale client + tools, logs its own status line
868
+ changes.push(`${old ? "reconnected" : "added"} ${name} (${registry.get(name)?.status})`);
869
+ }
870
+ return changes;
871
+ };
872
+ const result = reloadChain.then(run, run);
873
+ reloadChain = result.catch(() => { }); // a failed reload must not wedge the ones after it
874
+ return result;
875
+ }
876
+ // Watch both settings files and hot-reload on save. Polling (fs.watchFile), not
877
+ // fs.watch: editors often save by writing a temp file and renaming it over the
878
+ // original, which silently detaches an fs.watch handle; a stat poll just sees a
879
+ // new mtime. It also works for a file that doesn't exist yet — create
880
+ // .mini-agent/settings.json mid-session and it is picked up. Returns a stop function.
881
+ const WATCH_INTERVAL_MS = 1000; // how often to stat; Claude Code polls ~/.claude.json at the same rate
882
+ const RELOAD_DEBOUNCE_MS = 300; // a burst of writes (save + format-on-save) → one reload
883
+ export function watchMcpConfig() {
884
+ let timer;
885
+ const onChange = (curr, prev) => {
886
+ if (curr.mtimeMs === prev.mtimeMs)
887
+ return; // a stat tick with nothing new
888
+ clearTimeout(timer);
889
+ timer = setTimeout(async () => {
890
+ const changes = await reloadMcpServers().catch((err) => [`reload failed: ${err.message}`]);
891
+ if (changes.length)
892
+ console.log(chalk.dim(`(mcp config changed: ${changes.join(", ")})`));
893
+ }, RELOAD_DEBOUNCE_MS);
894
+ };
895
+ const files = [GLOBAL_SETTINGS_PATH, PROJECT_SETTINGS_PATH];
896
+ for (const file of files)
897
+ fs.watchFile(file, { interval: WATCH_INTERVAL_MS, persistent: false }, onChange); // persistent:false — never keeps the process alive
898
+ return () => {
899
+ clearTimeout(timer);
900
+ for (const file of files)
901
+ fs.unwatchFile(file, onChange);
902
+ };
903
+ }
653
904
  // ---- /mcp command support ----------------------------------------------------
654
905
  export function listMcpServers() {
655
906
  // Include every CONFIGURED server, even ones never attempted this session.
@@ -658,29 +909,70 @@ export function listMcpServers() {
658
909
  return {
659
910
  name,
660
911
  transport: def.url ? "http" : "stdio",
661
- status: entry?.status ?? "failed",
912
+ status: entry?.status ?? "pending", // configured but not attempted yet (e.g. just added to settings)
662
913
  tools: entry?.tools ?? 0,
663
914
  error: entry?.error,
664
915
  authenticated: Boolean(oauthStore[name]?.accessToken),
665
916
  url: def.url,
917
+ command: def.command ? [def.command, ...(def.args ?? [])].join(" ") : undefined,
918
+ protocolVersion: entry?.status === "connected" ? entry.client?.protocolVersion : undefined,
919
+ capabilities: entry?.status === "connected" ? entry.client?.capabilities ?? [] : [],
920
+ toolSpecs: (entry?.toolSpecs ?? []).map((t) => ({ name: t.name, description: t.description })),
666
921
  };
667
922
  });
668
923
  }
669
- // Which actions make sense for a server in a given state. Mirrors Claude Code's
670
- // per-server menu: authenticate when it needs auth, reconnect when it isn't,
671
- // enable/disable to toggle it off/on.
924
+ // /mcp <subcommand> <server> → the menu action it runs (scriptable, like Claude Code's /mcp).
925
+ export const MCP_SUBCOMMANDS = { tools: "view-tools", auth: "authenticate", authenticate: "authenticate", "clear-auth": "clear-auth", reconnect: "reconnect", enable: "enable", disable: "disable" };
926
+ // Which actions make sense for a server in a given state — Claude Code's menu,
927
+ // in its order: View tools, (Re-)authenticate, Clear authentication,
928
+ // Reconnect, Disable. A disabled server offers only Enable.
672
929
  export function mcpActionsFor(info) {
673
930
  if (info.status === "disabled") {
674
931
  return [{ label: "Enable", action: "enable" }];
675
932
  }
676
933
  const out = [];
934
+ if (info.status === "connected" && info.tools > 0)
935
+ out.push({ label: "View tools", action: "view-tools" });
677
936
  if (info.transport === "http") {
678
937
  out.push({ label: info.authenticated ? "Re-authenticate" : "Authenticate", action: "authenticate" });
938
+ if (info.authenticated)
939
+ out.push({ label: "Clear authentication", action: "clear-auth" });
679
940
  }
680
941
  out.push({ label: "Reconnect", action: "reconnect" });
681
942
  out.push({ label: "Disable", action: "disable" });
682
943
  return out;
683
944
  }
945
+ // The server's details for the /mcp panel, one "Label: value" per line —
946
+ // the same fields Claude Code's server screen shows.
947
+ export function mcpServerDetails(info) {
948
+ const ok = (s) => chalk.green(`✔ ${s}`);
949
+ const status = { connected: ok("connected"), pending: chalk.dim("◌ connecting…"), "needs-auth": chalk.yellow("△ needs authentication"), failed: chalk.red("✗ failed"), disabled: chalk.dim("○ disabled") }[info.status];
950
+ const rows = [["Status", status + (info.status === "failed" && info.error ? chalk.dim(` ${info.error}`) : "")]];
951
+ if (info.transport === "http")
952
+ rows.push(["Auth", info.authenticated ? ok("authenticated") : chalk.dim("not authenticated")]);
953
+ if (info.protocolVersion)
954
+ rows.push(["Protocol", chalk.dim(info.protocolVersion)]);
955
+ rows.push(info.url ? ["URL", chalk.dim(info.url)] : ["Command", chalk.dim(info.command ?? "")]);
956
+ rows.push(["Config location", chalk.dim(mcpConfigPath(info.name))]);
957
+ if (info.capabilities.length)
958
+ rows.push(["Capabilities", info.capabilities.join(" · ")]);
959
+ if (info.status === "connected")
960
+ rows.push(["Tools", chalk.dim(`${info.tools} tool${info.tools === 1 ? "" : "s"}`)]);
961
+ const width = Math.max(...rows.map(([k]) => k.length)) + 3;
962
+ return rows.map(([k, v]) => chalk.bold(`${k}:`.padEnd(width)) + v).join("\n");
963
+ }
964
+ // What the status bar should flag: servers that failed or need auth, and how
965
+ // many are still connecting. Claude Code shows the same in its footer.
966
+ // Only servers we actually tried count (the registry), so a session that never
967
+ // connects MCP (tests, a front-end without it) shows nothing.
968
+ export function mcpProblems() {
969
+ const live = [...registry.entries()].filter(([name]) => name in CONFIG.mcpServers);
970
+ return {
971
+ failed: live.filter(([, e]) => e.status === "failed").map(([name]) => name),
972
+ needsAuth: live.filter(([, e]) => e.status === "needs-auth").map(([name]) => name),
973
+ pending: live.filter(([, e]) => e.status === "pending").length,
974
+ };
975
+ }
684
976
  // Trade the stored refresh token for a new access token, at the token endpoint
685
977
  // recorded when the user authenticated — no rediscovery. A definitive rejection
686
978
  // (400/401: the refresh token itself is dead) wipes the record so the UI tells
@@ -744,6 +1036,26 @@ export async function authenticateMcpServer(name, onUrl) {
744
1036
  ? `Authentication successful. Connected to ${name}.`
745
1037
  : `Authentication done, but ${name} is ${status} — use /mcp reconnect ${name} to retry.`;
746
1038
  }
1039
+ // Forget a server's stored OAuth tokens, then reconnect — without a token it
1040
+ // comes back as "needs auth" (or connected, if its config carries a static
1041
+ // header). The next /mcp auth starts a fresh browser flow.
1042
+ export async function clearMcpAuthentication(name) {
1043
+ if (!CONFIG.mcpServers[name])
1044
+ return `No MCP server named "${name}"`;
1045
+ delete oauthStore[name];
1046
+ writeOAuthStore(oauthStore);
1047
+ await connectOne(name, false, true);
1048
+ return `Cleared authentication for ${name}${entryFor(name).status === "needs-auth" ? ` — run /mcp auth ${name} to sign in again` : ""}.`;
1049
+ }
1050
+ // The tool list as text (name + the first line of its description).
1051
+ export function describeMcpTools(name) {
1052
+ const info = listMcpServers().find((s) => s.name === name);
1053
+ if (!info)
1054
+ return `No MCP server named "${name}"`;
1055
+ if (!info.toolSpecs.length)
1056
+ return `${name} has no tools${info.status === "connected" ? "" : ` (${info.status})`}.`;
1057
+ return [`${name} — ${info.toolSpecs.length} tool${info.toolSpecs.length === 1 ? "" : "s"}:`, ...info.toolSpecs.map((t) => ` ${chalk.bold(t.name)}${t.description ? chalk.dim(` ${t.description.split("\n")[0].slice(0, 100)}`) : ""}`)].join("\n");
1058
+ }
747
1059
  // Disconnect and reconnect a single server (re-registering its tools). A stale
748
1060
  // stored token is refreshed inside connectOne, so this is just: connect again.
749
1061
  export async function reconnectMcpServer(name) {
@@ -779,6 +1091,10 @@ export async function enableMcpServer(name) {
779
1091
  // Run one action from mcpActionsFor and return the note to display.
780
1092
  export async function runMcpAction(name, action, onUrl) {
781
1093
  switch (action) {
1094
+ case "view-tools":
1095
+ return describeMcpTools(name);
1096
+ case "clear-auth":
1097
+ return clearMcpAuthentication(name);
782
1098
  case "authenticate":
783
1099
  return authenticateMcpServer(name, onUrl);
784
1100
  case "reconnect":