pi-mcp-client 0.2.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 +87 -23
  2. package/dist/index.js +367 -125
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -29,22 +29,66 @@ Start a new Pi session and ask it to search Cloudflare's documentation. Use `/mc
29
29
  to inspect the connection. For authenticated services, see [OAuth](#oauth) or
30
30
  [secret commands](#secret-commands).
31
31
 
32
- Pi searches for the tools it needs, then calls those tools directly. Search
33
- loads up to five matching tools by default, or up to 50 with `limit`. Results
34
- use local BM25-based ranking, with tool names weighted more strongly than
35
- descriptions and support for prefix matching. Full schemas become available on
36
- the next model turn, without a separate describe step. Previously loaded tools
37
- remain available as the conversation continues.
32
+ Pi discovers candidates, explicitly activates the tools it needs, then calls
33
+ those tools natively. One `mcp_tools` tool supports both steps:
34
+
35
+ ```js
36
+ // Discover candidates. Never activates, even for an exact-name query.
37
+ mcp_tools({ query: "list teams", server: "linear", limit: 5 })
38
+
39
+ // Activate exact identifiers. Never invokes.
40
+ mcp_tools({ activate: ["linear.list_teams", "linear.get_team"] })
41
+ ```
42
+
43
+ Pass exactly one of `query` or `activate`. The optional `server` and `limit`
44
+ fields are valid only with `query`. Discovery returns up to five candidates by
45
+ default, or up to 50 with `limit`. Each candidate shows its exact activation
46
+ identifier, a short description, required parameter names only, and `[loaded]`
47
+ if already active. Results use local BM25-based ranking, with tool names weighted
48
+ more strongly than descriptions and support for prefix matching.
49
+
50
+ Activation accepts 1–50 exact `server.tool` or `mcp__server__tool` identifiers,
51
+ ignores duplicates, and works without a prior search. Typos never activate fuzzy
52
+ matches: failures list nearby catalog names when available so the assistant can
53
+ retry with an exact identifier. Each identifier reports `loaded`, `already loaded`,
54
+ or `not loaded` with a reason. Partial success keeps the tools that loaded.
55
+
56
+ Full schemas become available on the model turn after activation. First use of a
57
+ capability now takes three turns—discover, activate, call—so a fuzzy search match
58
+ can never become an active tool. Previously loaded tools remain available.
59
+
60
+ `mcp_tools` replaces `mcp_search` without backward compatibility. Update explicit
61
+ Pi tool allowlists to use `mcp_tools` and activate the tools you need again in
62
+ existing sessions. The UI labels discovery calls **mcp discover** and activation
63
+ calls **mcp activate**.
64
+
65
+ ### Result display
66
+
67
+ Discovery rows show `○` for inactive candidates and `●` for already active tools,
68
+ without a status suffix. These reflect the state when discovery runs; earlier
69
+ results don't update retroactively. Activation results use `✔︎` for success and
70
+ `✘︎` for failure. Descriptions stay gray; identifiers remain prominent.
71
+
72
+ Expand a tool result to see JSON objects and arrays formatted with two-space
73
+ indentation and syntax highlighting. Explicit JSON resource MIME types (including
74
+ `application/*+json`) and structured content identify JSON without guessing.
75
+ Other explicit MIME types stay plain text; unlabeled text is checked for JSON.
76
+
77
+ Formatting changes only the display, not the response sent to the assistant.
78
+ Invalid or truncated JSON stays plain text. Results that would exceed formatting
79
+ limits also stay plain text. Resource-link MIME types describe the linked content,
80
+ not the displayed link label.
38
81
 
39
82
  ### Session behavior
40
83
 
41
84
  - Tools accumulate rather than rotating with each prompt.
42
- - Resume and branch navigation restore tools acquired on the selected branch.
85
+ - Resume and branch navigation restore tools activated through `mcp_tools` on
86
+ the selected branch. Discovery results never restore tools.
43
87
  - Compaction retains the acquired tool set. New sessions start fresh.
44
88
  - Pi uses native deferred loading where supported by the model and provider.
45
89
  Other providers receive the expanded tool list normally.
46
- - Search respects server filters and Pi's tool exclusions. An explicit tool
47
- allowlist must include both `mcp_search` and the native tools you want to load.
90
+ - Discovery respects server filters; activation also respects Pi's tool exclusions. An explicit tool
91
+ allowlist must include both `mcp_tools` and the native tools you want to load.
48
92
 
49
93
  ### Commands
50
94
 
@@ -54,6 +98,8 @@ remain available as the conversation continues.
54
98
  | `/mcp inspect <server>` | Inspect status and configuration, including disabled servers. Connection values are hidden. |
55
99
  | `/mcp tools <server>` | Browse the server's tools and inspect descriptions without activating tools. |
56
100
  | `/mcp reload` | Apply configuration changes without restarting Pi. |
101
+ | `/mcp enable <server>` | Enable a server in its effective configuration file. |
102
+ | `/mcp disable <server>` | Disable a server, close its connection, and deactivate its tools. |
57
103
  | `/mcp auth <server>` | Authenticate an OAuth-enabled HTTP server. |
58
104
  | `/mcp reconnect <server>` | Replace a connection and refresh its catalog. |
59
105
  | `/mcp refresh <server>` | Refresh a server's catalog without loading additional tools. |
@@ -64,8 +110,8 @@ connections open on demand. A dash (`—`) means the catalog hasn't been fetched
64
110
  not that the server has no tools. The **Loaded** column counts tools currently
65
111
  active for the assistant.
66
112
 
67
- After refreshing a changed schema, search for the tool again to load its current
68
- definition. Calls validate the live catalog before execution and refuse removed
113
+ After refreshing a changed schema, activate the tool again with its exact
114
+ identifier to load its current definition. Calls validate the live catalog before execution and refuse removed
69
115
  or changed tools. The extension does not retry failed tool invocations; after an
70
116
  interrupted call, check whether the operation completed before trying again.
71
117
 
@@ -145,12 +191,12 @@ Commands use `/bin/sh` on Unix or Pi's shell selection on Windows, inherit Pi's
145
191
  process environment, and run in the server's configured `cwd` (the project
146
192
  directory by default). They run once per connection, including reconnections,
147
193
  not during configuration loading, status display, or cached discovery. Cold
148
- searches can connect and therefore execute commands. Concurrent connection
194
+ searches and activations can connect and therefore execute commands. Concurrent connection
149
195
  requests share the same resolution.
150
196
 
151
197
  The client trims stdout and rejects empty output, nonzero exits, output above
152
198
  64 KiB, and resolution taking more than 10 seconds (or a shorter `timeoutMs`).
153
- Session shutdown cancels pending commands. Cancelling an individual search stops
199
+ Session shutdown cancels pending commands. Cancelling an individual search or activation stops
154
200
  waiting but leaves shared connection work running for other callers. The client
155
201
  discards command stderr and does not include resolved secrets in errors, session
156
202
  records, or catalog caches. Commands themselves remain responsible for avoiding
@@ -200,6 +246,21 @@ invalid configuration leaves the previous setup intact. It closes existing
200
246
  connections, which reopen on demand, and deactivates tools from changed, removed,
201
247
  or disabled server definitions. Unchanged active tools remain available.
202
248
 
249
+ To toggle a server without editing JSON, use `/mcp disable <server>` or
250
+ `/mcp enable <server>`. The change persists in the trusted project's `.mcp.json`
251
+ if that file defines the server; otherwise, it persists in the global
252
+ `~/.pi/agent/mcp.json`. Untrusted project files are neither read nor changed.
253
+ The command reports which scope changed. It updates only the `disabled` option,
254
+ preserves other values (including secret references), and reformats the file as
255
+ indented JSON. Repeating a toggle that's already set leaves the file unchanged.
256
+
257
+ Both commands wait for active agent work to finish, then apply configuration as
258
+ `/mcp reload` does: connections close and reopen on demand, while unchanged active
259
+ tools from other servers remain available. Disabling removes the server from
260
+ search and deactivates its tools. Enabling does not connect, authenticate, or load
261
+ tools; ask the assistant to discover the capabilities you need. Other running Pi
262
+ sessions pick up the saved change when they reload their MCP configuration.
263
+
203
264
  Use `/mcp inspect <server>` to check the effective transport, protocol, filters,
204
265
  and connection status without connecting or running secret commands. Connection
205
266
  values—including commands, arguments, URLs, headers, and environment variables—
@@ -216,19 +277,21 @@ context. This command requires an interactive UI.
216
277
 
217
278
  Connections start on demand, never while the extension factory loads. A search
218
279
  without a cached catalog contacts configured servers, with at most four discoveries
219
- in flight. A server-scoped search only contacts that server. Failed servers are
220
- reported as unsearched, not mistaken for an empty catalog.
280
+ in flight. A server-scoped search only contacts that server. Activation discovers
281
+ only the servers named by its identifiers, with the same concurrency bound.
282
+ Failed servers are reported as unavailable, not mistaken for an empty catalog.
221
283
 
222
284
  Catalogs are cached privately under `~/.pi/agent/cache/pi-mcp-client/`, keyed by
223
285
  server configuration and working directory. Disk caches expire after 24 hours.
224
- They contain tool metadata, not configured credentials. Cached search needs no
225
- connection; invocation refreshes the live catalog before calling the tool.
286
+ They contain tool metadata, not configured credentials. Cached discovery and
287
+ activation need no connection; invocation refreshes the live catalog before
288
+ calling the tool.
226
289
  Connections remain open until shutdown or explicit reconnection.
227
290
 
228
291
  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
292
+ memory and disk catalogs. The next discovery or activation fetches the current
293
+ list, including new or removed tools. Notifications don't replace active tool
294
+ definitions: changed schemas require another `mcp_tools({activate: [...]})` before use. Disconnected, cache-only searches
232
295
  can't receive notifications and still use the 24-hour disk-cache expiry.
233
296
 
234
297
  ### OAuth
@@ -255,8 +318,9 @@ Only load configuration you trust. Server executables and secret commands run
255
318
  with your user permissions; trusted project configuration can replace global
256
319
  connections and settings.
257
320
 
258
- Server metadata is untrusted. Search activates tools but does not approve their
259
- side effects or provide per-call confirmation. Use tool filters and Pi permission
321
+ Server metadata is untrusted. Discovery never activates tools. Explicit
322
+ activation exposes schemas but does not approve tool side effects or provide
323
+ per-call confirmation. Use tool filters and Pi permission
260
324
  extensions for additional controls. Cancelling a call does not guarantee that the
261
325
  server rolled back its effects.
262
326
 
@@ -297,7 +361,7 @@ unavailable server is not an empty catalog.
297
361
  | `connection_failed` | Server executable, working directory, endpoint, network, and TLS configuration. |
298
362
  | `timeout` | Server responsiveness and the applicable request, secret-command, or OAuth time limit. |
299
363
  | `protocol_error` | Server compatibility and the `protocol` setting. |
300
- | `tool_changed` | Server filters and the current tool schema; search again. Reload Pi if connection configuration changed. |
364
+ | `tool_changed` | Server filters and the current tool schema; activate the exact identifier again. Reload Pi if connection configuration changed. |
301
365
  | `tool_error` | The server's tool result and inputs; verify the outcome before retrying. |
302
366
  | `oauth_failed` | Browser access to the callback and support for dynamically registered public clients. |
303
367
  | `callback_unavailable` | Another process using local port 19847. |
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",
@@ -2467,7 +2544,7 @@ function inspectServer(name, config, status) {
2467
2544
  // src/auth.ts
2468
2545
  init_config();
2469
2546
  init_diagnostics();
2470
- import { randomUUID } from "node:crypto";
2547
+ import { randomUUID as randomUUID2 } from "node:crypto";
2471
2548
  import { createServer } from "node:http";
2472
2549
  import {
2473
2550
  auth
@@ -2517,7 +2594,7 @@ var OAuthProvider = class {
2517
2594
  data;
2518
2595
  verifier;
2519
2596
  discovery;
2520
- expectedState = randomUUID();
2597
+ expectedState = randomUUID2();
2521
2598
  save() {
2522
2599
  this.store.write(JSON.stringify(this.data));
2523
2600
  }
@@ -2641,9 +2718,9 @@ async function authenticate(url, open, signal, store) {
2641
2718
 
2642
2719
  // src/runtime.ts
2643
2720
  init_config();
2644
- import { mkdir, readFile as readFile2, rename, rm, 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";
2645
2722
  import { join as join2 } from "node:path";
2646
- import { randomUUID as randomUUID2 } from "node:crypto";
2723
+ import { randomUUID as randomUUID3 } from "node:crypto";
2647
2724
  import {
2648
2725
  Client,
2649
2726
  StreamableHTTPClientTransport
@@ -2652,7 +2729,7 @@ import {
2652
2729
  StdioClientTransport,
2653
2730
  getDefaultEnvironment
2654
2731
  } from "@modelcontextprotocol/client/stdio";
2655
- import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
2732
+ import { withFileMutationQueue as withFileMutationQueue2 } from "@earendil-works/pi-coding-agent";
2656
2733
  init_secrets();
2657
2734
  init_diagnostics();
2658
2735
  var ToolContractError = class extends Error {
@@ -2825,9 +2902,9 @@ var McpRuntime = class {
2825
2902
  state.tools = void 0;
2826
2903
  state.warnings = void 0;
2827
2904
  const path = join2(this.cacheDir, `${identity}.json`);
2828
- state.invalidating = withFileMutationQueue(
2905
+ state.invalidating = withFileMutationQueue2(
2829
2906
  path,
2830
- () => rm(path, { force: true })
2907
+ () => rm2(path, { force: true })
2831
2908
  ).catch(() => {
2832
2909
  state.warnings = [
2833
2910
  `${name}: stale catalog cache could not be removed.`
@@ -2934,7 +3011,7 @@ var McpRuntime = class {
2934
3011
  return tools;
2935
3012
  }
2936
3013
  throw new ToolContractError(
2937
- "MCP tool catalog kept changing. Search again."
3014
+ "MCP tool catalog kept changing. Retry discovery or activation."
2938
3015
  );
2939
3016
  })().catch((error) => {
2940
3017
  state.error = this.failure(name, error);
@@ -2947,8 +3024,8 @@ var McpRuntime = class {
2947
3024
  return waitFor(state.listing, signal);
2948
3025
  }
2949
3026
  async discover(server, signal) {
2950
- if (server) this.definition(server);
2951
- 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();
2952
3029
  const result = {
2953
3030
  tools: [],
2954
3031
  unavailable: [],
@@ -2985,7 +3062,7 @@ var McpRuntime = class {
2985
3062
  const found = current.find((candidate) => candidate.name === tool.name);
2986
3063
  if (!found || found.schemaHash !== tool.schemaHash)
2987
3064
  throw new ToolContractError(
2988
- "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."
2989
3066
  );
2990
3067
  const client = await waitFor(this.client(tool.server), signal);
2991
3068
  try {
@@ -3074,7 +3151,7 @@ var McpRuntime = class {
3074
3151
  async readCache(name, identity) {
3075
3152
  try {
3076
3153
  const path = join2(this.cacheDir, `${identity}.json`);
3077
- const info = await stat(path);
3154
+ const info = await stat2(path);
3078
3155
  if (info.size > 4 * 1024 * 1024 || Date.now() - info.mtimeMs > 864e5)
3079
3156
  return;
3080
3157
  const data = JSON.parse(await readFile2(path, "utf8"));
@@ -3095,28 +3172,28 @@ var McpRuntime = class {
3095
3172
  if (Buffer.byteLength(text) > 4 * 1024 * 1024) return;
3096
3173
  await mkdir(this.cacheDir, { recursive: true, mode: 448 });
3097
3174
  const path = join2(this.cacheDir, `${identity}.json`);
3098
- await withFileMutationQueue(path, async () => {
3175
+ await withFileMutationQueue2(path, async () => {
3099
3176
  if (!isCurrent()) return;
3100
- const temp = `${path}.${randomUUID2()}.tmp`;
3101
- await writeFile(temp, text, { mode: 384 });
3102
- if (isCurrent()) await rename(temp, path);
3103
- else await rm(temp, { force: true });
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 });
3104
3181
  });
3105
3182
  }
3106
3183
  };
3107
3184
 
3108
3185
  // src/exposure.ts
3109
3186
  init_config();
3110
- var SEARCH_TOOL = "mcp_search";
3187
+ var TOOLS_TOOL = "mcp_tools";
3111
3188
  function restoredTools(entries) {
3112
3189
  const tools = /* @__PURE__ */ new Map();
3113
3190
  for (const entry of entries) {
3114
- 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)
3115
3192
  continue;
3116
3193
  const details = entry.message.details;
3117
3194
  if (!object(details) || details.mcpClient !== 1 || !Array.isArray(details.loaded))
3118
3195
  continue;
3119
- for (const raw of details.loaded.slice(0, 10)) {
3196
+ for (const raw of details.loaded.slice(0, MAX_SEARCH_LIMIT)) {
3120
3197
  if (!object(raw) || typeof raw.server !== "string" || typeof raw.identity !== "string" || typeof raw.name !== "string" || typeof raw.description !== "string" || !object(raw.inputSchema))
3121
3198
  continue;
3122
3199
  try {
@@ -3179,40 +3256,59 @@ var Exposure = class {
3179
3256
 
3180
3257
  // src/output.ts
3181
3258
  init_diagnostics();
3182
- import { mkdtemp, writeFile as writeFile2 } from "node:fs/promises";
3259
+ import { mkdtemp, writeFile as writeFile3 } from "node:fs/promises";
3183
3260
  import { tmpdir } from "node:os";
3184
3261
  import { join as join3 } from "node:path";
3185
3262
  import {
3186
3263
  truncateHead,
3187
- withFileMutationQueue as withFileMutationQueue2
3264
+ withFileMutationQueue as withFileMutationQueue3
3188
3265
  } from "@earendil-works/pi-coding-agent";
3189
3266
  function textResult(text, details) {
3190
3267
  return { content: [{ type: "text", text }], details };
3191
3268
  }
3192
3269
  async function convertResult(result, label) {
3193
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
+ };
3194
3279
  const images = [];
3195
3280
  let imageBytes = 0;
3196
3281
  let needsSpill = false;
3197
3282
  for (const part of result.content ?? []) {
3198
- if (part.type === "text") texts.push(part.text);
3283
+ if (part.type === "text") append(part.text);
3199
3284
  else if (part.type === "image" && ["image/png", "image/jpeg", "image/gif", "image/webp"].includes(part.mimeType) && imageBytes + part.data.length <= 8 * 1024 * 1024) {
3200
3285
  images.push({ type: "image", data: part.data, mimeType: part.mimeType });
3201
3286
  imageBytes += part.data.length;
3202
3287
  } else if (part.type === "resource" && "text" in part.resource)
3203
- texts.push(part.resource.text);
3204
- 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
+ });
3205
3294
  else {
3206
- 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
+ });
3207
3298
  needsSpill = true;
3208
3299
  }
3209
3300
  }
3210
3301
  if (result.structuredContent !== void 0)
3211
- texts.push(JSON.stringify(result.structuredContent, null, 2));
3302
+ append(JSON.stringify(result.structuredContent, null, 2), { structured: true });
3212
3303
  const truncated = truncateHead(texts.join("\n\n"));
3213
3304
  let text = truncated.content;
3214
3305
  const details = {
3215
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
+ })),
3216
3312
  failed: result.isError === true,
3217
3313
  ...result.isError ? {
3218
3314
  diagnostics: [
@@ -3226,9 +3322,9 @@ async function convertResult(result, label) {
3226
3322
  };
3227
3323
  if (truncated.truncated || needsSpill) {
3228
3324
  const path = join3(await mkdtemp(join3(tmpdir(), "pi-mcp-client-")), "result.json");
3229
- await withFileMutationQueue2(
3325
+ await withFileMutationQueue3(
3230
3326
  path,
3231
- () => writeFile2(path, JSON.stringify(result), { mode: 384 })
3327
+ () => writeFile3(path, JSON.stringify(result), { mode: 384 })
3232
3328
  );
3233
3329
  details.fullOutputPath = path;
3234
3330
  text += `
@@ -3245,7 +3341,102 @@ Full MCP result: ${path}`;
3245
3341
  import { keyText } from "@earendil-works/pi-coding-agent";
3246
3342
  import { Text, truncateToWidth as truncateToWidth2 } from "@earendil-works/pi-tui";
3247
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
3248
3437
  var states = {
3438
+ candidate: { glyph: "\u25CB", color: "dim" },
3439
+ active: { glyph: "\u25CF", color: "dim" },
3249
3440
  queued: { glyph: "\u25CF", color: "dim" },
3250
3441
  running: { glyph: "\u25B6\uFE0E", color: "muted" },
3251
3442
  done: { glyph: "\u2714\uFE0E", color: "success" },
@@ -3254,7 +3445,7 @@ var states = {
3254
3445
  };
3255
3446
  function renderCall(title, args, theme, expanded) {
3256
3447
  const values = object(args) ? args : {};
3257
- 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(" ");
3258
3449
  return {
3259
3450
  render(width) {
3260
3451
  if (width <= 0) return [];
@@ -3273,23 +3464,30 @@ function renderCall(title, args, theme, expanded) {
3273
3464
  }
3274
3465
  };
3275
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
+ }
3276
3473
  function renderResult(result, options, theme, isError) {
3277
3474
  const details = object(result.details) && result.details.mcpClient === 1 ? result.details : void 0;
3278
- const text = plain(
3279
- result.content.filter((part) => part.type === "text").map((part) => part.text ?? "").join("\n")
3280
- );
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;
3281
3478
  const rows = details?.rows ?? [
3282
3479
  {
3283
- label: line(text) || "Working\u2026",
3480
+ label: validation ? "Invalid tool arguments" : isError ? "Tool failed" : line(text) || "Working\u2026",
3284
3481
  state: isError ? "failed" : options.isPartial ? "running" : "done"
3285
3482
  }
3286
3483
  ];
3484
+ let formattedOutput;
3287
3485
  return {
3288
3486
  render(width) {
3289
3487
  if (width <= 0) return [];
3290
3488
  const lines = rows.flatMap((row) => {
3291
3489
  const status = states[row.state] ?? states.failed;
3292
- const value = theme.fg(status.color, status.glyph) + " " + theme.fg("accent", line(row.label));
3490
+ const value = theme.fg(status.color, status.glyph) + " " + theme.fg("accent", line(row.label)) + (row.inlineDescription ? theme.fg("dim", ` ${line(row.inlineDescription)}`) : "");
3293
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)];
3294
3492
  if (options.expanded && row.description)
3295
3493
  rendered.push(truncateToWidth2(
@@ -3302,7 +3500,11 @@ function renderResult(result, options, theme, isError) {
3302
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)));
3303
3501
  if (options.expanded && !options.isPartial && text && !details?.searchNotes)
3304
3502
  lines.push(
3305
- ...new Text(text, 0, 0).render(width).map((x) => truncateToWidth2(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))
3306
3508
  );
3307
3509
  if (!options.expanded && details?.fullOutputPath)
3308
3510
  lines.push(
@@ -3314,6 +3516,7 @@ function renderResult(result, options, theme, isError) {
3314
3516
  return lines;
3315
3517
  },
3316
3518
  invalidate() {
3519
+ formattedOutput = void 0;
3317
3520
  }
3318
3521
  };
3319
3522
  }
@@ -3330,7 +3533,10 @@ function errorResult(error, context) {
3330
3533
  failed: true,
3331
3534
  diagnostics: [value],
3332
3535
  rows: [
3333
- { label: message, state: value.code === "cancelled" ? "cancelled" : "failed" }
3536
+ {
3537
+ label: value.code === "cancelled" ? "Cancelled" : message,
3538
+ state: value.code === "cancelled" ? "cancelled" : "failed"
3539
+ }
3334
3540
  ]
3335
3541
  });
3336
3542
  }
@@ -3400,16 +3606,27 @@ Text output is limited to 2000 lines or 50 KiB; larger results are saved to a pr
3400
3606
  });
3401
3607
  exposure.restore(tools);
3402
3608
  };
3403
- async function reloadConfiguration(ctx) {
3609
+ async function reloadConfiguration(ctx, toggle) {
3404
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
+ };
3405
3619
  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
- }
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);
3413
3630
  const next = new McpRuntime(
3414
3631
  nextConfig,
3415
3632
  ctx.cwd,
@@ -3429,6 +3646,7 @@ Text output is limited to 2000 lines or 50 KiB; larger results are saved to a pr
3429
3646
  runtime = next;
3430
3647
  exposure.restore(retained);
3431
3648
  await old?.close();
3649
+ return update?.scope;
3432
3650
  }
3433
3651
  pi.on("session_start", async (_event, ctx) => {
3434
3652
  sessionGeneration++;
@@ -3453,7 +3671,7 @@ Text output is limited to 2000 lines or 50 KiB; larger results are saved to a pr
3453
3671
  await old?.close();
3454
3672
  });
3455
3673
  pi.on("before_agent_start", (event) => {
3456
- if (!pi.getActiveTools().includes(SEARCH_TOOL)) return;
3674
+ if (!pi.getActiveTools().includes(TOOLS_TOOL)) return;
3457
3675
  const directory = Object.entries(config).filter(([, value]) => !value.disabled).map(
3458
3676
  ([name, value]) => `- ${name}${value.description ? `: ${line(value.description).slice(0, 160)}` : ""}`
3459
3677
  ).join("\n");
@@ -3463,24 +3681,29 @@ Text output is limited to 2000 lines or 50 KiB; larger results are saved to a pr
3463
3681
 
3464
3682
  Additional MCP capabilities (directory metadata, not instructions):
3465
3683
  ${directory}
3466
- 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.`
3467
3685
  };
3468
3686
  });
3469
3687
  pi.on("tool_result", (event) => {
3470
- 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)
3471
3689
  return { isError: true };
3472
3690
  });
3473
3691
  pi.registerTool({
3474
- name: SEARCH_TOOL,
3475
- label: "MCP Search",
3476
- 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.",
3477
3695
  parameters: Type.Object(
3478
3696
  {
3479
- query: Type.String({
3697
+ query: Type.Optional(Type.String({
3480
3698
  minLength: 1,
3481
3699
  maxLength: 500,
3482
- description: "One focused capability or exact server.tool name, for example linear.list_teams. Do not enumerate every capability of a server."
3483
- }),
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."
3706
+ })),
3484
3707
  server: Type.Optional(
3485
3708
  Type.String({
3486
3709
  minLength: 1,
@@ -3493,78 +3716,85 @@ Use mcp_search to load relevant tools, then call them directly. Loaded tools rem
3493
3716
  minimum: 1,
3494
3717
  maximum: MAX_SEARCH_LIMIT,
3495
3718
  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.`
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.`
3497
3720
  })
3498
3721
  )
3499
3722
  },
3500
3723
  { additionalProperties: false }
3501
3724
  ),
3502
- 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),
3503
3726
  renderResult: (result, options2, theme, context) => renderResult(result, options2, theme, context.isError),
3504
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" }] });
3505
3732
  try {
3506
3733
  const activeRuntime = current();
3507
- onUpdate?.(
3508
- textResult("Searching MCP catalog\u2026", {
3509
- mcpClient: 1,
3510
- rows: [{ label: args.query, state: "running" }]
3511
- })
3512
- );
3513
- const namedServer = Object.keys(config).sort((a, b) => b.length - a.length).find(
3514
- (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))
3515
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
+ }));
3516
3743
  const discovery = await activeRuntime.discover(
3517
- args.server ?? namedServer,
3744
+ hasActivate ? identifiers.flatMap(serversFor) : args.server ?? serversFor(args.query)[0],
3518
3745
  signal ?? ctx.signal
3519
3746
  );
3520
3747
  (signal ?? ctx.signal)?.throwIfAborted();
3521
3748
  if (runtime !== activeRuntime)
3522
- throw new Error("MCP session changed during search.");
3523
- const matches2 = searchTools(discovery.tools, args.query, args.server, args.limit);
3524
- const { loaded, added, rejected } = exposure.load(matches2);
3525
- const messages2 = loaded.map(
3526
- (tool) => `${added.includes(tool.nativeName) ? "Loaded" : "Already loaded"}: ${tool.nativeName} \u2014 ${line(tool.description).slice(0, 180)}`
3527
- );
3528
- if (!messages2.length)
3529
- messages2.push(
3530
- "No callable matches found. Try a more specific capability, server, or exact tool name."
3531
- );
3532
- if (loaded.length)
3533
- messages2.push(
3534
- "Call the loaded tools directly. Their full schemas are now available."
3535
- );
3536
- messages2.push(
3537
- ...discovery.unavailable.map((message) => `Not searched: ${message}`),
3538
- ...discovery.warnings
3539
- );
3540
- if (rejected.length)
3541
- messages2.push(
3542
- `Not loaded (name collision or Pi tool restriction): ${rejected.join(", ")}`
3543
- );
3749
+ throw new Error("MCP session changed during search or activation.");
3544
3750
  const details = {
3545
3751
  mcpClient: 1,
3546
- loaded,
3547
3752
  searchNotes: discovery.warnings,
3548
3753
  diagnostics: discovery.diagnostics,
3549
- failed: !loaded.length && discovery.diagnostics.length > 0,
3550
- rows: [
3551
- ...loaded.map((tool) => ({
3552
- label: `${tool.server}.${tool.name} \xB7 ${added.includes(tool.nativeName) ? "loaded" : "already loaded"}`,
3553
- description: tool.description,
3554
- state: "done"
3555
- })),
3556
- ...discovery.unavailable.map((label) => ({
3557
- label,
3558
- state: "failed"
3559
- })),
3560
- ...rejected.map((name) => ({
3561
- label: `${name} \xB7 not loaded`,
3562
- state: "failed"
3563
- }))
3564
- ]
3754
+ rows: []
3565
3755
  };
3566
- if (!details.rows.length)
3567
- 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" });
3568
3798
  return textResult(messages2.join("\n"), details);
3569
3799
  } catch (error) {
3570
3800
  return errorResult(error, {
@@ -3577,9 +3807,9 @@ Use mcp_search to load relevant tools, then call them directly. Loaded tools rem
3577
3807
  }
3578
3808
  });
3579
3809
  pi.registerCommand("mcp", {
3580
- description: "Manage MCP servers: list, status, reload, inspect|tools|auth|reconnect|refresh <server>",
3810
+ description: "Manage MCP servers: list, status, reload, enable|disable|inspect|tools|auth|reconnect|refresh <server>",
3581
3811
  getArgumentCompletions(prefix) {
3582
- const serverActions = ["inspect", "tools", "auth", "reconnect", "refresh"];
3812
+ const serverActions = ["enable", "disable", "inspect", "tools", "auth", "reconnect", "refresh"];
3583
3813
  const input = prefix.trimStart();
3584
3814
  const match = /^(\S+)\s+(.*)$/s.exec(input);
3585
3815
  if (!match) {
@@ -3588,13 +3818,16 @@ Use mcp_search to load relevant tools, then call them directly. Loaded tools rem
3588
3818
  const [, action, partialServer] = match;
3589
3819
  if (!serverActions.includes(action) || /\s/.test(partialServer)) return [];
3590
3820
  return Object.keys(config).filter(
3591
- (name) => name.startsWith(partialServer) && (action === "inspect" || !config[name].disabled)
3821
+ (name) => name.startsWith(partialServer) && (action === "inspect" || (action === "enable" ? config[name].disabled : !config[name].disabled))
3592
3822
  ).sort().map((name) => ({ value: `${action} ${name}`, label: name }));
3593
3823
  },
3594
3824
  async handler(args, ctx) {
3825
+ const generation = sessionGeneration;
3595
3826
  await ctx.waitForIdle();
3596
3827
  const [action = "status", server, ...extra] = args.trim().split(/\s+/).filter(Boolean);
3597
3828
  try {
3829
+ if (generation !== sessionGeneration)
3830
+ throw new CommandUsageError("The Pi session changed while waiting for idle.");
3598
3831
  if (action === "reload" && !server) {
3599
3832
  await reloadConfiguration(ctx);
3600
3833
  if (ctx.hasUI)
@@ -3604,6 +3837,15 @@ Use mcp_search to load relevant tools, then call them directly. Loaded tools rem
3604
3837
  );
3605
3838
  return;
3606
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
+ }
3607
3849
  if (action === "inspect" && server && !extra.length && Object.hasOwn(config, server)) {
3608
3850
  if (ctx.hasUI)
3609
3851
  ctx.ui.notify(
@@ -3624,7 +3866,7 @@ Use mcp_search to load relevant tools, then call them directly. Loaded tools rem
3624
3866
  }
3625
3867
  if (!server || extra.length || !Object.hasOwn(config, server) || config[server].disabled)
3626
3868
  throw new CommandUsageError(
3627
- "Usage: /mcp list|status|reload or /mcp inspect|tools|auth|reconnect|refresh <server>. Only inspect accepts a disabled server."
3869
+ "Usage: /mcp list|status|reload or /mcp enable|disable|inspect|tools|auth|reconnect|refresh <server>. Disabled servers accept enable, disable, and inspect."
3628
3870
  );
3629
3871
  if (action === "tools") {
3630
3872
  if (!ctx.hasUI)
@@ -3699,7 +3941,7 @@ ${target}`, "info");
3699
3941
  else if (action === "refresh") await current().catalog(server, ctx.signal, true);
3700
3942
  else
3701
3943
  throw new CommandUsageError(
3702
- "Unknown MCP command. Use /mcp list|status|reload or /mcp inspect|tools|auth|reconnect|refresh <server>."
3944
+ "Unknown MCP command. Use /mcp list|status|reload or /mcp enable|disable|inspect|tools|auth|reconnect|refresh <server>."
3703
3945
  );
3704
3946
  if (ctx.hasUI)
3705
3947
  ctx.ui.notify(
@@ -3710,7 +3952,7 @@ ${target}`, "info");
3710
3952
  const message = error instanceof CommandUsageError ? error.message : formatDiagnostic(
3711
3953
  diagnose(error, {
3712
3954
  server,
3713
- operation: action === "reload" || action === "inspect" ? "configuration" : action === "tools" ? "search" : 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",
3714
3956
  oauth: config[server]?.oauth,
3715
3957
  signal: ctx.signal
3716
3958
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mcp-client",
3
- "version": "0.2.0",
3
+ "version": "0.3.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",