pi-mcp-client 0.1.0 → 0.3.0

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.
Files changed (3) hide show
  1. package/README.md +117 -23
  2. package/dist/index.js +736 -214
  3. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -30,7 +30,7 @@ function diagnostic(code, context) {
30
30
  cancelled: "Start the operation again when ready.",
31
31
  connection_failed: `Check the URL or executable, working directory, network, and TLS setup; then run /mcp reconnect ${target}.`,
32
32
  protocol_error: "Check the server's MCP compatibility and protocol setting. Only stdio and Streamable HTTP are supported.",
33
- tool_changed: "Check server filters and run mcp_search again to load the current tool definition. Reload Pi if the connection configuration changed.",
33
+ tool_changed: "Check server filters and use mcp_tools with activate and the exact identifier to activate the current tool definition. Reload Pi if the connection configuration changed.",
34
34
  tool_error: "Review the server's tool result and inputs. Verify the outcome before retrying.",
35
35
  oauth_failed: `Check OAuth support and browser access to the local callback, then run /mcp auth ${target}.`,
36
36
  callback_unavailable: "Free local port 19847, then retry authentication.",
@@ -285,12 +285,14 @@ __export(config_exports, {
285
285
  matches: () => matches,
286
286
  object: () => object,
287
287
  parseConfig: () => parseConfig,
288
- resolveServer: () => resolveServer
288
+ resolveServer: () => resolveServer,
289
+ setServerDisabled: () => setServerDisabled
289
290
  });
290
- import { readFile } from "node:fs/promises";
291
+ import { chmod, readFile, realpath, stat, writeFile, rename, rm } from "node:fs/promises";
292
+ import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
291
293
  import { resolve, join } from "node:path";
292
294
  import { homedir } from "node:os";
293
- import { createHash } from "node:crypto";
295
+ import { createHash, randomUUID } from "node:crypto";
294
296
  function object(value) {
295
297
  return value !== null && typeof value === "object" && !Array.isArray(value);
296
298
  }
@@ -386,6 +388,46 @@ async function loadConfig(agentDir, cwd, trusted) {
386
388
  }
387
389
  return config;
388
390
  }
