pi-mcp-client 0.1.0 → 0.2.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 +33 -3
  2. package/dist/index.js +393 -113
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -50,11 +50,20 @@ remain available as the conversation continues.
50
50
 
51
51
  | Command | Purpose |
52
52
  | --- | --- |
53
- | `/mcp` | Show server connection status, catalog sizes, and the loaded tool count. |
53
+ | `/mcp`, `/mcp list`, `/mcp status` | Show a server status matrix with catalog and loaded-tool counts. |
54
+ | `/mcp inspect <server>` | Inspect status and configuration, including disabled servers. Connection values are hidden. |
55
+ | `/mcp tools <server>` | Browse the server's tools and inspect descriptions without activating tools. |
56
+ | `/mcp reload` | Apply configuration changes without restarting Pi. |
54
57
  | `/mcp auth <server>` | Authenticate an OAuth-enabled HTTP server. |
55
58
  | `/mcp reconnect <server>` | Replace a connection and refresh its catalog. |
56
59
  | `/mcp refresh <server>` | Refresh a server's catalog without loading additional tools. |
57
60
 
61
+ The status matrix uses glyphs to distinguish idle (`○`), connected (`●`),
62
+ connecting (`▶︎`), disabled (`○`), and failed (`✘︎`) servers. Idle is normal:
63
+ connections open on demand. A dash (`—`) means the catalog hasn't been fetched,
64
+ not that the server has no tools. The **Loaded** column counts tools currently
65
+ active for the assistant.
66
+
58
67
  After refreshing a changed schema, search for the tool again to load its current
59
68
  definition. Calls validate the live catalog before execution and refuse removed
60
69
  or changed tools. The extension does not retry failed tool invocations; after an
@@ -185,8 +194,23 @@ Every definition must include a `url` or `command`, even when `disabled` is true
185
194
  These options are specific to Pi MCP Client, not standardized MCP connection
186
195
  fields. Other clients may reject them when you copy a definition.
187
196
 
188
- Configuration changes take effect when Pi reloads the extension or starts a new
189
- session.
197
+ After editing your configuration, run `/mcp reload` to apply it without restarting
198
+ Pi. Reload validates the new configuration before replacing the current setup;
199
+ invalid configuration leaves the previous setup intact. It closes existing
200
+ connections, which reopen on demand, and deactivates tools from changed, removed,
201
+ or disabled server definitions. Unchanged active tools remain available.
202
+
203
+ Use `/mcp inspect <server>` to check the effective transport, protocol, filters,
204
+ and connection status without connecting or running secret commands. Connection
205
+ values—including commands, arguments, URLs, headers, and environment variables—
206
+ are hidden because any of them can contain credentials.
207
+
208
+ Use `/mcp tools <server>` to fetch the current catalog and browse a scrollable
209
+ list. Each row shows the tool name and description, trimmed to the terminal width
210
+ with an ellipsis. Select a tool to see a multiline signature and parameter details,
211
+ with each parameter in a separate paragraph. Browsing respects your include and
212
+ exclude filters and doesn't activate tools or add their schemas to the assistant's
213
+ context. This command requires an interactive UI.
190
214
 
191
215
  ### Discovery and caching
192
216
 
@@ -201,6 +225,12 @@ They contain tool metadata, not configured credentials. Cached search needs no
201
225
  connection; invocation refreshes the live catalog before calling the tool.
202
226
  Connections remain open until shutdown or explicit reconnection.
203
227
 
228
+ When a connected server reports a tool-list change, the extension invalidates its
229
+ memory and disk catalogs. The next search fetches the current list, including new
230
+ or removed tools. Notifications don't replace active tool definitions: changed
231
+ schemas require another `mcp_search` before use. Disconnected, cache-only searches
232
+ can't receive notifications and still use the 24-hour disk-cache expiry.
233
+
204
234
  ### OAuth
205
235
 
206
236
  Set `"oauth": true` under `mcpServers.<server>` in `mcp.json`, without an
package/dist/index.js CHANGED
@@ -2354,6 +2354,116 @@ function searchTools(tools, query, server, limit = DEFAULT_SEARCH_LIMIT) {
2354
2354
  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
2355
  }
2356
2356
 
2357
+ // src/management.ts
2358
+ init_config();
2359
+ import { truncateToWidth } from "@earendil-works/pi-tui";
2360
+ var serverStates = {
2361
+ disconnected: { glyph: "\u25CB", label: "idle" },
2362
+ connected: { glyph: "\u25CF", label: "connected" },
2363
+ connecting: { glyph: "\u25B6\uFE0E", label: "connecting" },
2364
+ failed: { glyph: "\u2718\uFE0E", label: "error" },
2365
+ disabled: { glyph: "\u25CB", label: "disabled" }
2366
+ };
2367
+ function serverMatrix(servers, loaded, width = 80) {
2368
+ if (width <= 0) return "";
2369
+ const fit = (text) => plain(truncateToWidth(text, width));
2370
+ if (!servers.length) return fit("No MCP servers configured.");
2371
+ const nameWidth = Math.min(40, Math.max(6, ...servers.map(({ name }) => name.length)));
2372
+ const heading = ` ${"Server".padEnd(nameWidth)} ${"State".padEnd(10)} ${"Tools".padStart(5)} ${"Loaded".padStart(6)}`;
2373
+ const rows = servers.map((server) => {
2374
+ const state = serverStates[server.state];
2375
+ const name = plain(truncateToWidth(line(server.name), nameWidth)).padEnd(nameWidth);
2376
+ return `${state.glyph} ${name} ${state.label.padEnd(10)} ${String(server.catalogSize ?? "\u2014").padStart(5)} ${String(loaded.get(server.name) ?? 0).padStart(6)}`;
2377
+ });
2378
+ const errors = servers.filter((server) => server.state === "failed" && server.error).map((server) => `\u2718\uFE0E ${line(server.name)}: [${server.error.code}] ${line(server.error.message)}`);
2379
+ return [
2380
+ heading,
2381
+ ...rows,
2382
+ "",
2383
+ "Connections open on demand. \u2014 = catalog not fetched.",
2384
+ ...errors
2385
+ ].map(fit).join("\n");
2386
+ }
2387
+ function toolPickerLabel(tool, index, columns = 80) {
2388
+ const width = Math.max(0, columns - 4);
2389
+ const text = `${index + 1}. ${line(tool.name)}: ${line(tool.description) || "No description."}`;
2390
+ return plain(truncateToWidth(text, width, "\u2026"));
2391
+ }
2392
+ function schemaType(schema, depth = 0) {
2393
+ if (!object(schema) || depth > 2) return "unknown";
2394
+ const alternatives = schema.anyOf ?? schema.oneOf;
2395
+ if (Array.isArray(alternatives)) {
2396
+ if (alternatives.length > 4) return "union";
2397
+ return [...new Set(alternatives.map((part) => schemaType(part, depth + 1)))].join(" | ") || "unknown";
2398
+ }
2399
+ if (schema.$ref || schema.allOf) return "unknown";
2400
+ const type = schema.type;
2401
+ if (Array.isArray(type))
2402
+ return type.slice(0, 4).map((part) => schemaType({ ...schema, type: part }, depth + 1)).join(" | ");
2403
+ if (type === "array") return `Array<${schemaType(schema.items, depth + 1)}>`;
2404
+ if (type === "integer") return "integer";
2405
+ if (["string", "number", "boolean", "object", "null"].includes(String(type))) return String(type);
2406
+ return "unknown";
2407
+ }
2408
+ function toolParameters(tool) {
2409
+ const schema = tool.inputSchema;
2410
+ if (!object(schema)) return [];
2411
+ const required = new Set(Array.isArray(schema.required) ? schema.required : []);
2412
+ return Object.entries(object(schema.properties) ? schema.properties : {}).map(([name, value]) => ({
2413
+ name: line(name).slice(0, 80),
2414
+ required: required.has(name),
2415
+ type: schemaType(value),
2416
+ description: object(value) && typeof value.description === "string" ? line(value.description) : ""
2417
+ }));
2418
+ }
2419
+ function toolSignature(tool, limit = 40) {
2420
+ const parameters = toolParameters(tool);
2421
+ const args = parameters.slice(0, limit).map(
2422
+ (parameter) => `${parameter.name}${parameter.required ? "" : "?"}: ${parameter.type}`
2423
+ );
2424
+ if (parameters.length > limit) args.push(`\u2026 +${parameters.length - limit} more`);
2425
+ const schema = tool.inputSchema;
2426
+ if (object(schema) && schema.additionalProperties !== false) args.push("\u2026");
2427
+ const name = line(tool.name).slice(0, 160);
2428
+ return args.length ? `${name}(
2429
+ ${args.map((arg) => ` ${arg},`).join("\n")}
2430
+ )` : `${name}()`;
2431
+ }
2432
+ function inspectTool(tool) {
2433
+ const parameters = toolParameters(tool);
2434
+ return [
2435
+ toolSignature(tool),
2436
+ line(tool.description) || "No description.",
2437
+ ...parameters.length ? ["Parameters:"] : [],
2438
+ ...parameters.slice(0, 40).map(
2439
+ (parameter) => `${parameter.name}: ${parameter.type} (${parameter.required ? "required" : "optional"})${parameter.description ? `
2440
+ ${parameter.description}` : ""}`
2441
+ ),
2442
+ ...parameters.length > 40 ? [`\u2026 ${parameters.length - 40} more parameters`] : [],
2443
+ "Types are summaries; the full schema may impose additional constraints."
2444
+ ].join("\n\n");
2445
+ }
2446
+ function inspectServer(name, config, status) {
2447
+ return [
2448
+ status,
2449
+ `Server: ${name}`,
2450
+ `Transport: ${config.command ? "stdio" : "HTTP"}`,
2451
+ `Protocol: ${config.protocol ?? "auto"}`,
2452
+ `OAuth: ${config.oauth ? "enabled" : "disabled"}`,
2453
+ `Timeout: ${config.timeoutMs ? `${config.timeoutMs} ms` : "default"}`,
2454
+ ...config.command ? [
2455
+ "Command and working directory: hidden",
2456
+ `Arguments: ${config.args?.length ?? 0} (values hidden)`,
2457
+ `Environment overrides: ${Object.keys(config.env ?? {}).length} (names and values hidden)`
2458
+ ] : [
2459
+ "URL: hidden",
2460
+ `Headers: ${Object.keys(config.headers ?? {}).length} (names and values hidden)`
2461
+ ],
2462
+ `Include tools: ${config.includeTools ? config.includeTools.map(line).join(", ") : "all"}`,
2463
+ `Exclude tools: ${config.excludeTools?.map(line).join(", ") || "none"}`
2464
+ ].join("\n");
2465
+ }
2466
+
2357
2467
  // src/auth.ts