391
+ async function setServerDisabled(agentDir, cwd, trusted, server, disabled, validate) {
392
+ const paths = [join(agentDir, "mcp.json"), ...trusted ? [join(cwd, ".mcp.json")] : []];
393
+ const locks = [...new Set(await Promise.all(
394
+ paths.map((path) => realpath(path).catch(() => resolve(path)))
395
+ ))].sort();
396
+ const locked = async (index) => {
397
+ if (index < locks.length)
398
+ return withFileMutationQueue(locks[index], () => locked(index + 1));
399
+ const documents = await Promise.all(paths.map(readJson));
400
+ const config = /* @__PURE__ */ Object.create(null);
401
+ let source = -1;
402
+ for (const [index2, document2] of documents.entries()) {
403
+ if (document2 === void 0) continue;
404
+ const parsed = parseConfig(document2, paths[index2]);
405
+ Object.assign(config, parsed);
406
+ if (Object.hasOwn(parsed, server)) source = index2;
407
+ }
408
+ if (source < 0) throw new Error("Server is no longer configured. Run /mcp reload.");
409
+ const changed = Boolean(config[server].disabled) !== disabled;
410
+ if (changed) config[server].disabled = disabled;
411
+ validate(config);
412
+ const document = documents[source];
413
+ if (changed) {
414
+ document.mcpServers[server].disabled = disabled;
415
+ const target = await realpath(paths[source]);
416
+ const mode = (await stat(target)).mode & 511;
417
+ const temporary = `${target}.${randomUUID()}.tmp`;
418
+ try {
419
+ await writeFile(temporary, JSON.stringify(document, null, 2) + "\n", { mode: 384, flag: "wx" });
420
+ await chmod(temporary, mode);
421
+ validate(config);
422
+ await rename(temporary, target);
423
+ } finally {
424
+ await rm(temporary, { force: true });
425
+ }
426
+ }
427
+ return { config, scope: source === 0 ? "global" : "project" };
428
+ };
429
+ return locked(0);
430
+ }
389
431
  function interpolate(value, env = process.env) {
390
432
  return value.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_, name) => {
391
433
  const found = env[name];
@@ -547,9 +589,9 @@ var recurse = (node, query, maxDistance, results, matrix, m, n, prefix) => {
547
589
  const offset = m * n;
548
590
  key: for (const key of node.keys()) {
549
591
  if (key === LEAF) {
550
- const distance = matrix[offset - 1];
551
- if (distance <= maxDistance) {
552
- results.set(prefix, [node.get(key), distance]);
592
+ const distance2 = matrix[offset - 1];
593
+ if (distance2 <= maxDistance) {
594
+ results.set(prefix, [node.get(key), distance2]);
553
595
  }
554
596
  } else {
555
597
  let i = m;
@@ -1903,22 +1945,22 @@ var MiniSearch = class _MiniSearch {
1903
1945
  }
1904
1946
  if (prefixMatches) {
1905
1947
  for (const [term, data2] of prefixMatches) {
1906
- const distance = term.length - query.term.length;
1907
- if (!distance) {
1948
+ const distance2 = term.length - query.term.length;
1949
+ if (!distance2) {
1908
1950
  continue;
1909
1951
  }
1910
1952
  fuzzyMatches === null || fuzzyMatches === void 0 ? void 0 : fuzzyMatches.delete(term);
1911
- const weight = prefixWeight * term.length / (term.length + 0.3 * distance);
1953
+ const weight = prefixWeight * term.length / (term.length + 0.3 * distance2);
1912
1954
  this.termResults(query.term, term, weight, query.termBoost, data2, boosts, boostDocument, bm25params, results);
1913
1955
  }
1914
1956
  }
1915
1957
  if (fuzzyMatches) {
1916
1958
  for (const term of fuzzyMatches.keys()) {
1917
- const [data2, distance] = fuzzyMatches.get(term);
1918
- if (!distance) {
1959
+ const [data2, distance2] = fuzzyMatches.get(term);
1960
+ if (!distance2) {
1919
1961
  continue;
1920
1962
  }
1921
- const weight = fuzzyWeight * term.length / (term.length + distance);
1963
+ const weight = fuzzyWeight * term.length / (term.length + distance2);
1922
1964
  this.termResults(query.term, term, weight, query.termBoost, data2, boosts, boostDocument, bm25params, results);
1923
1965
  }
1924
1966
  }
@@ -2310,6 +2352,41 @@ function prepareTool(server, identity, tool) {
2310
2352
  schemaHash: fingerprint(inputSchema)
2311
2353
  };
2312
2354
  }
2355
+ function summarize(tool, includeIdentifier = true) {
2356
+ const schema = tool.inputSchema;
2357
+ const required = Array.isArray(schema.required) ? schema.required.filter((name) => typeof name === "string") : [];
2358
+ const prefix = includeIdentifier ? `${tool.server}.${tool.name} \u2014 ` : "";
2359
+ return `${prefix}${line(tool.description).slice(0, 180)} (required: ${required.map(line).join(", ") || "none"})`;
2360
+ }
2361
+ function distance(a, b) {
2362
+ const row = Array.from({ length: b.length + 1 }, (_, i) => i);
2363
+ for (let i = 0; i < a.length; i++) {
2364
+ let diagonal = row[0];
2365
+ row[0] = i + 1;
2366
+ for (let j = 0; j < b.length; j++) {
2367
+ const previous = row[j + 1];
2368
+ row[j + 1] = Math.min(row[j] + 1, previous + 1, diagonal + Number(a[i] !== b[j]));
2369
+ diagonal = previous;
2370
+ }
2371
+ }
2372
+ return row[b.length];
2373
+ }
2374
+ function resolveTools(tools, identifiers) {
2375
+ return [...new Set(identifiers)].map((identifier) => {
2376
+ const matches2 = tools.filter(
2377
+ (tool2) => identifier === tool2.nativeName || identifier === `${tool2.server}.${tool2.name}`
2378
+ );
2379
+ const tool = matches2.length === 1 ? matches2[0] : void 0;
2380
+ const suggestions = tool ? [] : tools.map((candidate) => ({
2381
+ name: `${candidate.server}.${candidate.name}`,
2382
+ score: Math.min(
2383
+ distance(identifier, `${candidate.server}.${candidate.name}`),
2384
+ distance(identifier, candidate.nativeName)
2385
+ )
2386
+ })).sort((a, b) => a.score - b.score || a.name.localeCompare(b.name)).slice(0, 3).map(({ name }) => name);
2387
+ return { identifier, tool, suggestions };
2388
+ });
2389
+ }
2313
2390
  var STOP = /* @__PURE__ */ new Set([
2314
2391
  "a",
2315
2392
  "an",
@@ -2354,10 +2431,120 @@ function searchTools(tools, query, server, limit = DEFAULT_SEARCH_LIMIT) {
2354
2431
  return index.search(query).sort((a, b) => b.score - a.score || String(a.id).localeCompare(String(b.id))).slice(0, Math.max(1, Math.min(limit, MAX_SEARCH_LIMIT))).map((result) => byName.get(result.id));
2355
2432
  }
2356
2433
 
2434
+ // src/management.ts
2435
+ init_config();
2436
+ import { truncateToWidth } from "@earendil-works/pi-tui";
2437
+ var serverStates = {
2438
+ disconnected: { glyph: "\u25CB", label: "idle" },
2439
+ connected: { glyph: "\u25CF", label: "connected" },
2440
+ connecting: { glyph: "\u25B6\uFE0E", label: "connecting" },
2441
+ failed: { glyph: "\u2718\uFE0E", label: "error" },
2442
+ disabled: { glyph: "\u25CB", label: "disabled" }
2443
+ };
2444
+ function serverMatrix(servers, loaded, width = 80) {
2445
+ if (width <= 0) return "";
2446
+ const fit = (text) => plain(truncateToWidth(text, width));
2447
+ if (!servers.length) return fit("No MCP servers configured.");
2448
+ const nameWidth = Math.min(40, Math.max(6, ...servers.map(({ name }) => name.length)));
2449
+ const heading = ` ${"Server".padEnd(nameWidth)} ${"State".padEnd(10)} ${"Tools".padStart(5)} ${"Loaded".padStart(6)}`;
2450
+ const rows = servers.map((server) => {
2451
+ const state = serverStates[server.state];
2452
+ const name = plain(truncateToWidth(line(server.name), nameWidth)).padEnd(nameWidth);
2453
+ return `${state.glyph} ${name} ${state.label.padEnd(10)} ${String(server.catalogSize ?? "\u2014").padStart(5)} ${String(loaded.get(server.name) ?? 0).padStart(6)}`;
2454
+ });
2455
+ const errors = servers.filter((server) => server.state === "failed" && server.error).map((server) => `\u2718\uFE0E ${line(server.name)}: [${server.error.code}] ${line(server.error.message)}`);
2456
+ return [
2457
+ heading,
2458
+ ...rows,
2459
+ "",
2460
+ "Connections open on demand. \u2014 = catalog not fetched.",
2461
+ ...errors
2462
+ ].map(fit).join("\n");
2463
+ }
2464
+ function toolPickerLabel(tool, index, columns = 80) {
2465
+ const width = Math.max(0, columns - 4);
2466
+ const text = `${index + 1}. ${line(tool.name)}: ${line(tool.description) || "No description."}`;
2467
+ return plain(truncateToWidth(text, width, "\u2026"));
2468
+ }
2469
+ function schemaType(schema, depth = 0) {
2470
+ if (!object(schema) || depth > 2) return "unknown";
2471
+ const alternatives = schema.anyOf ?? schema.oneOf;
2472
+ if (Array.isArray(alternatives)) {
2473
+ if (alternatives.length > 4) return "union";
2474
+ return [...new Set(alternatives.map((part) => schemaType(part, depth + 1)))].join(" | ") || "unknown";
2475
+ }
2476
+ if (schema.$ref || schema.allOf) return "unknown";
2477
+ const type = schema.type;
2478
+ if (Array.isArray(type))
2479
+ return type.slice(0, 4).map((part) => schemaType({ ...schema, type: part }, depth + 1)).join(" | ");
2480
+ if (type === "array") return `Array<${schemaType(schema.items, depth + 1)}>`;
2481
+ if (type === "integer") return "integer";
2482
+ if (["string", "number", "boolean", "object", "null"].includes(String(type))) return String(type);
2483
+ return "unknown";
2484
+ }
2485
+ function toolParameters(tool) {
2486
+ const schema = tool.inputSchema;
2487
+ if (!object(schema)) return [];
2488
+ const required = new Set(Array.isArray(schema.required) ? schema.required : []);
2489
+ return Object.entries(object(schema.properties) ? schema.properties : {}).map(([name, value]) => ({
2490
+ name: line(name).slice(0, 80),
2491
+ required: required.has(name),
2492
+ type: schemaType(value),
2493
+ description: object(value) && typeof value.description === "string" ? line(value.description) : ""
2494
+ }));
2495
+ }
2496
+ function toolSignature(tool, limit = 40) {
2497
+ const parameters = toolParameters(tool);
2498
+ const args = parameters.slice(0, limit).map(
2499
+ (parameter) => `${parameter.name}${parameter.required ? "" : "?"}: ${parameter.type}`
2500
+ );
2501
+ if (parameters.length > limit) args.push(`\u2026 +${parameters.length - limit} more`);
2502
+ const schema = tool.inputSchema;
2503
+ if (object(schema) && schema.additionalProperties !== false) args.push("\u2026");
2504
+ const name = line(tool.name).slice(0, 160);
2505
+ return args.length ? `${name}(
2506
+ ${args.map((arg) => ` ${arg},`).join("\n")}
2507
+ )` : `${name}()`;
2508
+ }
2509
+ function inspectTool(tool) {
2510
+ const parameters = toolParameters(tool);
2511
+ return [
2512
+ toolSignature(tool),
2513
+ line(tool.description) || "No description.",
2514
+ ...parameters.length ? ["Parameters:"] : [],
2515
+ ...parameters.slice(0, 40).map(
2516
+ (parameter) => `${parameter.name}: ${parameter.type} (${parameter.required ? "required" : "optional"})${parameter.description ? `
2517
+ ${parameter.description}` : ""}`
2518
+ ),
2519
+ ...parameters.length > 40 ? [`\u2026 ${parameters.length - 40} more parameters`] : [],
2520
+ "Types are summaries; the full schema may impose additional constraints."
2521
+ ].join("\n\n");
2522
+ }
2523
+ function inspectServer(name, config, status) {
2524
+ return [
2525
+ status,
2526
+ `Server: ${name}`,
2527
+ `Transport: ${config.command ? "stdio" : "HTTP"}`,
2528
+ `Protocol: ${config.protocol ?? "auto"}`,
2529
+ `OAuth: ${config.oauth ? "enabled" : "disabled"}`,
2530
+ `Timeout: ${config.timeoutMs ? `${config.timeoutMs} ms` : "default"}`,
2531
+ ...config.command ? [
2532
+ "Command and working directory: hidden",
2533
+ `Arguments: ${config.args?.length ?? 0} (values hidden)`,
2534
+ `Environment overrides: ${Object.keys(config.env ?? {}).length} (names and values hidden)`
2535
+ ] : [
2536
+ "URL: hidden",
2537
+ `Headers: ${Object.keys(config.headers ?? {}).length} (names and values hidden)`
2538
+ ],
2539
+ `Include tools: ${config.includeTools ? config.includeTools.map(line).join(", ") : "all"}`,
2540
+ `Exclude tools: ${config.excludeTools?.map(line).join(", ") || "none"}`
2541
+ ].join("\n");
2542
+ }
2543
+
2357
2544
  // src/auth.ts
2358
2545
  init_config();
2359
2546
  init_diagnostics();
2360
- import { randomUUID } from "node:crypto";
2547
+ import { randomUUID as randomUUID2 } from "node:crypto";
2361
2548
  import { createServer } from "node:http";
2362
2549
  import {
2363
2550
  auth
@@ -2407,7 +2594,7 @@ var OAuthProvider = class {
2407
2594
  data;
2408
2595
  verifier;
2409
2596
  discovery;
2410
- expectedState = randomUUID();
2597
+ expectedState = randomUUID2();
2411
2598
  save() {
2412
2599
  this.store.write(JSON.stringify(this.data));
2413
2600
  }
@@ -2531,9 +2718,9 @@ async function authenticate(url, open, signal, store) {
2531
2718
 
2532
2719
  // src/runtime.ts
2533
2720
  init_config();
2534
- import { mkdir, readFile as readFile2, rename, stat, writeFile } from "node:fs/promises";
2721
+ import { mkdir, readFile as readFile2, rename as rename2, rm as rm2, stat as stat2, writeFile as writeFile2 } from "node:fs/promises";
2535
2722
  import { join as join2 } from "node:path";
2536
- import { randomUUID as randomUUID2 } from "node:crypto";
2723
+ import { randomUUID as randomUUID3 } from "node:crypto";
2537
2724
  import {
2538
2725
  Client,
2539
2726
  StreamableHTTPClientTransport
@@ -2542,7 +2729,7 @@ import {
2542
2729
  StdioClientTransport,
2543
2730
  getDefaultEnvironment
2544
2731
  } from "@modelcontextprotocol/client/stdio";
2545
- import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
2732
+ import { withFileMutationQueue as withFileMutationQueue2 } from "@earendil-works/pi-coding-agent";
2546
2733
  init_secrets();
2547
2734
  init_diagnostics();
2548
2735
  var ToolContractError = class extends Error {
@@ -2565,11 +2752,18 @@ function waitFor(promise, signal) {
2565
2752
  if (signal.aborted) abort();
2566
2753
  });
2567
2754
  }
2568
- var connectSdk = async (_name, config, signal) => {
2755
+ var connectSdk = async (_name, config, signal, onToolsChanged) => {
2569
2756
  const timeout = config.timeoutMs ?? 15e3;
2570
2757
  const client = new Client(
2571
2758
  { name: "pi-mcp-client", version: "0.1.0" },
2572
2759
  {
2760
+ listChanged: {
2761
+ tools: {
2762
+ autoRefresh: false,
2763
+ debounceMs: 0,
2764
+ onChanged: () => onToolsChanged?.()
2765
+ }
2766
+ },
2573
2767
  versionNegotiation: {
2574
2768
  mode: config.protocol ?? "auto",
2575
2769
  probe: { timeoutMs: timeout }
@@ -2585,21 +2779,39 @@ var connectSdk = async (_name, config, signal) => {
2585
2779
  }) : new StreamableHTTPClientTransport(new URL(config.url), {
2586
2780
  requestInit: { headers: config.headers },
2587
2781
  authProvider: config.oauth ? new OAuthProvider(config.url, await credentialStore(config.url)) : void 0,
2588
- // Bound each HTTP request, including OAuth discovery and token refresh.
2589
- fetch: (input, init) => fetch(input, {
2590
- ...init,
2591
- signal: AbortSignal.any([
2592
- signal,
2593
- AbortSignal.timeout(timeout),
2594
- ...init?.signal ? [init.signal] : []
2595
- ])
2596
- })
2597
- });
2598
- if (transport instanceof StdioClientTransport) transport.stderr?.on("data", () => {
2782
+ // Bound HTTP responses (including OAuth), but not established SSE streams.
2783
+ // The SDK bounds ordinary MCP requests with their request timeout.
2784
+ fetch: async (input, init) => {
2785
+ const deadline = new AbortController();
2786
+ const timer = setTimeout(
2787
+ () => deadline.abort(new Error("HTTP response timed out.")),
2788
+ timeout
2789
+ );
2790
+ timer.unref();
2791
+ try {
2792
+ const response = await fetch(input, {
2793
+ ...init,
2794
+ signal: AbortSignal.any([
2795
+ signal,
2796
+ deadline.signal,
2797
+ ...init?.signal ? [init.signal] : []
2798
+ ])
2799
+ });
2800
+ if (response.headers.get("content-type")?.split(";")[0].trim() === "text/event-stream")
2801
+ clearTimeout(timer);
2802
+ return response;
2803
+ } catch (error) {
2804
+ clearTimeout(timer);
2805
+ throw error;
2806
+ }
2807
+ }
2599
2808
  });
2809
+ if (transport instanceof StdioClientTransport)
2810
+ transport.stderr?.on("data", () => {
2811
+ });
2600
2812
  try {
2601
2813
  await waitFor(
2602
- client.connect(transport),
2814
+ client.connect(transport, { signal, timeout }),
2603
2815
  AbortSignal.any([signal, AbortSignal.timeout(timeout)])
2604
2816
  );
2605
2817
  signal.throwIfAborted();
@@ -2621,6 +2833,7 @@ var McpRuntime = class {
2621
2833
  }
2622
2834
  states = /* @__PURE__ */ new Map();
2623
2835
  lifetime = new AbortController();
2836
+ closing;
2624
2837
  identity(name) {
2625
2838
  const config = this.definition(name);
2626
2839
  let resolved;
@@ -2643,18 +2856,21 @@ var McpRuntime = class {
2643
2856
  definition(name) {
2644
2857
  const config = Object.hasOwn(this.config, name) ? this.config[name] : void 0;
2645
2858
  if (!config || config.disabled)
2646
- throw new ToolContractError("MCP server is not configured or is disabled.");
2859
+ throw new ToolContractError(
2860
+ "MCP server is not configured or is disabled."
2861
+ );
2647
2862
  return config;
2648
2863
  }
2649
2864
  state(name) {
2650
2865
  let state = this.states.get(name);
2651
2866
  if (!state) {
2652
- state = {};
2867
+ state = { catalogGeneration: 0 };
2653
2868
  this.states.set(name, state);
2654
2869
  }
2655
2870
  return state;
2656
2871
  }
2657
2872
  async client(name) {
2873
+ if (this.closing) throw new Error("MCP session ended.");
2658
2874
  this.lifetime.signal.throwIfAborted();
2659
2875
  const state = this.state(name);
2660
2876
  const identity = this.identity(name);
@@ -2666,6 +2882,8 @@ var McpRuntime = class {
2666
2882
  if (state.connecting) return state.connecting;
2667
2883
  const config = resolveServer(this.definition(name), this.cwd);
2668
2884
  state.connectionIdentity = identity;
2885
+ const token = {};
2886
+ state.connectionToken = token;
2669
2887
  state.connecting = (async () => {
2670
2888
  const { client, transport } = await this.connect(
2671
2889
  name,
@@ -2675,9 +2893,28 @@ var McpRuntime = class {
2675
2893
  this.cwd,
2676
2894
  this.lifetime.signal
2677
2895
  ),
2678
- this.lifetime.signal
2896
+ this.lifetime.signal,
2897
+ () => {
2898
+ if (this.closing || this.lifetime.signal.aborted || state.connectionToken !== token)
2899
+ return;
2900
+ state.catalogGeneration++;
2901
+ state.catalogDirty = true;
2902
+ state.tools = void 0;
2903
+ state.warnings = void 0;
2904
+ const path = join2(this.cacheDir, `${identity}.json`);
2905
+ state.invalidating = withFileMutationQueue2(
2906
+ path,
2907
+ () => rm2(path, { force: true })
2908
+ ).catch(() => {
2909
+ state.warnings = [
2910
+ `${name}: stale catalog cache could not be removed.`
2911
+ ];
2912
+ });
2913
+ }
2679
2914
  );
2680
- if (this.lifetime.signal.aborted) {
2915
+ if (this.closing || this.lifetime.signal.aborted) {
2916
+ await client.autoOpenedSubscription?.close().catch(() => {
2917
+ });
2681
2918
  await client.close();
2682
2919
  throw new Error("MCP session ended.");
2683
2920
  }
@@ -2688,10 +2925,12 @@ var McpRuntime = class {
2688
2925
  if (state.client === client) {
2689
2926
  state.client = void 0;
2690
2927
  state.transport = void 0;
2928
+ state.connectionToken = void 0;
2691
2929
  }
2692
2930
  };
2693
2931
  return client;
2694
2932
  })().catch((error) => {
2933
+ if (state.connectionToken === token) state.connectionToken = void 0;
2695
2934
  throw new DiagnosticError(
2696
2935
  diagnose(error, {
2697
2936
  server: name,
@@ -2707,10 +2946,13 @@ var McpRuntime = class {
2707
2946
  }
2708
2947
  async catalog(name, signal, refresh = false) {
2709
2948
  signal?.throwIfAborted();
2949
+ if (this.closing) throw new Error("MCP session ended.");
2710
2950
  this.lifetime.signal.throwIfAborted();
2711
2951
  const state = this.state(name);
2712
2952
  const identity = this.identity(name);
2713
- if (!refresh && state.tools && state.identity === identity) return state.tools;
2953
+ refresh ||= !!state.catalogDirty;
2954
+ if (!refresh && state.tools && state.identity === identity)
2955
+ return state.tools;
2714
2956
  if (refresh && state.listing && !state.listingLive) {
2715
2957
  await waitFor(state.listing, signal);
2716
2958
  return this.catalog(name, signal, true);
@@ -2718,44 +2960,59 @@ var McpRuntime = class {
2718
2960
  if (!state.listing) {
2719
2961
  state.listingLive = refresh;
2720
2962
  state.listing = (async () => {
2721
- if (!refresh) {
2722
- const cached = await this.readCache(name, identity);
2723
- if (cached) {
2724
- state.tools = cached;
2725
- state.identity = identity;
2726
- return cached;
2963
+ for (let attempt = 0; attempt < 3; attempt++) {
2964
+ const generation = state.catalogGeneration;
2965
+ if (!refresh && !state.catalogDirty) {
2966
+ const cached = await this.readCache(name, identity);
2967
+ if (cached && generation === state.catalogGeneration) {
2968
+ state.tools = cached;
2969
+ state.identity = identity;
2970
+ return cached;
2971
+ }
2727
2972
  }
2728
- }
2729
- const client = await this.client(name);
2730
- const listed = await client.listTools(void 0, {
2731
- signal: this.lifetime.signal,
2732
- timeout: this.definition(name).timeoutMs ?? 15e3
2733
- });
2734
- const tools = [];
2735
- const warnings = [];
2736
- const seen = /* @__PURE__ */ new Set();
2737
- for (const tool of listed.tools) {
2738
- if (!allowed(tool.name, this.definition(name))) continue;
2739
- try {
2740
- const prepared = prepareTool(name, identity, tool);
2741
- if (seen.has(prepared.nativeName)) throw new Error("Duplicate tool name.");
2742
- seen.add(prepared.nativeName);
2743
- tools.push(prepared);
2744
- } catch {
2745
- warnings.push(
2746
- `${name}: skipped an invalid, duplicate, or unsupported tool schema.`
2747
- );
2973
+ const client = await this.client(name);
2974
+ const listed = await client.listTools(void 0, {
2975
+ signal: this.lifetime.signal,
2976
+ timeout: this.definition(name).timeoutMs ?? 15e3
2977
+ });
2978
+ const tools = [];
2979
+ const warnings = [];
2980
+ const seen = /* @__PURE__ */ new Set();
2981
+ for (const tool of listed.tools) {
2982
+ if (!allowed(tool.name, this.definition(name))) continue;
2983
+ try {
2984
+ const prepared = prepareTool(name, identity, tool);
2985
+ if (seen.has(prepared.nativeName))
2986
+ throw new Error("Duplicate tool name.");
2987
+ seen.add(prepared.nativeName);
2988
+ tools.push(prepared);
2989
+ } catch {
2990
+ warnings.push(
2991
+ `${name}: skipped an invalid, duplicate, or unsupported tool schema.`
2992
+ );
2993
+ }
2748
2994
  }
2995
+ this.lifetime.signal.throwIfAborted();
2996
+ if (generation !== state.catalogGeneration) continue;
2997
+ await state.invalidating;
2998
+ await this.writeCache(
2999
+ identity,
3000
+ tools,
3001
+ () => generation === state.catalogGeneration
3002
+ ).catch(() => {
3003
+ warnings.push(`${name}: catalog cache could not be saved.`);
3004
+ });
3005
+ if (generation !== state.catalogGeneration) continue;
3006
+ state.tools = tools;
3007
+ state.identity = identity;
3008
+ state.catalogDirty = false;
3009
+ state.error = void 0;
3010
+ state.warnings = warnings;
3011
+ return tools;
2749
3012
  }
2750
- this.lifetime.signal.throwIfAborted();
2751
- state.tools = tools;
2752
- state.identity = identity;
2753
- state.error = void 0;
2754
- state.warnings = warnings;
2755
- await this.writeCache(identity, tools).catch(() => {
2756
- warnings.push(`${name}: catalog cache could not be saved.`);
2757
- });
2758
- return tools;
3013
+ throw new ToolContractError(
3014
+ "MCP tool catalog kept changing. Retry discovery or activation."
3015
+ );
2759
3016
  })().catch((error) => {
2760
3017
  state.error = this.failure(name, error);
2761
3018
  throw new DiagnosticError(state.error);
@@ -2767,8 +3024,8 @@ var McpRuntime = class {
2767
3024
  return waitFor(state.listing, signal);
2768
3025
  }
2769
3026
  async discover(server, signal) {
2770
- if (server) this.definition(server);
2771
- const names = server ? [server] : Object.keys(this.config).filter((name) => !this.config[name].disabled).sort();
3027
+ if (typeof server === "string") this.definition(server);
3028
+ const names = Array.isArray(server) ? [...new Set(server)] : server ? [server] : Object.keys(this.config).filter((name) => !this.config[name].disabled).sort();
2772
3029
  const result = {
2773
3030
  tools: [],
2774
3031
  unavailable: [],
@@ -2805,14 +3062,17 @@ var McpRuntime = class {
2805
3062
  const found = current.find((candidate) => candidate.name === tool.name);
2806
3063
  if (!found || found.schemaHash !== tool.schemaHash)
2807
3064
  throw new ToolContractError(
2808
- "MCP tool was removed or its schema changed. Run mcp_search to load its current definition."
3065
+ "MCP tool was removed or its schema changed. Use mcp_tools with activate and its exact identifier to activate its current definition."
2809
3066
  );
2810
3067
  const client = await waitFor(this.client(tool.server), signal);
2811
3068
  try {
2812
3069
  return await client.callTool(
2813
3070
  { name: tool.name, arguments: args },
2814
3071
  {
2815
- signal: AbortSignal.any([this.lifetime.signal, ...signal ? [signal] : []]),
3072
+ signal: AbortSignal.any([
3073
+ this.lifetime.signal,
3074
+ ...signal ? [signal] : []
3075
+ ]),
2816
3076
  timeout: config.timeoutMs ?? 3e4,
2817
3077
  onprogress: (event) => progress?.(
2818
3078
  event.message ?? `${event.progress}${event.total === void 0 ? "" : `/${event.total}`}`
@@ -2835,18 +3095,38 @@ var McpRuntime = class {
2835
3095
  const state = this.state(name);
2836
3096
  if (state.connecting || state.listing)
2837
3097
  throw failure("busy", { server: name, operation: "reconnect" });
3098
+ await state.client?.autoOpenedSubscription?.close();
2838
3099
  await state.client?.close();
2839
3100
  state.client = void 0;
2840
3101
  await this.catalog(name, void 0, true);
2841
3102
  }
2842
- status() {
3103
+ serverStatuses() {
2843
3104
  return Object.entries(this.config).map(([name, config]) => {
2844
3105
  const state = this.states.get(name);
2845
- const status = config.disabled ? "disabled" : state?.connecting || state?.listing ? "connecting" : state?.error ? formatDiagnostic({ ...state.error, server: void 0 }) : state?.client ? "connected" : "disconnected";
2846
- return `${name}: ${status} \xB7 ${state?.tools?.length ?? "unknown"} catalog tools`;
3106
+ return {
3107
+ name,
3108
+ state: config.disabled ? "disabled" : state?.connecting || state?.listing ? "connecting" : state?.error ? "failed" : state?.client ? "connected" : "disconnected",
3109
+ catalogSize: state?.tools?.length,
3110
+ error: state?.error
3111
+ };
3112
+ });
3113
+ }
3114
+ status(server) {
3115
+ return this.serverStatuses().filter(({ name }) => server === void 0 || name === server).map((row) => {
3116
+ const status = row.state === "failed" && row.error ? formatDiagnostic({ ...row.error, server: void 0 }) : row.state;
3117
+ return `${row.name}: ${status} \xB7 ${row.catalogSize ?? "unknown"} catalog tools`;
2847
3118
  }).join("\n") || "No MCP servers configured.";
2848
3119
  }
2849
- async close() {
3120
+ close() {
3121
+ return this.closing ??= this.shutdown();
3122
+ }
3123
+ async shutdown() {
3124
+ await Promise.all(
3125
+ [...this.states.values()].map(
3126
+ (state) => state.client?.autoOpenedSubscription?.close().catch(() => {
3127
+ })
3128
+ )
3129
+ );
2850
3130
  this.lifetime.abort(new Error("MCP session ended."));
2851
3131
  await Promise.all(
2852
3132
  [...this.states.values()].map(async (state) => {
@@ -2856,6 +3136,7 @@ var McpRuntime = class {
2856
3136
  });
2857
3137
  await state.listing?.catch(() => {
2858
3138
  });
3139
+ await state.invalidating;
2859
3140
  })
2860
3141
  );
2861
3142
  }
@@ -2870,8 +3151,9 @@ var McpRuntime = class {
2870
3151
  async readCache(name, identity) {
2871
3152
  try {
2872
3153
  const path = join2(this.cacheDir, `${identity}.json`);
2873
- const info = await stat(path);
2874
- if (info.size > 4 * 1024 * 1024 || Date.now() - info.mtimeMs > 864e5) return;
3154
+ const info = await stat2(path);
3155
+ if (info.size > 4 * 1024 * 1024 || Date.now() - info.mtimeMs > 864e5)
3156
+ return;
2875
3157
  const data = JSON.parse(await readFile2(path, "utf8"));
2876
3158
  if (!Array.isArray(data) || data.length > 1e4) return;
2877
3159
  return data.filter((tool) => allowed(tool.name, this.definition(name))).map((tool) => prepareTool(name, identity, tool));
@@ -2879,7 +3161,7 @@ var McpRuntime = class {
2879
3161
  return;
2880
3162
  }
2881
3163
  }
2882
- async writeCache(identity, tools) {
3164
+ async writeCache(identity, tools, isCurrent) {
2883
3165
  const text = JSON.stringify(
2884
3166
  tools.map(({ name, description, inputSchema }) => ({
2885
3167
  name,
@@ -2890,26 +3172,28 @@ var McpRuntime = class {
2890
3172
  if (Buffer.byteLength(text) > 4 * 1024 * 1024) return;
2891
3173
  await mkdir(this.cacheDir, { recursive: true, mode: 448 });
2892
3174
  const path = join2(this.cacheDir, `${identity}.json`);
2893
- await withFileMutationQueue(path, async () => {
2894
- const temp = `${path}.${randomUUID2()}.tmp`;
2895
- await writeFile(temp, text, { mode: 384 });
2896
- await rename(temp, path);
3175
+ await withFileMutationQueue2(path, async () => {
3176
+ if (!isCurrent()) return;
3177
+ const temp = `${path}.${randomUUID3()}.tmp`;
3178
+ await writeFile2(temp, text, { mode: 384 });
3179
+ if (isCurrent()) await rename2(temp, path);
3180
+ else await rm2(temp, { force: true });
2897
3181
  });
2898
3182
  }
2899
3183
  };
2900
3184
 
2901
3185
  // src/exposure.ts
2902
3186
  init_config();
2903
- var SEARCH_TOOL = "mcp_search";
3187
+ var TOOLS_TOOL = "mcp_tools";
2904
3188
  function restoredTools(entries) {
2905
3189
  const tools = /* @__PURE__ */ new Map();
2906
3190
  for (const entry of entries) {
2907
- if (entry.type !== "message" || entry.message.role !== "toolResult" || entry.message.toolName !== SEARCH_TOOL || entry.message.isError)
3191
+ if (entry.type !== "message" || entry.message.role !== "toolResult" || entry.message.toolName !== TOOLS_TOOL || entry.message.isError)
2908
3192
  continue;
2909
3193
  const details = entry.message.details;
2910
3194
  if (!object(details) || details.mcpClient !== 1 || !Array.isArray(details.loaded))
2911
3195
  continue;
2912
- for (const raw of details.loaded.slice(0, 10)) {
3196
+ for (const raw of details.loaded.slice(0, MAX_SEARCH_LIMIT)) {
2913
3197
  if (!object(raw) || typeof raw.server !== "string" || typeof raw.identity !== "string" || typeof raw.name !== "string" || typeof raw.description !== "string" || !object(raw.inputSchema))
2914
3198
  continue;
2915
3199
  try {
@@ -2972,40 +3256,59 @@ var Exposure = class {
2972
3256
 
2973
3257
  // src/output.ts
2974
3258
  init_diagnostics();
2975
- import { mkdtemp, writeFile as writeFile2 } from "node:fs/promises";
3259
+ import { mkdtemp, writeFile as writeFile3 } from "node:fs/promises";
2976
3260
  import { tmpdir } from "node:os";
2977
3261
  import { join as join3 } from "node:path";
2978
3262
  import {
2979
3263
  truncateHead,
2980
- withFileMutationQueue as withFileMutationQueue2
3264
+ withFileMutationQueue as withFileMutationQueue3
2981
3265
  } from "@earendil-works/pi-coding-agent";
2982
3266
  function textResult(text, details) {
2983
3267
  return { content: [{ type: "text", text }], details };
2984
3268
  }
2985
3269
  async function convertResult(result, label) {
2986
3270
  const texts = [];
3271
+ const blocks = [];
3272
+ let offset = 0;
3273
+ const append = (text2, metadata = {}) => {
3274
+ const start = offset + (texts.length ? 2 : 0);
3275
+ offset = start + text2.length;
3276
+ texts.push(text2);
3277
+ blocks.push({ ...metadata, start, end: offset });
3278
+ };
2987
3279
  const images = [];
2988
3280
  let imageBytes = 0;
2989
3281
  let needsSpill = false;
2990
3282
  for (const part of result.content ?? []) {
2991
- if (part.type === "text") texts.push(part.text);
3283
+ if (part.type === "text") append(part.text);
2992
3284
  else if (part.type === "image" && ["image/png", "image/jpeg", "image/gif", "image/webp"].includes(part.mimeType) && imageBytes + part.data.length <= 8 * 1024 * 1024) {
2993
3285
  images.push({ type: "image", data: part.data, mimeType: part.mimeType });
2994
3286
  imageBytes += part.data.length;
2995
3287
  } else if (part.type === "resource" && "text" in part.resource)
2996
- texts.push(part.resource.text);
2997
- else if (part.type === "resource_link") texts.push(`${part.name}: ${part.uri}`);
3288
+ append(part.resource.text, { mimeType: part.resource.mimeType });
3289
+ else if (part.type === "resource_link")
3290
+ append(`${part.name}: ${part.uri}`, {
3291
+ mimeType: part.mimeType,
3292
+ resourceLink: true
3293
+ });
2998
3294
  else {
2999
- texts.push(`[${part.type} content saved in full result file]`);
3295
+ append(`[${part.type} content saved in full result file]`, {
3296
+ mimeType: "text/plain"
3297
+ });
3000
3298
  needsSpill = true;
3001
3299
  }
3002
3300
  }
3003
3301
  if (result.structuredContent !== void 0)
3004
- texts.push(JSON.stringify(result.structuredContent, null, 2));
3302
+ append(JSON.stringify(result.structuredContent, null, 2), { structured: true });
3005
3303
  const truncated = truncateHead(texts.join("\n\n"));
3006
3304
  let text = truncated.content;
3007
3305
  const details = {
3008
3306
  mcpClient: 1,
3307
+ displayBlocks: blocks.filter((block) => block.start < text.length).map((block) => ({
3308
+ ...block,
3309
+ end: Math.min(block.end, text.length),
3310
+ ...block.end > text.length ? { truncated: true } : {}
3311
+ })),
3009
3312
  failed: result.isError === true,
3010
3313
  ...result.isError ? {
3011
3314
  diagnostics: [
@@ -3019,9 +3322,9 @@ async function convertResult(result, label) {
3019
3322
  };
3020
3323
  if (truncated.truncated || needsSpill) {
3021
3324
  const path = join3(await mkdtemp(join3(tmpdir(), "pi-mcp-client-")), "result.json");
3022
- await withFileMutationQueue2(
3325
+ await withFileMutationQueue3(
3023
3326
  path,
3024
- () => writeFile2(path, JSON.stringify(result), { mode: 384 })
3327
+ () => writeFile3(path, JSON.stringify(result), { mode: 384 })
3025
3328
  );
3026
3329
  details.fullOutputPath = path;
3027
3330
  text += `
@@ -3036,9 +3339,104 @@ Full MCP result: ${path}`;
3036
3339
 
3037
3340
  // src/render.ts
3038
3341
  import { keyText } from "@earendil-works/pi-coding-agent";
3039
- import { Text, truncateToWidth } from "@earendil-works/pi-tui";
3342
+ import { Text, truncateToWidth as truncateToWidth2 } from "@earendil-works/pi-tui";
3040
3343
  init_config();
3344
+
3345
+ // src/format.ts
3346
+ import {
3347
+ DEFAULT_MAX_BYTES,
3348
+ DEFAULT_MAX_LINES
3349
+ } from "@earendil-works/pi-coding-agent";
3350
+ function formatBlock(text, block, theme) {
3351
+ const fallback = plain(text);
3352
+ if (block.truncated || block.resourceLink || Buffer.byteLength(text) > DEFAULT_MAX_BYTES)
3353
+ return fallback;
3354
+ const mime = block.mimeType?.split(";", 1)[0]?.trim().toLowerCase();
3355
+ const explicitJson = mime === "application/json" || mime === "text/json" || /^application\/[^\s/;]+\+json$/.test(mime ?? "");
3356
+ if (mime && !explicitJson) return fallback;
3357
+ if (!explicitJson && !block.structured && !/^[\s]*[\[{]/.test(text))
3358
+ return fallback;
3359
+ try {
3360
+ JSON.parse(text);
3361
+ } catch {
3362
+ return fallback;
3363
+ }
3364
+ const tokens2 = text.match(
3365
+ /"(?:[^"\\]|\\.)*"|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?|true|false|null|[{}\[\],:]/g
3366
+ ) ?? [];
3367
+ const pieces = [];
3368
+ let depth = 0;
3369
+ let bytes = 0;
3370
+ let lines = 1;
3371
+ const add = (value, color) => {
3372
+ bytes += Buffer.byteLength(value);
3373
+ lines += value.split("\n").length - 1;
3374
+ pieces.push({ text: value, color });
3375
+ };
3376
+ const newline = () => add("\n" + " ".repeat(depth));
3377
+ for (let i = 0; i < tokens2.length; i++) {
3378
+ const token = tokens2[i];
3379
+ if (token === "{" || token === "[") {
3380
+ add(token, "syntaxPunctuation");
3381
+ depth++;
3382
+ if (depth > 100) return fallback;
3383
+ if (tokens2[i + 1] !== "}" && tokens2[i + 1] !== "]") newline();
3384
+ } else if (token === "}" || token === "]") {
3385
+ depth--;
3386
+ if (tokens2[i - 1] !== "{" && tokens2[i - 1] !== "[") newline();
3387
+ add(token, "syntaxPunctuation");
3388
+ } else if (token === ",") {
3389
+ add(token, "syntaxPunctuation");
3390
+ newline();
3391
+ } else if (token === ":") {
3392
+ add(":", "syntaxPunctuation");
3393
+ add(" ");
3394
+ } else {
3395
+ add(
3396
+ token,
3397
+ token.startsWith('"') ? tokens2[i + 1] === ":" ? "syntaxVariable" : "syntaxString" : /^(true|false|null)$/.test(token) ? "syntaxKeyword" : "syntaxNumber"
3398
+ );
3399
+ }
3400
+ if (bytes > DEFAULT_MAX_BYTES || lines > DEFAULT_MAX_LINES) return fallback;
3401
+ }
3402
+ return pieces.map(
3403
+ ({ text: value, color }) => color ? theme.fg(color, plain(value)) : value
3404
+ ).join("");
3405
+ }
3406
+ function formatOutput(text, blocks, theme) {
3407
+ if (!blocks) return formatBlock(text, {}, theme);
3408
+ let cursor = 0;
3409
+ let extraBytes = 0;
3410
+ let extraLines = 0;
3411
+ const output = [];
3412
+ for (const block of blocks) {
3413
+ if (!Number.isInteger(block.start) || !Number.isInteger(block.end) || block.start < cursor || block.end < block.start || block.end > text.length)
3414
+ return plain(text);
3415
+ output.push(plain(text.slice(cursor, block.start)));
3416
+ const source = text.slice(block.start, block.end);
3417
+ const formatted = formatBlock(source, block, theme);
3418
+ const visible = plain(formatted);
3419
+ extraBytes += Math.max(
3420
+ 0,
3421
+ Buffer.byteLength(visible) - Buffer.byteLength(source)
3422
+ );
3423
+ extraLines += Math.max(
3424
+ 0,
3425
+ visible.split("\n").length - source.split("\n").length
3426
+ );
3427
+ if (extraBytes > DEFAULT_MAX_BYTES || extraLines > DEFAULT_MAX_LINES)
3428
+ return plain(text);
3429
+ output.push(formatted);
3430
+ cursor = block.end;
3431
+ }
3432
+ output.push(plain(text.slice(cursor)));
3433
+ return output.join("");
3434
+ }
3435
+
3436
+ // src/render.ts
3041
3437
  var states = {
3438
+ candidate: { glyph: "\u25CB", color: "dim" },
3439
+ active: { glyph: "\u25CF", color: "dim" },
3042
3440
  queued: { glyph: "\u25CF", color: "dim" },
3043
3441
  running: { glyph: "\u25B6\uFE0E", color: "muted" },
3044
3442
  done: { glyph: "\u2714\uFE0E", color: "success" },
@@ -3047,16 +3445,16 @@ var states = {
3047
3445
  };
3048
3446
  function renderCall(title, args, theme, expanded) {
3049
3447
  const values = object(args) ? args : {};
3050
- const preview = Object.entries(values).map(([key, value]) => `${line(key)}=${line(JSON.stringify(value) ?? "")}`).join(" ");
3448
+ const preview = title === "mcp activate" ? "" : Object.entries(values).map(([key, value]) => `${line(key)}=${line(JSON.stringify(value) ?? "")}`).join(" ");
3051
3449
  return {
3052
3450
  render(width) {
3053
3451
  if (width <= 0) return [];
3054
3452
  const text = theme.fg("toolTitle", theme.bold(line(title))) + (preview ? theme.fg("dim", ` ${preview}`) : "");
3055
3453
  if (expanded)
3056
- return new Text(text, 0, 0).render(width).map((row) => truncateToWidth(row, width));
3454
+ return new Text(text, 0, 0).render(width).map((row) => truncateToWidth2(row, width));
3057
3455
  const key = keyText("app.tools.expand");
3058
3456
  return [
3059
- truncateToWidth(
3457
+ truncateToWidth2(
3060
3458
  text + (key ? theme.fg("muted", ` (${key} to expand)`) : ""),
3061
3459
  width
3062
3460
  )
@@ -3066,40 +3464,51 @@ function renderCall(title, args, theme, expanded) {
3066
3464
  }
3067
3465
  };
3068
3466
  }
3467
+ function formatValidation(text, theme) {
3468
+ const marker = /\r?\n\r?\nReceived arguments:\r?\n/.exec(text);
3469
+ if (!marker) return plain(text);
3470
+ const start = marker.index + marker[0].length;
3471
+ return plain(text.slice(0, start)) + formatBlock(text.slice(start), {}, theme);
3472
+ }
3069
3473
  function renderResult(result, options, theme, isError) {
3070
3474
  const details = object(result.details) && result.details.mcpClient === 1 ? result.details : void 0;
3071
- const text = plain(
3072
- result.content.filter((part) => part.type === "text").map((part) => part.text ?? "").join("\n")
3073
- );
3475
+ const text = result.content.filter((part) => part.type === "text").map((part) => part.text ?? "").join("\n");
3476
+ const validation = !details && isError ? /^Validation failed for tool "[^"\r\n]+":\r?\n([\s\S]*)$/.exec(text) : null;
3477
+ const body = validation?.[1] ?? text;
3074
3478
  const rows = details?.rows ?? [
3075
3479
  {
3076
- label: line(text) || "Working\u2026",
3480
+ label: validation ? "Invalid tool arguments" : isError ? "Tool failed" : line(text) || "Working\u2026",
3077
3481
  state: isError ? "failed" : options.isPartial ? "running" : "done"
3078
3482
  }
3079
3483
  ];
3484
+ let formattedOutput;
3080
3485
  return {
3081
3486
  render(width) {
3082
3487
  if (width <= 0) return [];
3083
3488
  const lines = rows.flatMap((row) => {
3084
3489
  const status = states[row.state] ?? states.failed;
3085
- const value = theme.fg(status.color, status.glyph) + " " + theme.fg("accent", line(row.label));
3086
- const rendered = options.expanded && (!details?.searchNotes || row.state === "failed") ? new Text(value, 0, 0).render(width).map((x) => truncateToWidth(x, width)) : [truncateToWidth(value, width)];
3490
+ const value = theme.fg(status.color, status.glyph) + " " + theme.fg("accent", line(row.label)) + (row.inlineDescription ? theme.fg("dim", ` ${line(row.inlineDescription)}`) : "");
3491
+ const rendered = options.expanded && (!details?.searchNotes || row.state === "failed") ? new Text(value, 0, 0).render(width).map((x) => truncateToWidth2(x, width)) : [truncateToWidth2(value, width)];
3087
3492
  if (options.expanded && row.description)
3088
- rendered.push(truncateToWidth(
3493
+ rendered.push(truncateToWidth2(
3089
3494
  theme.fg("dim", ` ${line(row.description)}`),
3090
3495
  width
3091
3496
  ));
3092
3497
  return rendered;
3093
3498
  });
3094
3499
  for (const note of details?.searchNotes ?? [])
3095
- lines.push(...(options.expanded ? new Text(theme.fg("warning", plain(note)), 0, 0).render(width) : [theme.fg("warning", line(note))]).map((row) => truncateToWidth(row, width)));
3500
+ lines.push(...(options.expanded ? new Text(theme.fg("warning", plain(note)), 0, 0).render(width) : [theme.fg("warning", line(note))]).map((row) => truncateToWidth2(row, width)));
3096
3501
  if (options.expanded && !options.isPartial && text && !details?.searchNotes)
3097
3502
  lines.push(
3098
- ...new Text(text, 0, 0).render(width).map((x) => truncateToWidth(x, width))
3503
+ ...new Text(
3504
+ formattedOutput ??= validation ? formatValidation(body, theme) : formatOutput(body, details?.displayBlocks, theme),
3505
+ 0,
3506
+ 0
3507
+ ).render(width).map((x) => truncateToWidth2(x, width))
3099
3508
  );
3100
3509
  if (!options.expanded && details?.fullOutputPath)
3101
3510
  lines.push(
3102
- truncateToWidth(
3511
+ truncateToWidth2(
3103
3512
  theme.fg("dim", `Full result: ${details.fullOutputPath}`),
3104
3513
  width
3105
3514
  )
@@ -3107,6 +3516,7 @@ function renderResult(result, options, theme, isError) {
3107
3516
  return lines;
3108
3517
  },
3109
3518
  invalidate() {
3519
+ formattedOutput = void 0;
3110
3520
  }
3111
3521
  };
3112
3522
  }
@@ -3123,7 +3533,10 @@ function errorResult(error, context) {
3123
3533
  failed: true,
3124
3534
  diagnostics: [value],
3125
3535
  rows: [
3126
- { label: message, state: value.code === "cancelled" ? "cancelled" : "failed" }
3536
+ {
3537
+ label: value.code === "cancelled" ? "Cancelled" : message,
3538
+ state: value.code === "cancelled" ? "cancelled" : "failed"
3539
+ }
3127
3540
  ]
3128
3541
  });
3129
3542
  }
@@ -3132,6 +3545,7 @@ function mcpClient(pi, options = {}) {
3132
3545
  let runtime;
3133
3546
  let config = {};
3134
3547
  let configError;
3548
+ let sessionGeneration = 0;
3135
3549
  const exposure = new Exposure(pi, registerNative);
3136
3550
  const current = () => {
3137
3551
  if (!runtime)
@@ -3192,34 +3606,72 @@ Text output is limited to 2000 lines or 50 KiB; larger results are saved to a pr
3192
3606
  });
3193
3607
  exposure.restore(tools);
3194
3608
  };
3609
+ async function reloadConfiguration(ctx, toggle) {
3610
+ const generation = sessionGeneration;
3611
+ const validate = (nextConfig2) => {
3612
+ ctx.signal?.throwIfAborted();
3613
+ if (generation !== sessionGeneration)
3614
+ throw new CommandUsageError("The Pi session changed during configuration reload.");
3615
+ for (const definition of Object.values(nextConfig2)) {
3616
+ if (!definition.disabled) resolveServer(definition, ctx.cwd);
3617
+ }
3618
+ };
3619
+ ctx.signal?.throwIfAborted();
3620
+ const update = toggle && await setServerDisabled(
3621
+ agentDir,
3622
+ ctx.cwd,
3623
+ ctx.isProjectTrusted(),
3624
+ toggle.server,
3625
+ toggle.disabled,
3626
+ validate
3627
+ );
3628
+ const nextConfig = update ? update.config : await loadConfig(agentDir, ctx.cwd, ctx.isProjectTrusted());
3629
+ validate(nextConfig);
3630
+ const next = new McpRuntime(
3631
+ nextConfig,
3632
+ ctx.cwd,
3633
+ join4(agentDir, "cache", "pi-mcp-client")
3634
+ );
3635
+ const active = new Set(pi.getActiveTools());
3636
+ const retained = [...exposure.definitions.values()].filter((tool) => {
3637
+ try {
3638
+ return active.has(tool.nativeName) && next.identity(tool.server) === tool.identity && allowed(tool.name, nextConfig[tool.server]);
3639
+ } catch {
3640
+ return false;
3641
+ }
3642
+ });
3643
+ const old = runtime;
3644
+ config = nextConfig;
3645
+ configError = void 0;
3646
+ runtime = next;
3647
+ exposure.restore(retained);
3648
+ await old?.close();
3649
+ return update?.scope;
3650
+ }
3195
3651
  pi.on("session_start", async (_event, ctx) => {
3652
+ sessionGeneration++;
3196
3653
  await runtime?.close();
3197
3654
  runtime = void 0;
3198
3655
  config = {};
3199
3656
  configError = void 0;
3200
3657
  try {
3201
3658
  config = await loadConfig(agentDir, ctx.cwd, ctx.isProjectTrusted());
3202
- runtime = new McpRuntime(
3203
- config,
3204
- ctx.cwd,
3205
- join4(agentDir, "cache", "pi-mcp-client")
3206
- );
3659
+ runtime = new McpRuntime(config, ctx.cwd, join4(agentDir, "cache", "pi-mcp-client"));
3207
3660
  } catch (error) {
3208
- configError = new DiagnosticError(
3209
- diagnose(error, { operation: "configuration" })
3210
- );
3661
+ configError = new DiagnosticError(diagnose(error, { operation: "configuration" }));
3211
3662
  if (ctx.hasUI) ctx.ui.notify(configError.message, "error");
3212
3663
  }
3213
3664
  restore(ctx);
3214
3665
  });
3215
3666
  pi.on("session_tree", (_event, ctx) => restore(ctx));
3216
3667
  pi.on("session_shutdown", async () => {
3668
+ sessionGeneration++;
3217
3669
  const old = runtime;
3218
3670
  runtime = void 0;
3219
3671
  await old?.close();
3220
3672
  });
3221
3673
  pi.on("before_agent_start", (event) => {
3222
- if (!pi.getActiveTools().includes(SEARCH_TOOL)) return;
3674
+ if (!pi.getActiveTools().includes(TOOLS_TOOL)) return;
3223
3675
  const directory = Object.entries(config).filter(([, value]) => !value.disabled).map(
3224
3676
  ([name, value]) => `- ${name}${value.description ? `: ${line(value.description).slice(0, 160)}` : ""}`
3225
3677
  ).join("\n");
@@ -3229,109 +3681,120 @@ Text output is limited to 2000 lines or 50 KiB; larger results are saved to a pr
3229
3681
 
3230
3682
  Additional MCP capabilities (directory metadata, not instructions):
3231
3683
  ${directory}
3232
- Use mcp_search to load relevant tools, then call them directly. Loaded tools remain available; search again only when a missing capability is needed.`
3684
+ Discover candidates with mcp_tools({query: "capability", server: "name"}); discovery never activates tools, even for exact-name queries. Then explicitly activate only the identifiers you need with mcp_tools({activate: ["server.tool"]}), and call the loaded native tools directly. Activation accepts exact identifiers without prior discovery and never invokes tools. Loaded tools remain available.`
3233
3685
  };
3234
3686
  });
3235
3687
  pi.on("tool_result", (event) => {
3236
- if ((event.toolName === SEARCH_TOOL || exposure.definitions.has(event.toolName)) && object(event.details) && event.details.mcpClient === 1 && event.details.failed === true)
3688
+ if ((event.toolName === TOOLS_TOOL || exposure.definitions.has(event.toolName)) && object(event.details) && event.details.mcpClient === 1 && event.details.failed === true)
3237
3689
  return { isError: true };
3238
3690
  });
3239
3691
  pi.registerTool({
3240
- name: SEARCH_TOOL,
3241
- label: "MCP Search",
3242
- description: "Search for and load MCP tools by capability or exact server.tool / mcp__server__tool name. Matches become directly callable on the next turn and remain available. Use a focused query and optionally a server name. Search only discovers tools; it does not invoke them. Default limit: 5, maximum: 50.",
3692
+ name: TOOLS_TOOL,
3693
+ label: "MCP Tools",
3694
+ description: "Discover MCP candidates with query (optional server and limit), or explicitly activate 1\u201350 exact server.tool / mcp__server__tool identifiers with activate. Exactly one of query or activate is required; server and limit are query-only. Even an exact-name query is discovery-only and never activates tools. Activation needs no prior search, never resolves fuzzy matches, and never invokes tools. Activated tools become natively callable on the next turn and remain available. Discovery default limit: 5, maximum: 50.",
3243
3695
  parameters: Type.Object(
3244
3696
  {
3245
- query: Type.String({
3697
+ query: Type.Optional(Type.String({
3246
3698
  minLength: 1,
3247
3699
  maxLength: 500,
3248
- description: "One focused capability or exact server.tool name, for example linear.list_teams. Do not enumerate every capability of a server."
3249
- }),
3250
- server: Type.Optional(Type.String({
3251
- minLength: 1,
3252
- maxLength: 80,
3253
- description: "Restrict discovery to this configured MCP server."
3700
+ description: "One focused capability or exact server.tool name, for example linear.list_teams. Discovery only; does not activate tools."
3701
+ })),
3702
+ activate: Type.Optional(Type.Array(Type.String({ minLength: 1, maxLength: 600 }), {
3703
+ minItems: 1,
3704
+ maxItems: MAX_SEARCH_LIMIT,
3705
+ description: "Exact server.tool or mcp__server__tool identifiers to activate, without invoking. Duplicates are ignored. Cannot be combined with query, server, or limit."
3254
3706
  })),
3255
- limit: Type.Optional(Type.Integer({
3256
- minimum: 1,
3257
- maximum: MAX_SEARCH_LIMIT,
3258
- default: DEFAULT_SEARCH_LIMIT,
3259
- description: `Maximum number of tools to load: 1\u2013${MAX_SEARCH_LIMIT} inclusive (default: ${DEFAULT_SEARCH_LIMIT}). This is not a limit on records returned by a native tool. Omit unless more tools are needed.`
3260
- }))
3707
+ server: Type.Optional(
3708
+ Type.String({
3709
+ minLength: 1,
3710
+ maxLength: 80,
3711
+ description: "Restrict discovery to this configured MCP server."
3712
+ })
3713
+ ),
3714
+ limit: Type.Optional(
3715
+ Type.Integer({
3716
+ minimum: 1,
3717
+ maximum: MAX_SEARCH_LIMIT,
3718
+ default: DEFAULT_SEARCH_LIMIT,
3719
+ description: `Maximum number of candidates to return: 1\u2013${MAX_SEARCH_LIMIT} inclusive (default: ${DEFAULT_SEARCH_LIMIT}). This is not a limit on records returned by a native tool. Omit unless more tools are needed.`
3720
+ })
3721
+ )
3261
3722
  },
3262
3723
  { additionalProperties: false }
3263
3724
  ),
3264
- renderCall: (args, theme, context) => renderCall("mcp search", args, theme, context.expanded),
3725
+ renderCall: (args, theme, context) => renderCall(args.activate ? "mcp activate" : "mcp discover", args, theme, context.expanded),
3265
3726
  renderResult: (result, options2, theme, context) => renderResult(result, options2, theme, context.isError),
3266
3727
  async execute(_id, args, signal, onUpdate, ctx) {
3728
+ const usage = 'Use exactly one of {query: "capability", server?: "name", limit?: 1\u201350} or {activate: ["server.tool", ...]} (1\u201350 exact identifiers). server and limit are valid only with query.';
3729
+ const hasQuery = args.query !== void 0;
3730
+ const hasActivate = args.activate !== void 0;
3731
+ if (hasQuery === hasActivate || hasActivate && (args.server !== void 0 || args.limit !== void 0) || hasQuery && (typeof args.query !== "string" || !args.query.trim() || args.query.length > 500) || hasActivate && (!Array.isArray(args.activate) || args.activate.length < 1 || args.activate.length > MAX_SEARCH_LIMIT || args.activate.some((id) => typeof id !== "string" || !id.trim() || id.length > 600)) || args.server !== void 0 && (typeof args.server !== "string" || !args.server.trim() || args.server.length > 80) || args.limit !== void 0 && (!Number.isInteger(args.limit) || args.limit < 1 || args.limit > MAX_SEARCH_LIMIT) || Object.keys(args).some((key) => !["query", "activate", "server", "limit"].includes(key))) return textResult(usage, { mcpClient: 1, failed: true, rows: [{ label: usage, state: "failed" }] });
3267
3732
  try {
3268
3733
  const activeRuntime = current();
3269
- onUpdate?.(
3270
- textResult("Searching MCP catalog\u2026", {
3271
- mcpClient: 1,
3272
- rows: [{ label: args.query, state: "running" }]
3273
- })
3274
- );
3275
- const namedServer = Object.keys(config).sort((a, b) => b.length - a.length).find(
3276
- (name) => args.query.startsWith(`${name}.`) || args.query.startsWith(`mcp__${name}__`)
3734
+ const identifiers = [...new Set(args.activate ?? [])];
3735
+ const serversFor = (identifier) => Object.keys(config).filter(
3736
+ (name) => identifier.startsWith(`${name}.`) || identifier.startsWith(`mcp__${name}__`) || // Long server names can have a truncated, hashed native name.
3737
+ `mcp__${name}__`.length > 50 && identifier.startsWith(`mcp__${name}__`.slice(0, 50))
3277
3738
  );
3739
+ onUpdate?.(textResult(hasActivate ? "Activating MCP tools\u2026" : "Searching MCP catalog\u2026", {
3740
+ mcpClient: 1,
3741
+ rows: [{ label: args.query ?? identifiers.join(", "), state: "running" }]
3742
+ }));
3278
3743
  const discovery = await activeRuntime.discover(
3279
- args.server ?? namedServer,
3744
+ hasActivate ? identifiers.flatMap(serversFor) : args.server ?? serversFor(args.query)[0],
3280
3745
  signal ?? ctx.signal
3281
3746
  );
3282
3747
  (signal ?? ctx.signal)?.throwIfAborted();
3283
3748
  if (runtime !== activeRuntime)
3284
- throw new Error("MCP session changed during search.");
3285
- const matches2 = searchTools(
3286
- discovery.tools,
3287
- args.query,
3288
- args.server,
3289
- args.limit
3290
- );
3291
- const { loaded, added, rejected } = exposure.load(matches2);
3292
- const messages2 = loaded.map(
3293
- (tool) => `${added.includes(tool.nativeName) ? "Loaded" : "Already loaded"}: ${tool.nativeName} \u2014 ${line(tool.description).slice(0, 180)}`
3294
- );
3295
- if (!messages2.length)
3296
- messages2.push(
3297
- "No callable matches found. Try a more specific capability, server, or exact tool name."
3298
- );
3299
- if (loaded.length)
3300
- messages2.push(
3301
- "Call the loaded tools directly. Their full schemas are now available."
3302
- );
3303
- messages2.push(
3304
- ...discovery.unavailable.map((message) => `Not searched: ${message}`),
3305
- ...discovery.warnings
3306
- );
3307
- if (rejected.length)
3308
- messages2.push(
3309
- `Not loaded (name collision or Pi tool restriction): ${rejected.join(", ")}`
3310
- );
3749
+ throw new Error("MCP session changed during search or activation.");
3311
3750
  const details = {
3312
3751
  mcpClient: 1,
3313
- loaded,
3314
3752
  searchNotes: discovery.warnings,
3315
3753
  diagnostics: discovery.diagnostics,
3316
- failed: !loaded.length && discovery.diagnostics.length > 0,
3317
- rows: [
3318
- ...loaded.map((tool) => ({
3319
- label: `${tool.server}.${tool.name} \xB7 ${added.includes(tool.nativeName) ? "loaded" : "already loaded"}`,
3320
- description: tool.description,
3321
- state: "done"
3322
- })),
3323
- ...discovery.unavailable.map((label) => ({
3324
- label,
3325
- state: "failed"
3326
- })),
3327
- ...rejected.map((name) => ({
3328
- label: `${name} \xB7 not loaded`,
3329
- state: "failed"
3330
- }))
3331
- ]
3754
+ rows: []
3332
3755
  };
3333
- if (!details.rows.length)
3334
- details.rows.push({ label: "No matching tools", state: "done" });
3756
+ const messages2 = [];
3757
+ if (!hasActivate) {
3758
+ const candidates = searchTools(discovery.tools, args.query, args.server, args.limit);
3759
+ details.candidates = candidates;
3760
+ details.failed = !candidates.length && discovery.diagnostics.length > 0;
3761
+ const active = new Set(pi.getActiveTools());
3762
+ for (const tool of candidates) {
3763
+ const label = summarize(tool) + (active.has(tool.nativeName) ? " [loaded]" : "");
3764
+ messages2.push(label);
3765
+ details.rows.push({
3766
+ label: `${tool.server}.${tool.name}`,
3767
+ inlineDescription: summarize(tool, false),
3768
+ state: active.has(tool.nativeName) ? "active" : "candidate"
3769
+ });
3770
+ }
3771
+ if (!candidates.length) messages2.push("No matching tools. Try a more specific capability, server, or exact tool name.");
3772
+ details.rows.push(...discovery.unavailable.map((label) => ({ label, state: "failed" })));
3773
+ messages2.push(...discovery.unavailable.map((message) => `Not searched: ${message}`), ...discovery.warnings);
3774
+ messages2.push("No tools activated. Call mcp_tools({activate: [...]}) with the identifiers you need.");
3775
+ } else {
3776
+ const resolved = resolveTools(discovery.tools, identifiers);
3777
+ const matches2 = [...new Map(resolved.flatMap(({ tool }) => tool ? [[tool.nativeName, tool]] : [])).values()];
3778
+ const collisions = new Set(pi.getAllTools().filter((tool) => !exposure.definitions.has(tool.name)).map((tool) => tool.name));
3779
+ const { loaded, added } = exposure.load(matches2);
3780
+ details.loaded = loaded;
3781
+ details.failed = loaded.length === 0;
3782
+ for (const { identifier, tool, suggestions } of resolved) {
3783
+ const ok = tool && loaded.includes(tool);
3784
+ const unavailable = discovery.diagnostics.find((value) => value.server && serversFor(identifier).includes(value.server));
3785
+ const reason = unavailable ? `server unavailable: ${formatDiagnostic(unavailable)}` : tool ? collisions.has(tool.nativeName) ? "name collision" : "restricted by Pi" : `unknown identifier${suggestions.length ? `; nearest catalog names: ${suggestions.join(", ")}` : "; no catalog names available for this server. Check the server identifier or discover candidates with query."}`;
3786
+ const label = `${line(identifier)} \u2014 ${ok ? added.includes(tool.nativeName) ? "loaded" : "already loaded" : `not loaded \u2014 ${reason}`}`;
3787
+ messages2.push(label);
3788
+ details.rows.push({
3789
+ label: line(identifier),
3790
+ ...ok ? {} : { inlineDescription: reason },
3791
+ state: ok ? "done" : "failed"
3792
+ });
3793
+ }
3794
+ if (loaded.length) messages2.push("Call the loaded tools directly. Their full schemas are now available.");
3795
+ messages2.push(...discovery.warnings);
3796
+ }
3797
+ if (!details.rows.length) details.rows.push({ label: "No matching tools", state: "candidate" });
3335
3798
  return textResult(messages2.join("\n"), details);
3336
3799
  } catch (error) {
3337
3800
  return errorResult(error, {
@@ -3344,34 +3807,93 @@ Use mcp_search to load relevant tools, then call them directly. Loaded tools rem
3344
3807
  }
3345
3808
  });
3346
3809
  pi.registerCommand("mcp", {
3347
- description: "Inspect MCP servers; /mcp auth|reconnect|refresh <server>",
3810
+ description: "Manage MCP servers: list, status, reload, enable|disable|inspect|tools|auth|reconnect|refresh <server>",
3348
3811
  getArgumentCompletions(prefix) {
3349
- const values = [
3350
- "status",
3351
- ...["auth", "reconnect", "refresh"].flatMap(
3352
- (action) => Object.keys(config).map((name) => `${action} ${name}`)
3353
- )
3354
- ];
3355
- return values.filter((value) => value.startsWith(prefix)).map((value) => ({ value, label: value }));
3812
+ const serverActions = ["enable", "disable", "inspect", "tools", "auth", "reconnect", "refresh"];
3813
+ const input = prefix.trimStart();
3814
+ const match = /^(\S+)\s+(.*)$/s.exec(input);
3815
+ if (!match) {
3816
+ return ["list", "status", "reload", ...serverActions].filter((action2) => action2.startsWith(input)).map((action2) => ({ value: action2, label: action2 }));
3817
+ }
3818
+ const [, action, partialServer] = match;
3819
+ if (!serverActions.includes(action) || /\s/.test(partialServer)) return [];
3820
+ return Object.keys(config).filter(
3821
+ (name) => name.startsWith(partialServer) && (action === "inspect" || (action === "enable" ? config[name].disabled : !config[name].disabled))
3822
+ ).sort().map((name) => ({ value: `${action} ${name}`, label: name }));
3356
3823
  },
3357
3824
  async handler(args, ctx) {
3825
+ const generation = sessionGeneration;
3358
3826
  await ctx.waitForIdle();
3359
3827
  const [action = "status", server, ...extra] = args.trim().split(/\s+/).filter(Boolean);
3360
3828
  try {
3361
- if (action === "status" && !server) {
3362
- const loaded = pi.getActiveTools().filter((name) => exposure.definitions.has(name)).length;
3829
+ if (generation !== sessionGeneration)
3830
+ throw new CommandUsageError("The Pi session changed while waiting for idle.");
3831
+ if (action === "reload" && !server) {
3832
+ await reloadConfiguration(ctx);
3833
+ if (ctx.hasUI)
3834
+ ctx.ui.notify(
3835
+ "\u2714\uFE0E MCP configuration reloaded. Connections reopen on demand; tools from changed or removed servers are no longer active.",
3836
+ "info"
3837
+ );
3838
+ return;
3839
+ }
3840
+ if ((action === "enable" || action === "disable") && server && !extra.length && Object.hasOwn(config, server)) {
3841
+ const scope = await reloadConfiguration(ctx, { server, disabled: action === "disable" });
3842
+ if (ctx.hasUI)
3843
+ ctx.ui.notify(
3844
+ `\u2714\uFE0E ${server}: ${action === "enable" ? "enabled" : "disabled"} in ${scope} configuration. ` + (action === "enable" ? "Connections and tool discovery remain on demand." : "Its connection is closed and its tools are no longer active."),
3845
+ "info"
3846
+ );
3847
+ return;
3848
+ }
3849
+ if (action === "inspect" && server && !extra.length && Object.hasOwn(config, server)) {
3363
3850
  if (ctx.hasUI)
3364
3851
  ctx.ui.notify(
3365
- `${current().status()}
3366
- ${loaded} native MCP tools loaded.`,
3852
+ inspectServer(server, config[server], current().status(server)),
3367
3853
  "info"
3368
3854
  );
3369
3855
  return;
3370
3856
  }
3857
+ if ((action === "status" || action === "list") && !server) {
3858
+ const statuses = current().serverStatuses();
3859
+ const loaded = /* @__PURE__ */ new Map();
3860
+ for (const name of pi.getActiveTools()) {
3861
+ const tool = exposure.definitions.get(name);
3862
+ if (tool) loaded.set(tool.server, (loaded.get(tool.server) ?? 0) + 1);
3863
+ }
3864
+ if (ctx.hasUI) ctx.ui.notify(serverMatrix(statuses, loaded), "info");
3865
+ return;
3866
+ }
3371
3867
  if (!server || extra.length || !Object.hasOwn(config, server) || config[server].disabled)
3372
3868
  throw new CommandUsageError(
3373
- "Usage: /mcp auth|reconnect|refresh <enabled-server>"
3869
+ "Usage: /mcp list|status|reload or /mcp enable|disable|inspect|tools|auth|reconnect|refresh <server>. Disabled servers accept enable, disable, and inspect."
3374
3870
  );
3871
+ if (action === "tools") {
3872
+ if (!ctx.hasUI)
3873
+ throw new CommandUsageError("Tool browsing requires an interactive UI.");
3874
+ const tools = await current().catalog(server, ctx.signal, true);
3875
+ if (!tools.length) {
3876
+ ctx.ui.notify(
3877
+ `${server}: no tools available under the configured filters.`,
3878
+ "info"
3879
+ );
3880
+ return;
3881
+ }
3882
+ const choices = [...tools].sort((a, b) => a.name.localeCompare(b.name));
3883
+ const columns = ctx.mode === "tui" ? process.stdout.columns || 80 : 80;
3884
+ const labels = choices.map((tool2, index) => toolPickerLabel(tool2, index, columns));
3885
+ const selected = await ctx.ui.select(
3886
+ `${server}: ${tools.length} tools (select to inspect; none are activated)`,
3887
+ labels
3888
+ );
3889
+ const tool = selected === void 0 ? void 0 : choices[labels.indexOf(selected)];
3890
+ if (tool)
3891
+ ctx.ui.notify(
3892
+ inspectTool(tool),
3893
+ "info"
3894
+ );
3895
+ return;
3896
+ }
3375
3897
  if (action === "auth") {
3376
3898
  if (!ctx.hasUI)
3377
3899
  throw new CommandUsageError(
@@ -3416,27 +3938,27 @@ ${target}`, "info");
3416
3938
  } else await authenticate(url, open, ctx.signal);
3417
3939
  await current().reconnect(server);
3418
3940
  } else if (action === "reconnect") await current().reconnect(server);
3419
- else if (action === "refresh")
3420
- await current().catalog(server, ctx.signal, true);
3941
+ else if (action === "refresh") await current().catalog(server, ctx.signal, true);
3421
3942
  else
3422
3943
  throw new CommandUsageError(
3423
- "Unknown MCP command. Use /mcp auth|reconnect|refresh <server>."
3944
+ "Unknown MCP command. Use /mcp list|status|reload or /mcp enable|disable|inspect|tools|auth|reconnect|refresh <server>."
3424
3945
  );
3425
3946
  if (ctx.hasUI)
3426
3947
  ctx.ui.notify(
3427
- `${server}: ${action} complete. Search to load new or changed tools.`,
3948
+ `\u2714\uFE0E ${server}: ${action} complete. Updated tools are available for the assistant to discover.`,
3428
3949
  "info"
3429
3950
  );
3430
3951
  } catch (error) {
3431
3952
  const message = error instanceof CommandUsageError ? error.message : formatDiagnostic(
3432
3953
  diagnose(error, {
3433
3954
  server,
3434
- operation: action === "auth" ? "auth" : action === "refresh" ? "refresh" : "reconnect",
3955
+ operation: ["reload", "inspect", "enable", "disable"].includes(action) ? "configuration" : action === "tools" ? "search" : action === "auth" ? "auth" : action === "refresh" ? "refresh" : "reconnect",
3435
3956
  oauth: config[server]?.oauth,
3436
3957
  signal: ctx.signal
3437
3958
  })
3438
3959
  );
3439
3960
  if (ctx.hasUI) ctx.ui.notify(message, "error");
3961
+ else throw new Error(message);
3440
3962
  }
3441
3963
  }
3442
3964
  });