2358
2468
  init_config();
2359
2469
  init_diagnostics();
@@ -2531,7 +2641,7 @@ async function authenticate(url, open, signal, store) {
2531
2641
 
2532
2642
  // src/runtime.ts
2533
2643
  init_config();
2534
- import { mkdir, readFile as readFile2, rename, stat, writeFile } from "node:fs/promises";
2644
+ import { mkdir, readFile as readFile2, rename, rm, stat, writeFile } from "node:fs/promises";
2535
2645
  import { join as join2 } from "node:path";
2536
2646
  import { randomUUID as randomUUID2 } from "node:crypto";
2537
2647
  import {
@@ -2565,11 +2675,18 @@ function waitFor(promise, signal) {
2565
2675
  if (signal.aborted) abort();
2566
2676
  });
2567
2677
  }
2568
- var connectSdk = async (_name, config, signal) => {
2678
+ var connectSdk = async (_name, config, signal, onToolsChanged) => {
2569
2679
  const timeout = config.timeoutMs ?? 15e3;
2570
2680
  const client = new Client(
2571
2681
  { name: "pi-mcp-client", version: "0.1.0" },
2572
2682
  {
2683
+ listChanged: {
2684
+ tools: {
2685
+ autoRefresh: false,
2686
+ debounceMs: 0,
2687
+ onChanged: () => onToolsChanged?.()
2688
+ }
2689
+ },
2573
2690
  versionNegotiation: {
2574
2691
  mode: config.protocol ?? "auto",
2575
2692
  probe: { timeoutMs: timeout }
@@ -2585,21 +2702,39 @@ var connectSdk = async (_name, config, signal) => {
2585
2702
  }) : new StreamableHTTPClientTransport(new URL(config.url), {
2586
2703
  requestInit: { headers: config.headers },
2587
2704
  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", () => {
2705
+ // Bound HTTP responses (including OAuth), but not established SSE streams.
2706
+ // The SDK bounds ordinary MCP requests with their request timeout.
2707
+ fetch: async (input, init) => {
2708
+ const deadline = new AbortController();
2709
+ const timer = setTimeout(
2710
+ () => deadline.abort(new Error("HTTP response timed out.")),
2711
+ timeout
2712
+ );
2713
+ timer.unref();
2714
+ try {
2715
+ const response = await fetch(input, {
2716
+ ...init,
2717
+ signal: AbortSignal.any([
2718
+ signal,
2719
+ deadline.signal,
2720
+ ...init?.signal ? [init.signal] : []
2721
+ ])
2722
+ });
2723
+ if (response.headers.get("content-type")?.split(";")[0].trim() === "text/event-stream")
2724
+ clearTimeout(timer);
2725
+ return response;
2726
+ } catch (error) {
2727
+ clearTimeout(timer);
2728
+ throw error;
2729
+ }
2730
+ }
2599
2731
  });
2732
+ if (transport instanceof StdioClientTransport)
2733
+ transport.stderr?.on("data", () => {
2734
+ });
2600
2735
  try {
2601
2736
  await waitFor(
2602
- client.connect(transport),
2737
+ client.connect(transport, { signal, timeout }),
2603
2738
  AbortSignal.any([signal, AbortSignal.timeout(timeout)])
2604
2739
  );
2605
2740
  signal.throwIfAborted();
@@ -2621,6 +2756,7 @@ var McpRuntime = class {
2621
2756
  }
2622
2757
  states = /* @__PURE__ */ new Map();
2623
2758
  lifetime = new AbortController();
2759
+ closing;
2624
2760
  identity(name) {
2625
2761
  const config = this.definition(name);
2626
2762
  let resolved;
@@ -2643,18 +2779,21 @@ var McpRuntime = class {
2643
2779
  definition(name) {
2644
2780
  const config = Object.hasOwn(this.config, name) ? this.config[name] : void 0;
2645
2781
  if (!config || config.disabled)
2646
- throw new ToolContractError("MCP server is not configured or is disabled.");
2782
+ throw new ToolContractError(
2783
+ "MCP server is not configured or is disabled."
2784
+ );
2647
2785
  return config;
2648
2786
  }
2649
2787
  state(name) {
2650
2788
  let state = this.states.get(name);
2651
2789
  if (!state) {
2652
- state = {};
2790
+ state = { catalogGeneration: 0 };
2653
2791
  this.states.set(name, state);
2654
2792
  }
2655
2793
  return state;
2656
2794
  }
2657
2795
  async client(name) {
2796
+ if (this.closing) throw new Error("MCP session ended.");
2658
2797
  this.lifetime.signal.throwIfAborted();
2659
2798
  const state = this.state(name);
2660
2799
  const identity = this.identity(name);
@@ -2666,6 +2805,8 @@ var McpRuntime = class {
2666
2805
  if (state.connecting) return state.connecting;
2667
2806
  const config = resolveServer(this.definition(name), this.cwd);
2668
2807
  state.connectionIdentity = identity;
2808
+ const token = {};
2809
+ state.connectionToken = token;
2669
2810
  state.connecting = (async () => {
2670
2811
  const { client, transport } = await this.connect(
2671
2812
  name,
@@ -2675,9 +2816,28 @@ var McpRuntime = class {
2675
2816
  this.cwd,
2676
2817
  this.lifetime.signal
2677
2818
  ),
2678
- this.lifetime.signal
2819
+ this.lifetime.signal,
2820
+ () => {
2821
+ if (this.closing || this.lifetime.signal.aborted || state.connectionToken !== token)
2822
+ return;
2823
+ state.catalogGeneration++;
2824
+ state.catalogDirty = true;
2825
+ state.tools = void 0;
2826
+ state.warnings = void 0;
2827
+ const path = join2(this.cacheDir, `${identity}.json`);
2828
+ state.invalidating = withFileMutationQueue(
2829
+ path,
2830
+ () => rm(path, { force: true })
2831
+ ).catch(() => {
2832
+ state.warnings = [
2833
+ `${name}: stale catalog cache could not be removed.`
2834
+ ];
2835
+ });
2836
+ }
2679
2837
  );
2680
- if (this.lifetime.signal.aborted) {
2838
+ if (this.closing || this.lifetime.signal.aborted) {
2839
+ await client.autoOpenedSubscription?.close().catch(() => {
2840
+ });
2681
2841
  await client.close();
2682
2842
  throw new Error("MCP session ended.");
2683
2843
  }
@@ -2688,10 +2848,12 @@ var McpRuntime = class {
2688
2848
  if (state.client === client) {
2689
2849
  state.client = void 0;
2690
2850
  state.transport = void 0;
2851
+ state.connectionToken = void 0;
2691
2852
  }
2692
2853
  };
2693
2854
  return client;
2694
2855
  })().catch((error) => {
2856
+ if (state.connectionToken === token) state.connectionToken = void 0;
2695
2857
  throw new DiagnosticError(
2696
2858
  diagnose(error, {
2697
2859
  server: name,
@@ -2707,10 +2869,13 @@ var McpRuntime = class {
2707
2869
  }
2708
2870
  async catalog(name, signal, refresh = false) {
2709
2871
  signal?.throwIfAborted();
2872
+ if (this.closing) throw new Error("MCP session ended.");
2710
2873
  this.lifetime.signal.throwIfAborted();
2711
2874
  const state = this.state(name);
2712
2875
  const identity = this.identity(name);
2713
- if (!refresh && state.tools && state.identity === identity) return state.tools;
2876
+ refresh ||= !!state.catalogDirty;
2877
+ if (!refresh && state.tools && state.identity === identity)
2878
+ return state.tools;
2714
2879
  if (refresh && state.listing && !state.listingLive) {
2715
2880
  await waitFor(state.listing, signal);
2716
2881
  return this.catalog(name, signal, true);
@@ -2718,44 +2883,59 @@ var McpRuntime = class {
2718
2883
  if (!state.listing) {
2719
2884
  state.listingLive = refresh;
2720
2885
  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;
2886
+ for (let attempt = 0; attempt < 3; attempt++) {
2887
+ const generation = state.catalogGeneration;
2888
+ if (!refresh && !state.catalogDirty) {
2889
+ const cached = await this.readCache(name, identity);
2890
+ if (cached && generation === state.catalogGeneration) {
2891
+ state.tools = cached;
2892
+ state.identity = identity;
2893
+ return cached;
2894
+ }
2727
2895
  }
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
- );
2896
+ const client = await this.client(name);
2897
+ const listed = await client.listTools(void 0, {
2898
+ signal: this.lifetime.signal,
2899
+ timeout: this.definition(name).timeoutMs ?? 15e3
2900
+ });
2901
+ const tools = [];
2902
+ const warnings = [];
2903
+ const seen = /* @__PURE__ */ new Set();
2904
+ for (const tool of listed.tools) {
2905
+ if (!allowed(tool.name, this.definition(name))) continue;
2906
+ try {
2907
+ const prepared = prepareTool(name, identity, tool);
2908
+ if (seen.has(prepared.nativeName))
2909
+ throw new Error("Duplicate tool name.");
2910
+ seen.add(prepared.nativeName);
2911
+ tools.push(prepared);
2912
+ } catch {
2913
+ warnings.push(
2914
+ `${name}: skipped an invalid, duplicate, or unsupported tool schema.`
2915
+ );
2916
+ }
2748
2917
  }
2918
+ this.lifetime.signal.throwIfAborted();
2919
+ if (generation !== state.catalogGeneration) continue;
2920
+ await state.invalidating;
2921
+ await this.writeCache(
2922
+ identity,
2923
+ tools,
2924
+ () => generation === state.catalogGeneration
2925
+ ).catch(() => {
2926
+ warnings.push(`${name}: catalog cache could not be saved.`);
2927
+ });
2928
+ if (generation !== state.catalogGeneration) continue;
2929
+ state.tools = tools;
2930
+ state.identity = identity;
2931
+ state.catalogDirty = false;
2932
+ state.error = void 0;
2933
+ state.warnings = warnings;
2934
+ return tools;
2749
2935
  }
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;
2936
+ throw new ToolContractError(
2937
+ "MCP tool catalog kept changing. Search again."
2938
+ );
2759
2939
  })().catch((error) => {
2760
2940
  state.error = this.failure(name, error);
2761
2941
  throw new DiagnosticError(state.error);
@@ -2812,7 +2992,10 @@ var McpRuntime = class {
2812
2992
  return await client.callTool(
2813
2993
  { name: tool.name, arguments: args },
2814
2994
  {
2815
- signal: AbortSignal.any([this.lifetime.signal, ...signal ? [signal] : []]),
2995
+ signal: AbortSignal.any([
2996
+ this.lifetime.signal,
2997
+ ...signal ? [signal] : []
2998
+ ]),
2816
2999
  timeout: config.timeoutMs ?? 3e4,
2817
3000
  onprogress: (event) => progress?.(
2818
3001
  event.message ?? `${event.progress}${event.total === void 0 ? "" : `/${event.total}`}`
@@ -2835,18 +3018,38 @@ var McpRuntime = class {
2835
3018
  const state = this.state(name);
2836
3019
  if (state.connecting || state.listing)
2837
3020
  throw failure("busy", { server: name, operation: "reconnect" });
3021
+ await state.client?.autoOpenedSubscription?.close();
2838
3022
  await state.client?.close();
2839
3023
  state.client = void 0;
2840
3024
  await this.catalog(name, void 0, true);
2841
3025
  }
2842
- status() {
3026
+ serverStatuses() {
2843
3027
  return Object.entries(this.config).map(([name, config]) => {
2844
3028
  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`;
3029
+ return {
3030
+ name,
3031
+ state: config.disabled ? "disabled" : state?.connecting || state?.listing ? "connecting" : state?.error ? "failed" : state?.client ? "connected" : "disconnected",
3032
+ catalogSize: state?.tools?.length,
3033
+ error: state?.error
3034
+ };
3035
+ });
3036
+ }
3037
+ status(server) {
3038
+ return this.serverStatuses().filter(({ name }) => server === void 0 || name === server).map((row) => {
3039
+ const status = row.state === "failed" && row.error ? formatDiagnostic({ ...row.error, server: void 0 }) : row.state;
3040
+ return `${row.name}: ${status} \xB7 ${row.catalogSize ?? "unknown"} catalog tools`;
2847
3041
  }).join("\n") || "No MCP servers configured.";
2848
3042
  }
2849
- async close() {
3043
+ close() {
3044
+ return this.closing ??= this.shutdown();
3045
+ }
3046
+ async shutdown() {
3047
+ await Promise.all(
3048
+ [...this.states.values()].map(
3049
+ (state) => state.client?.autoOpenedSubscription?.close().catch(() => {
3050
+ })
3051
+ )
3052
+ );
2850
3053
  this.lifetime.abort(new Error("MCP session ended."));
2851
3054
  await Promise.all(
2852
3055
  [...this.states.values()].map(async (state) => {
@@ -2856,6 +3059,7 @@ var McpRuntime = class {
2856
3059
  });
2857
3060
  await state.listing?.catch(() => {
2858
3061
  });
3062
+ await state.invalidating;
2859
3063
  })
2860
3064
  );
2861
3065
  }
@@ -2871,7 +3075,8 @@ var McpRuntime = class {
2871
3075
  try {
2872
3076
  const path = join2(this.cacheDir, `${identity}.json`);
2873
3077
  const info = await stat(path);
2874
- if (info.size > 4 * 1024 * 1024 || Date.now() - info.mtimeMs > 864e5) return;
3078
+ if (info.size > 4 * 1024 * 1024 || Date.now() - info.mtimeMs > 864e5)
3079
+ return;
2875
3080
  const data = JSON.parse(await readFile2(path, "utf8"));
2876
3081
  if (!Array.isArray(data) || data.length > 1e4) return;
2877
3082
  return data.filter((tool) => allowed(tool.name, this.definition(name))).map((tool) => prepareTool(name, identity, tool));
@@ -2879,7 +3084,7 @@ var McpRuntime = class {
2879
3084
  return;
2880
3085
  }
2881
3086
  }
2882
- async writeCache(identity, tools) {
3087
+ async writeCache(identity, tools, isCurrent) {
2883
3088
  const text = JSON.stringify(
2884
3089
  tools.map(({ name, description, inputSchema }) => ({
2885
3090
  name,
@@ -2891,9 +3096,11 @@ var McpRuntime = class {
2891
3096
  await mkdir(this.cacheDir, { recursive: true, mode: 448 });
2892
3097
  const path = join2(this.cacheDir, `${identity}.json`);
2893
3098
  await withFileMutationQueue(path, async () => {
3099
+ if (!isCurrent()) return;
2894
3100
  const temp = `${path}.${randomUUID2()}.tmp`;
2895
3101
  await writeFile(temp, text, { mode: 384 });
2896
- await rename(temp, path);
3102
+ if (isCurrent()) await rename(temp, path);
3103
+ else await rm(temp, { force: true });
2897
3104
  });
2898
3105
  }
2899
3106
  };
@@ -3036,7 +3243,7 @@ Full MCP result: ${path}`;
3036
3243
 
3037
3244
  // src/render.ts
3038
3245
  import { keyText } from "@earendil-works/pi-coding-agent";
3039
- import { Text, truncateToWidth } from "@earendil-works/pi-tui";
3246
+ import { Text, truncateToWidth as truncateToWidth2 } from "@earendil-works/pi-tui";
3040
3247
  init_config();
3041
3248
  var states = {
3042
3249
  queued: { glyph: "\u25CF", color: "dim" },
@@ -3053,10 +3260,10 @@ function renderCall(title, args, theme, expanded) {
3053
3260
  if (width <= 0) return [];
3054
3261
  const text = theme.fg("toolTitle", theme.bold(line(title))) + (preview ? theme.fg("dim", ` ${preview}`) : "");
3055
3262
  if (expanded)
3056
- return new Text(text, 0, 0).render(width).map((row) => truncateToWidth(row, width));
3263
+ return new Text(text, 0, 0).render(width).map((row) => truncateToWidth2(row, width));
3057
3264
  const key = keyText("app.tools.expand");
3058
3265
  return [
3059
- truncateToWidth(
3266
+ truncateToWidth2(
3060
3267
  text + (key ? theme.fg("muted", ` (${key} to expand)`) : ""),
3061
3268
  width
3062
3269
  )
@@ -3083,23 +3290,23 @@ function renderResult(result, options, theme, isError) {
3083
3290
  const lines = rows.flatMap((row) => {
3084
3291
  const status = states[row.state] ?? states.failed;
3085
3292
  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)];
3293
+ 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
3294
  if (options.expanded && row.description)
3088
- rendered.push(truncateToWidth(
3295
+ rendered.push(truncateToWidth2(
3089
3296
  theme.fg("dim", ` ${line(row.description)}`),
3090
3297
  width
3091
3298
  ));
3092
3299
  return rendered;
3093
3300
  });
3094
3301
  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)));
3302
+ 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
3303
  if (options.expanded && !options.isPartial && text && !details?.searchNotes)
3097
3304
  lines.push(
3098
- ...new Text(text, 0, 0).render(width).map((x) => truncateToWidth(x, width))
3305
+ ...new Text(text, 0, 0).render(width).map((x) => truncateToWidth2(x, width))
3099
3306
  );
3100
3307
  if (!options.expanded && details?.fullOutputPath)
3101
3308
  lines.push(
3102
- truncateToWidth(
3309
+ truncateToWidth2(
3103
3310
  theme.fg("dim", `Full result: ${details.fullOutputPath}`),
3104
3311
  width
3105
3312
  )
@@ -3132,6 +3339,7 @@ function mcpClient(pi, options = {}) {
3132
3339
  let runtime;
3133
3340
  let config = {};
3134
3341
  let configError;
3342
+ let sessionGeneration = 0;
3135
3343
  const exposure = new Exposure(pi, registerNative);
3136
3344
  const current = () => {
3137
3345
  if (!runtime)
@@ -3192,28 +3400,54 @@ Text output is limited to 2000 lines or 50 KiB; larger results are saved to a pr
3192
3400
  });
3193
3401
  exposure.restore(tools);
3194
3402
  };
3403
+ async function reloadConfiguration(ctx) {
3404
+ const generation = sessionGeneration;
3405
+ ctx.signal?.throwIfAborted();
3406
+ const nextConfig = await loadConfig(agentDir, ctx.cwd, ctx.isProjectTrusted());
3407
+ ctx.signal?.throwIfAborted();
3408
+ if (generation !== sessionGeneration)
3409
+ throw new CommandUsageError("The Pi session changed during configuration reload.");
3410
+ for (const definition of Object.values(nextConfig)) {
3411
+ if (!definition.disabled) resolveServer(definition, ctx.cwd);
3412
+ }
3413
+ const next = new McpRuntime(
3414
+ nextConfig,
3415
+ ctx.cwd,
3416
+ join4(agentDir, "cache", "pi-mcp-client")
3417
+ );
3418
+ const active = new Set(pi.getActiveTools());
3419
+ const retained = [...exposure.definitions.values()].filter((tool) => {
3420
+ try {
3421
+ return active.has(tool.nativeName) && next.identity(tool.server) === tool.identity && allowed(tool.name, nextConfig[tool.server]);
3422
+ } catch {
3423
+ return false;
3424
+ }
3425
+ });
3426
+ const old = runtime;
3427
+ config = nextConfig;
3428
+ configError = void 0;
3429
+ runtime = next;
3430
+ exposure.restore(retained);
3431
+ await old?.close();
3432
+ }
3195
3433
  pi.on("session_start", async (_event, ctx) => {
3434
+ sessionGeneration++;
3196
3435
  await runtime?.close();
3197
3436
  runtime = void 0;
3198
3437
  config = {};
3199
3438
  configError = void 0;
3200
3439
  try {
3201
3440
  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
- );
3441
+ runtime = new McpRuntime(config, ctx.cwd, join4(agentDir, "cache", "pi-mcp-client"));
3207
3442
  } catch (error) {
3208
- configError = new DiagnosticError(
3209
- diagnose(error, { operation: "configuration" })
3210
- );
3443
+ configError = new DiagnosticError(diagnose(error, { operation: "configuration" }));
3211
3444
  if (ctx.hasUI) ctx.ui.notify(configError.message, "error");
3212
3445
  }
3213
3446
  restore(ctx);
3214
3447
  });
3215
3448
  pi.on("session_tree", (_event, ctx) => restore(ctx));
3216
3449
  pi.on("session_shutdown", async () => {
3450
+ sessionGeneration++;
3217
3451
  const old = runtime;
3218
3452
  runtime = void 0;
3219
3453
  await old?.close();
@@ -3247,17 +3481,21 @@ Use mcp_search to load relevant tools, then call them directly. Loaded tools rem
3247
3481
  maxLength: 500,
3248
3482
  description: "One focused capability or exact server.tool name, for example linear.list_teams. Do not enumerate every capability of a server."
3249
3483
  }),
3250
- server: Type.Optional(Type.String({
3251
- minLength: 1,
3252
- maxLength: 80,
3253
- description: "Restrict discovery to this configured MCP server."
3254
- })),
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
- }))
3484
+ server: Type.Optional(
3485
+ Type.String({
3486
+ minLength: 1,
3487
+ maxLength: 80,
3488
+ description: "Restrict discovery to this configured MCP server."
3489
+ })
3490
+ ),
3491
+ limit: Type.Optional(
3492
+ Type.Integer({
3493
+ minimum: 1,
3494
+ maximum: MAX_SEARCH_LIMIT,
3495
+ default: DEFAULT_SEARCH_LIMIT,
3496
+ 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.`
3497
+ })
3498
+ )
3261
3499
  },
3262
3500
  { additionalProperties: false }
3263
3501
  ),
@@ -3282,12 +3520,7 @@ Use mcp_search to load relevant tools, then call them directly. Loaded tools rem
3282
3520
  (signal ?? ctx.signal)?.throwIfAborted();
3283
3521
  if (runtime !== activeRuntime)
3284
3522
  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
- );
3523
+ const matches2 = searchTools(discovery.tools, args.query, args.server, args.limit);
3291
3524
  const { loaded, added, rejected } = exposure.load(matches2);
3292
3525
  const messages2 = loaded.map(
3293
3526
  (tool) => `${added.includes(tool.nativeName) ? "Loaded" : "Already loaded"}: ${tool.nativeName} \u2014 ${line(tool.description).slice(0, 180)}`
@@ -3344,34 +3577,81 @@ Use mcp_search to load relevant tools, then call them directly. Loaded tools rem
3344
3577
  }
3345
3578
  });
3346
3579
  pi.registerCommand("mcp", {
3347
- description: "Inspect MCP servers; /mcp auth|reconnect|refresh <server>",
3580
+ description: "Manage MCP servers: list, status, reload, inspect|tools|auth|reconnect|refresh <server>",
3348
3581
  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 }));
3582
+ const serverActions = ["inspect", "tools", "auth", "reconnect", "refresh"];
3583
+ const input = prefix.trimStart();
3584
+ const match = /^(\S+)\s+(.*)$/s.exec(input);
3585
+ if (!match) {
3586
+ return ["list", "status", "reload", ...serverActions].filter((action2) => action2.startsWith(input)).map((action2) => ({ value: action2, label: action2 }));
3587
+ }
3588
+ const [, action, partialServer] = match;
3589
+ if (!serverActions.includes(action) || /\s/.test(partialServer)) return [];
3590
+ return Object.keys(config).filter(
3591
+ (name) => name.startsWith(partialServer) && (action === "inspect" || !config[name].disabled)
3592
+ ).sort().map((name) => ({ value: `${action} ${name}`, label: name }));
3356
3593
  },
3357
3594
  async handler(args, ctx) {
3358
3595
  await ctx.waitForIdle();
3359
3596
  const [action = "status", server, ...extra] = args.trim().split(/\s+/).filter(Boolean);
3360
3597
  try {
3361
- if (action === "status" && !server) {
3362
- const loaded = pi.getActiveTools().filter((name) => exposure.definitions.has(name)).length;
3598
+ if (action === "reload" && !server) {
3599
+ await reloadConfiguration(ctx);
3363
3600
  if (ctx.hasUI)
3364
3601
  ctx.ui.notify(
3365
- `${current().status()}
3366
- ${loaded} native MCP tools loaded.`,
3602
+ "\u2714\uFE0E MCP configuration reloaded. Connections reopen on demand; tools from changed or removed servers are no longer active.",
3367
3603
  "info"
3368
3604
  );
3369
3605
  return;
3370
3606
  }
3607
+ if (action === "inspect" && server && !extra.length && Object.hasOwn(config, server)) {
3608
+ if (ctx.hasUI)
3609
+ ctx.ui.notify(
3610
+ inspectServer(server, config[server], current().status(server)),
3611
+ "info"
3612
+ );
3613
+ return;
3614
+ }
3615
+ if ((action === "status" || action === "list") && !server) {
3616
+ const statuses = current().serverStatuses();
3617
+ const loaded = /* @__PURE__ */ new Map();
3618
+ for (const name of pi.getActiveTools()) {
3619
+ const tool = exposure.definitions.get(name);
3620
+ if (tool) loaded.set(tool.server, (loaded.get(tool.server) ?? 0) + 1);
3621
+ }
3622
+ if (ctx.hasUI) ctx.ui.notify(serverMatrix(statuses, loaded), "info");
3623
+ return;
3624
+ }
3371
3625
  if (!server || extra.length || !Object.hasOwn(config, server) || config[server].disabled)
3372
3626
  throw new CommandUsageError(
3373
- "Usage: /mcp auth|reconnect|refresh <enabled-server>"
3627
+ "Usage: /mcp list|status|reload or /mcp inspect|tools|auth|reconnect|refresh <server>. Only inspect accepts a disabled server."
3374
3628
  );
3629
+ if (action === "tools") {
3630
+ if (!ctx.hasUI)
3631
+ throw new CommandUsageError("Tool browsing requires an interactive UI.");
3632
+ const tools = await current().catalog(server, ctx.signal, true);
3633
+ if (!tools.length) {
3634
+ ctx.ui.notify(
3635
+ `${server}: no tools available under the configured filters.`,
3636
+ "info"
3637
+ );
3638
+ return;
3639
+ }
3640
+ const choices = [...tools].sort((a, b) => a.name.localeCompare(b.name));
3641
+ const columns = ctx.mode === "tui" ? process.stdout.columns || 80 : 80;
3642
+ const labels = choices.map((tool2, index) => toolPickerLabel(tool2, index, columns));
3643
+ const selected = await ctx.ui.select(
3644
+ `${server}: ${tools.length} tools (select to inspect; none are activated)`,
3645
+ labels
3646
+ );
3647
+ const tool = selected === void 0 ? void 0 : choices[labels.indexOf(selected)];
3648
+ if (tool)
3649
+ ctx.ui.notify(
3650
+ inspectTool(tool),
3651
+ "info"
3652
+ );
3653
+ return;
3654
+ }
3375
3655
  if (action === "auth") {
3376
3656
  if (!ctx.hasUI)
3377
3657
  throw new CommandUsageError(
@@ -3416,27 +3696,27 @@ ${target}`, "info");
3416
3696
  } else await authenticate(url, open, ctx.signal);
3417
3697
  await current().reconnect(server);
3418
3698
  } else if (action === "reconnect") await current().reconnect(server);
3419
- else if (action === "refresh")
3420
- await current().catalog(server, ctx.signal, true);
3699
+ else if (action === "refresh") await current().catalog(server, ctx.signal, true);
3421
3700
  else
3422
3701
  throw new CommandUsageError(
3423
- "Unknown MCP command. Use /mcp auth|reconnect|refresh <server>."
3702
+ "Unknown MCP command. Use /mcp list|status|reload or /mcp inspect|tools|auth|reconnect|refresh <server>."
3424
3703
  );
3425
3704
  if (ctx.hasUI)
3426
3705
  ctx.ui.notify(
3427
- `${server}: ${action} complete. Search to load new or changed tools.`,
3706
+ `\u2714\uFE0E ${server}: ${action} complete. Updated tools are available for the assistant to discover.`,
3428
3707
  "info"
3429
3708
  );
3430
3709
  } catch (error) {
3431
3710
  const message = error instanceof CommandUsageError ? error.message : formatDiagnostic(
3432
3711
  diagnose(error, {
3433
3712
  server,
3434
- operation: action === "auth" ? "auth" : action === "refresh" ? "refresh" : "reconnect",
3713
+ operation: action === "reload" || action === "inspect" ? "configuration" : action === "tools" ? "search" : action === "auth" ? "auth" : action === "refresh" ? "refresh" : "reconnect",
3435
3714
  oauth: config[server]?.oauth,
3436
3715
  signal: ctx.signal
3437
3716
  })
3438
3717
  );
3439
3718
  if (ctx.hasUI) ctx.ui.notify(message, "error");
3719
+ else throw new Error(message);
3440
3720
  }
3441
3721
  }
3442
3722
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mcp-client",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "MCP tools for Pi, discovered on demand and called natively through the official SDK.",
5
5
  "type": "module",
6
6
  "license": "MIT",