pi-mcp-client 0.2.0 → 0.3.1

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 +380 -126
  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,9 @@ 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
+ server_unknown: "Choose a configured MCP server from the capability directory or run /mcp to list servers. Omit server in mcp_tools to search all enabled servers.",
34
+ server_disabled: `Run /mcp enable ${target}, or omit server in mcp_tools to search all enabled servers.`,
35
+ 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
36
  tool_error: "Review the server's tool result and inputs. Verify the outcome before retrying.",
35
37
  oauth_failed: `Check OAuth support and browser access to the local callback, then run /mcp auth ${target}.`,
36
38
  callback_unavailable: "Free local port 19847, then retry authentication.",
@@ -137,6 +139,8 @@ var init_diagnostics = __esm({
137
139
  cancelled: "The operation was cancelled.",
138
140
  connection_failed: "The server connection failed.",
139
141
  protocol_error: "The server response or protocol is not supported.",
142
+ server_unknown: "The MCP server is not configured.",
143
+ server_disabled: "The MCP server is disabled.",
140
144
  tool_changed: "The tool is unavailable or its configuration or schema changed.",
141
145
  tool_error: "The tool reported an error.",
142
146
  oauth_failed: "OAuth authentication did not complete.",
@@ -285,12 +289,14 @@ __export(config_exports, {
285
289
  matches: () => matches,
286
290
  object: () => object,
287
291
  parseConfig: () => parseConfig,
288
- resolveServer: () => resolveServer
292
+ resolveServer: () => resolveServer,
293
+ setServerDisabled: () => setServerDisabled
289
294
  });
290
- import { readFile } from "node:fs/promises";
295
+ import { chmod, readFile, realpath, stat, writeFile, rename, rm } from "node:fs/promises";
296
+ import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
291
297
  import { resolve, join } from "node:path";
292
298
  import { homedir } from "node:os";
293
- import { createHash } from "node:crypto";
299
+ import { createHash, randomUUID } from "node:crypto";
294
300
  function object(value) {
295
301
  return value !== null && typeof value === "object" && !Array.isArray(value);
296
302
  }
@@ -386,6 +392,46 @@ async function loadConfig(agentDir, cwd, trusted) {
386
392
  }
387
393
  return config;
388
394
  }
395
+ async function setServerDisabled(agentDir, cwd, trusted, server, disabled, validate) {
396
+ const paths = [join(agentDir, "mcp.json"), ...trusted ? [join(cwd, ".mcp.json")] : []];
397
+ const locks = [...new Set(await Promise.all(
398
+ paths.map((path) => realpath(path).catch(() => resolve(path)))
399
+ ))].sort();
400
+ const locked = async (index) => {
401
+ if (index < locks.length)
402
+ return withFileMutationQueue(locks[index], () => locked(index + 1));
403
+ const documents = await Promise.all(paths.map(readJson));
404
+ const config = /* @__PURE__ */ Object.create(null);
405
+ let source = -1;
406
+ for (const [index2, document2] of documents.entries()) {
407
+ if (document2 === void 0) continue;
408
+ const parsed = parseConfig(document2, paths[index2]);
409
+ Object.assign(config, parsed);
410
+ if (Object.hasOwn(parsed, server)) source = index2;
411
+ }
412
+ if (source < 0) throw new Error("Server is no longer configured. Run /mcp reload.");
413
+ const changed = Boolean(config[server].disabled) !== disabled;
414
+ if (changed) config[server].disabled = disabled;
415
+ validate(config);
416
+ const document = documents[source];
417
+ if (changed) {
418
+ document.mcpServers[server].disabled = disabled;
419
+ const target = await realpath(paths[source]);
420
+ const mode = (await stat(target)).mode & 511;
421
+ const temporary = `${target}.${randomUUID()}.tmp`;
422
+ try {
423
+ await writeFile(temporary, JSON.stringify(document, null, 2) + "\n", { mode: 384, flag: "wx" });
424
+ await chmod(temporary, mode);
425
+ validate(config);
426
+ await rename(temporary, target);
427
+ } finally {
428
+ await rm(temporary, { force: true });
429
+ }
430
+ }
431
+ return { config, scope: source === 0 ? "global" : "project" };
432
+ };
433
+ return locked(0);
434
+ }
389
435
  function interpolate(value, env = process.env) {
390
436
  return value.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_, name) => {
391
437
  const found = env[name];
@@ -547,9 +593,9 @@ var recurse = (node, query, maxDistance, results, matrix, m, n, prefix) => {
547
593
  const offset = m * n;
548
594
  key: for (const key of node.keys()) {
549
595
  if (key === LEAF) {
550
- const distance = matrix[offset - 1];
551
- if (distance <= maxDistance) {
552
- results.set(prefix, [node.get(key), distance]);
596
+ const distance2 = matrix[offset - 1];
597
+ if (distance2 <= maxDistance) {
598
+ results.set(prefix, [node.get(key), distance2]);
553
599
  }
554
600
  } else {
555
601
  let i = m;
@@ -1903,22 +1949,22 @@ var MiniSearch = class _MiniSearch {
1903
1949
  }
1904
1950
  if (prefixMatches) {
1905
1951
  for (const [term, data2] of prefixMatches) {
1906
- const distance = term.length - query.term.length;
1907
- if (!distance) {
1952
+ const distance2 = term.length - query.term.length;
1953
+ if (!distance2) {
1908
1954
  continue;
1909
1955
  }
1910
1956
  fuzzyMatches === null || fuzzyMatches === void 0 ? void 0 : fuzzyMatches.delete(term);
1911
- const weight = prefixWeight * term.length / (term.length + 0.3 * distance);
1957
+ const weight = prefixWeight * term.length / (term.length + 0.3 * distance2);
1912
1958
  this.termResults(query.term, term, weight, query.termBoost, data2, boosts, boostDocument, bm25params, results);
1913
1959
  }
1914
1960
  }
1915
1961
  if (fuzzyMatches) {
1916
1962
  for (const term of fuzzyMatches.keys()) {
1917
- const [data2, distance] = fuzzyMatches.get(term);
1918
- if (!distance) {
1963
+ const [data2, distance2] = fuzzyMatches.get(term);
1964
+ if (!distance2) {
1919
1965
  continue;
1920
1966
  }
1921
- const weight = fuzzyWeight * term.length / (term.length + distance);
1967
+ const weight = fuzzyWeight * term.length / (term.length + distance2);
1922
1968
  this.termResults(query.term, term, weight, query.termBoost, data2, boosts, boostDocument, bm25params, results);
1923
1969
  }
1924
1970
  }
@@ -2310,6 +2356,41 @@ function prepareTool(server, identity, tool) {
2310
2356
  schemaHash: fingerprint(inputSchema)
2311
2357
  };
2312
2358
  }
2359
+ function summarize(tool, includeIdentifier = true) {
2360
+ const schema = tool.inputSchema;
2361
+ const required = Array.isArray(schema.required) ? schema.required.filter((name) => typeof name === "string") : [];
2362
+ const prefix = includeIdentifier ? `${tool.server}.${tool.name} \u2014 ` : "";
2363
+ return `${prefix}${line(tool.description).slice(0, 180)} (required: ${required.map(line).join(", ") || "none"})`;
2364
+ }
2365
+ function distance(a, b) {
2366
+ const row = Array.from({ length: b.length + 1 }, (_, i) => i);
2367
+ for (let i = 0; i < a.length; i++) {
2368
+ let diagonal = row[0];
2369
+ row[0] = i + 1;
2370
+ for (let j = 0; j < b.length; j++) {
2371
+ const previous = row[j + 1];
2372
+ row[j + 1] = Math.min(row[j] + 1, previous + 1, diagonal + Number(a[i] !== b[j]));
2373
+ diagonal = previous;
2374
+ }
2375
+ }
2376
+ return row[b.length];
2377
+ }
2378
+ function resolveTools(tools, identifiers) {
2379
+ return [...new Set(identifiers)].map((identifier) => {
2380
+ const matches2 = tools.filter(
2381
+ (tool2) => identifier === tool2.nativeName || identifier === `${tool2.server}.${tool2.name}`
2382
+ );
2383
+ const tool = matches2.length === 1 ? matches2[0] : void 0;
2384
+ const suggestions = tool ? [] : tools.map((candidate) => ({
2385
+ name: `${candidate.server}.${candidate.name}`,
2386
+ score: Math.min(
2387
+ distance(identifier, `${candidate.server}.${candidate.name}`),
2388
+ distance(identifier, candidate.nativeName)
2389
+ )
2390
+ })).sort((a, b) => a.score - b.score || a.name.localeCompare(b.name)).slice(0, 3).map(({ name }) => name);
2391
+ return { identifier, tool, suggestions };
2392
+ });
2393
+ }
2313
2394
  var STOP = /* @__PURE__ */ new Set([
2314
2395
  "a",
2315
2396
  "an",
@@ -2467,7 +2548,7 @@ function inspectServer(name, config, status) {
2467
2548
  // src/auth.ts
2468
2549
  init_config();
2469
2550
  init_diagnostics();
2470
- import { randomUUID } from "node:crypto";
2551
+ import { randomUUID as randomUUID2 } from "node:crypto";
2471
2552
  import { createServer } from "node:http";
2472
2553
  import {
2473
2554
  auth
@@ -2517,7 +2598,7 @@ var OAuthProvider = class {
2517
2598
  data;
2518
2599
  verifier;
2519
2600
  discovery;
2520
- expectedState = randomUUID();
2601
+ expectedState = randomUUID2();
2521
2602
  save() {
2522
2603
  this.store.write(JSON.stringify(this.data));
2523
2604
  }
@@ -2641,9 +2722,9 @@ async function authenticate(url, open, signal, store) {
2641
2722
 
2642
2723
  // src/runtime.ts
2643
2724
  init_config();
2644
- import { mkdir, readFile as readFile2, rename, rm, stat, writeFile } from "node:fs/promises";
2725
+ import { mkdir, readFile as readFile2, rename as rename2, rm as rm2, stat as stat2, writeFile as writeFile2 } from "node:fs/promises";
2645
2726
  import { join as join2 } from "node:path";
2646
- import { randomUUID as randomUUID2 } from "node:crypto";
2727
+ import { randomUUID as randomUUID3 } from "node:crypto";
2647
2728
  import {
2648
2729
  Client,
2649
2730
  StreamableHTTPClientTransport
@@ -2652,7 +2733,7 @@ import {
2652
2733
  StdioClientTransport,
2653
2734
  getDefaultEnvironment
2654
2735
  } from "@modelcontextprotocol/client/stdio";
2655
- import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
2736
+ import { withFileMutationQueue as withFileMutationQueue2 } from "@earendil-works/pi-coding-agent";
2656
2737
  init_secrets();
2657
2738
  init_diagnostics();
2658
2739
  var ToolContractError = class extends Error {
@@ -2825,9 +2906,9 @@ var McpRuntime = class {
2825
2906
  state.tools = void 0;
2826
2907
  state.warnings = void 0;
2827
2908
  const path = join2(this.cacheDir, `${identity}.json`);
2828
- state.invalidating = withFileMutationQueue(
2909
+ state.invalidating = withFileMutationQueue2(
2829
2910
  path,
2830
- () => rm(path, { force: true })
2911
+ () => rm2(path, { force: true })
2831
2912
  ).catch(() => {
2832
2913
  state.warnings = [
2833
2914
  `${name}: stale catalog cache could not be removed.`
@@ -2934,7 +3015,7 @@ var McpRuntime = class {
2934
3015
  return tools;
2935
3016
  }
2936
3017
  throw new ToolContractError(
2937
- "MCP tool catalog kept changing. Search again."
3018
+ "MCP tool catalog kept changing. Retry discovery or activation."
2938
3019
  );
2939
3020
  })().catch((error) => {
2940
3021
  state.error = this.failure(name, error);
@@ -2947,8 +3028,15 @@ var McpRuntime = class {
2947
3028
  return waitFor(state.listing, signal);
2948
3029
  }
2949
3030
  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();
3031
+ if (typeof server === "string") {
3032
+ const config = Object.hasOwn(this.config, server) ? this.config[server] : void 0;
3033
+ if (!config || config.disabled)
3034
+ throw new DiagnosticError(diagnostic(
3035
+ config ? "server_disabled" : "server_unknown",
3036
+ { server, operation: "search" }
3037
+ ));
3038
+ }
3039
+ const names = Array.isArray(server) ? [...new Set(server)] : server ? [server] : Object.keys(this.config).filter((name) => !this.config[name].disabled).sort();
2952
3040
  const result = {
2953
3041
  tools: [],
2954
3042
  unavailable: [],
@@ -2985,7 +3073,7 @@ var McpRuntime = class {
2985
3073
  const found = current.find((candidate) => candidate.name === tool.name);
2986
3074
  if (!found || found.schemaHash !== tool.schemaHash)
2987
3075
  throw new ToolContractError(
2988
- "MCP tool was removed or its schema changed. Run mcp_search to load its current definition."
3076
+ "MCP tool was removed or its schema changed. Use mcp_tools with activate and its exact identifier to activate its current definition."
2989
3077
  );
2990
3078
  const client = await waitFor(this.client(tool.server), signal);
2991
3079
  try {
@@ -3074,7 +3162,7 @@ var McpRuntime = class {
3074
3162
  async readCache(name, identity) {
3075
3163
  try {
3076
3164
  const path = join2(this.cacheDir, `${identity}.json`);
3077
- const info = await stat(path);
3165
+ const info = await stat2(path);
3078
3166
  if (info.size > 4 * 1024 * 1024 || Date.now() - info.mtimeMs > 864e5)
3079
3167
  return;
3080
3168
  const data = JSON.parse(await readFile2(path, "utf8"));
@@ -3095,28 +3183,28 @@ var McpRuntime = class {
3095
3183
  if (Buffer.byteLength(text) > 4 * 1024 * 1024) return;
3096
3184
  await mkdir(this.cacheDir, { recursive: true, mode: 448 });
3097
3185
  const path = join2(this.cacheDir, `${identity}.json`);
3098
- await withFileMutationQueue(path, async () => {
3186
+ await withFileMutationQueue2(path, async () => {
3099
3187
  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 });
3188
+ const temp = `${path}.${randomUUID3()}.tmp`;
3189
+ await writeFile2(temp, text, { mode: 384 });
3190
+ if (isCurrent()) await rename2(temp, path);
3191
+ else await rm2(temp, { force: true });
3104
3192
  });
3105
3193
  }
3106
3194
  };
3107
3195
 
3108
3196
  // src/exposure.ts
3109
3197
  init_config();
3110
- var SEARCH_TOOL = "mcp_search";
3198
+ var TOOLS_TOOL = "mcp_tools";
3111
3199
  function restoredTools(entries) {
3112
3200
  const tools = /* @__PURE__ */ new Map();
3113
3201
  for (const entry of entries) {
3114
- if (entry.type !== "message" || entry.message.role !== "toolResult" || entry.message.toolName !== SEARCH_TOOL || entry.message.isError)
3202
+ if (entry.type !== "message" || entry.message.role !== "toolResult" || entry.message.toolName !== TOOLS_TOOL || entry.message.isError)
3115
3203
  continue;
3116
3204
  const details = entry.message.details;
3117
3205
  if (!object(details) || details.mcpClient !== 1 || !Array.isArray(details.loaded))
3118
3206
  continue;
3119
- for (const raw of details.loaded.slice(0, 10)) {
3207
+ for (const raw of details.loaded.slice(0, MAX_SEARCH_LIMIT)) {
3120
3208
  if (!object(raw) || typeof raw.server !== "string" || typeof raw.identity !== "string" || typeof raw.name !== "string" || typeof raw.description !== "string" || !object(raw.inputSchema))
3121
3209
  continue;
3122
3210
  try {
@@ -3179,40 +3267,59 @@ var Exposure = class {
3179
3267
 
3180
3268
  // src/output.ts
3181
3269
  init_diagnostics();
3182
- import { mkdtemp, writeFile as writeFile2 } from "node:fs/promises";
3270
+ import { mkdtemp, writeFile as writeFile3 } from "node:fs/promises";
3183
3271
  import { tmpdir } from "node:os";
3184
3272
  import { join as join3 } from "node:path";
3185
3273
  import {
3186
3274
  truncateHead,
3187
- withFileMutationQueue as withFileMutationQueue2
3275
+ withFileMutationQueue as withFileMutationQueue3
3188
3276
  } from "@earendil-works/pi-coding-agent";
3189
3277
  function textResult(text, details) {
3190
3278
  return { content: [{ type: "text", text }], details };
3191
3279
  }
3192
3280
  async function convertResult(result, label) {
3193
3281
  const texts = [];
3282
+ const blocks = [];
3283
+ let offset = 0;
3284
+ const append = (text2, metadata = {}) => {
3285
+ const start = offset + (texts.length ? 2 : 0);
3286
+ offset = start + text2.length;
3287
+ texts.push(text2);
3288
+ blocks.push({ ...metadata, start, end: offset });
3289
+ };
3194
3290
  const images = [];
3195
3291
  let imageBytes = 0;
3196
3292
  let needsSpill = false;
3197
3293
  for (const part of result.content ?? []) {
3198
- if (part.type === "text") texts.push(part.text);
3294
+ if (part.type === "text") append(part.text);
3199
3295
  else if (part.type === "image" && ["image/png", "image/jpeg", "image/gif", "image/webp"].includes(part.mimeType) && imageBytes + part.data.length <= 8 * 1024 * 1024) {
3200
3296
  images.push({ type: "image", data: part.data, mimeType: part.mimeType });
3201
3297
  imageBytes += part.data.length;
3202
3298
  } 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}`);
3299
+ append(part.resource.text, { mimeType: part.resource.mimeType });
3300
+ else if (part.type === "resource_link")
3301
+ append(`${part.name}: ${part.uri}`, {
3302
+ mimeType: part.mimeType,
3303
+ resourceLink: true
3304
+ });
3205
3305
  else {
3206
- texts.push(`[${part.type} content saved in full result file]`);
3306
+ append(`[${part.type} content saved in full result file]`, {
3307
+ mimeType: "text/plain"
3308
+ });
3207
3309
  needsSpill = true;
3208
3310
  }
3209
3311
  }
3210
3312
  if (result.structuredContent !== void 0)
3211
- texts.push(JSON.stringify(result.structuredContent, null, 2));
3313
+ append(JSON.stringify(result.structuredContent, null, 2), { structured: true });
3212
3314
  const truncated = truncateHead(texts.join("\n\n"));
3213
3315
  let text = truncated.content;
3214
3316
  const details = {
3215
3317
  mcpClient: 1,
3318
+ displayBlocks: blocks.filter((block) => block.start < text.length).map((block) => ({
3319
+ ...block,
3320
+ end: Math.min(block.end, text.length),
3321
+ ...block.end > text.length ? { truncated: true } : {}
3322
+ })),
3216
3323
  failed: result.isError === true,
3217
3324
  ...result.isError ? {
3218
3325
  diagnostics: [
@@ -3226,9 +3333,9 @@ async function convertResult(result, label) {
3226
3333
  };
3227
3334
  if (truncated.truncated || needsSpill) {
3228
3335
  const path = join3(await mkdtemp(join3(tmpdir(), "pi-mcp-client-")), "result.json");
3229
- await withFileMutationQueue2(
3336
+ await withFileMutationQueue3(
3230
3337
  path,
3231
- () => writeFile2(path, JSON.stringify(result), { mode: 384 })
3338
+ () => writeFile3(path, JSON.stringify(result), { mode: 384 })
3232
3339
  );
3233
3340
  details.fullOutputPath = path;
3234
3341
  text += `
@@ -3245,7 +3352,102 @@ Full MCP result: ${path}`;
3245
3352
  import { keyText } from "@earendil-works/pi-coding-agent";
3246
3353
  import { Text, truncateToWidth as truncateToWidth2 } from "@earendil-works/pi-tui";
3247
3354
  init_config();
3355
+
3356
+ // src/format.ts
3357
+ import {
3358
+ DEFAULT_MAX_BYTES,
3359
+ DEFAULT_MAX_LINES
3360
+ } from "@earendil-works/pi-coding-agent";
3361
+ function formatBlock(text, block, theme) {
3362
+ const fallback = plain(text);
3363
+ if (block.truncated || block.resourceLink || Buffer.byteLength(text) > DEFAULT_MAX_BYTES)
3364
+ return fallback;
3365
+ const mime = block.mimeType?.split(";", 1)[0]?.trim().toLowerCase();
3366
+ const explicitJson = mime === "application/json" || mime === "text/json" || /^application\/[^\s/;]+\+json$/.test(mime ?? "");
3367
+ if (mime && !explicitJson) return fallback;
3368
+ if (!explicitJson && !block.structured && !/^[\s]*[\[{]/.test(text))
3369
+ return fallback;
3370
+ try {
3371
+ JSON.parse(text);
3372
+ } catch {
3373
+ return fallback;
3374
+ }
3375
+ const tokens2 = text.match(
3376
+ /"(?:[^"\\]|\\.)*"|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?|true|false|null|[{}\[\],:]/g
3377
+ ) ?? [];
3378
+ const pieces = [];
3379
+ let depth = 0;
3380
+ let bytes = 0;
3381
+ let lines = 1;
3382
+ const add = (value, color) => {
3383
+ bytes += Buffer.byteLength(value);
3384
+ lines += value.split("\n").length - 1;
3385
+ pieces.push({ text: value, color });
3386
+ };
3387
+ const newline = () => add("\n" + " ".repeat(depth));
3388
+ for (let i = 0; i < tokens2.length; i++) {
3389
+ const token = tokens2[i];
3390
+ if (token === "{" || token === "[") {
3391
+ add(token, "syntaxPunctuation");
3392
+ depth++;
3393
+ if (depth > 100) return fallback;
3394
+ if (tokens2[i + 1] !== "}" && tokens2[i + 1] !== "]") newline();
3395
+ } else if (token === "}" || token === "]") {
3396
+ depth--;
3397
+ if (tokens2[i - 1] !== "{" && tokens2[i - 1] !== "[") newline();
3398
+ add(token, "syntaxPunctuation");
3399
+ } else if (token === ",") {
3400
+ add(token, "syntaxPunctuation");
3401
+ newline();
3402
+ } else if (token === ":") {
3403
+ add(":", "syntaxPunctuation");
3404
+ add(" ");
3405
+ } else {
3406
+ add(
3407
+ token,
3408
+ token.startsWith('"') ? tokens2[i + 1] === ":" ? "syntaxVariable" : "syntaxString" : /^(true|false|null)$/.test(token) ? "syntaxKeyword" : "syntaxNumber"
3409
+ );
3410
+ }
3411
+ if (bytes > DEFAULT_MAX_BYTES || lines > DEFAULT_MAX_LINES) return fallback;
3412
+ }
3413
+ return pieces.map(
3414
+ ({ text: value, color }) => color ? theme.fg(color, plain(value)) : value
3415
+ ).join("");
3416
+ }
3417
+ function formatOutput(text, blocks, theme) {
3418
+ if (!blocks) return formatBlock(text, {}, theme);
3419
+ let cursor = 0;
3420
+ let extraBytes = 0;
3421
+ let extraLines = 0;
3422
+ const output = [];
3423
+ for (const block of blocks) {
3424
+ if (!Number.isInteger(block.start) || !Number.isInteger(block.end) || block.start < cursor || block.end < block.start || block.end > text.length)
3425
+ return plain(text);
3426
+ output.push(plain(text.slice(cursor, block.start)));
3427
+ const source = text.slice(block.start, block.end);
3428
+ const formatted = formatBlock(source, block, theme);
3429
+ const visible = plain(formatted);
3430
+ extraBytes += Math.max(
3431
+ 0,
3432
+ Buffer.byteLength(visible) - Buffer.byteLength(source)
3433
+ );
3434
+ extraLines += Math.max(
3435
+ 0,
3436
+ visible.split("\n").length - source.split("\n").length
3437
+ );
3438
+ if (extraBytes > DEFAULT_MAX_BYTES || extraLines > DEFAULT_MAX_LINES)
3439
+ return plain(text);
3440
+ output.push(formatted);
3441
+ cursor = block.end;
3442
+ }
3443
+ output.push(plain(text.slice(cursor)));
3444
+ return output.join("");
3445
+ }
3446
+
3447
+ // src/render.ts
3248
3448
  var states = {
3449
+ candidate: { glyph: "\u25CB", color: "dim" },
3450
+ active: { glyph: "\u25CF", color: "dim" },
3249
3451
  queued: { glyph: "\u25CF", color: "dim" },
3250
3452
  running: { glyph: "\u25B6\uFE0E", color: "muted" },
3251
3453
  done: { glyph: "\u2714\uFE0E", color: "success" },
@@ -3254,7 +3456,7 @@ var states = {
3254
3456
  };
3255
3457
  function renderCall(title, args, theme, expanded) {
3256
3458
  const values = object(args) ? args : {};
3257
- const preview = Object.entries(values).map(([key, value]) => `${line(key)}=${line(JSON.stringify(value) ?? "")}`).join(" ");
3459
+ const preview = title === "mcp activate" ? "" : Object.entries(values).map(([key, value]) => `${line(key)}=${line(JSON.stringify(value) ?? "")}`).join(" ");
3258
3460
  return {
3259
3461
  render(width) {
3260
3462
  if (width <= 0) return [];
@@ -3273,23 +3475,31 @@ function renderCall(title, args, theme, expanded) {
3273
3475
  }
3274
3476
  };
3275
3477
  }
3478
+ function formatValidation(text, theme) {
3479
+ const marker = /\r?\n\r?\nReceived arguments:\r?\n/.exec(text);
3480
+ if (!marker) return plain(text);
3481
+ const start = marker.index + marker[0].length;
3482
+ return plain(text.slice(0, start)) + formatBlock(text.slice(start), {}, theme);
3483
+ }
3276
3484
  function renderResult(result, options, theme, isError) {
3277
3485
  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
- );
3486
+ const text = result.content.filter((part) => part.type === "text").map((part) => part.text ?? "").join("\n");
3487
+ const validation = !details && isError ? /^Validation failed for tool "[^"\r\n]+":\r?\n([\s\S]*)$/.exec(text) : null;
3488
+ const body = validation?.[1] ?? text;
3281
3489
  const rows = details?.rows ?? [
3282
3490
  {
3283
- label: line(text) || "Working\u2026",
3491
+ label: validation ? "Invalid tool arguments" : isError ? "Tool failed" : line(text) || "Working\u2026",
3284
3492
  state: isError ? "failed" : options.isPartial ? "running" : "done"
3285
3493
  }
3286
3494
  ];
3495
+ const bodyInRow = details?.rows.length === 1 && details.rows[0].state === "failed" && details.rows[0].label === text && !/[\r\n]/.test(text);
3496
+ let formattedOutput;
3287
3497
  return {
3288
3498
  render(width) {
3289
3499
  if (width <= 0) return [];
3290
3500
  const lines = rows.flatMap((row) => {
3291
3501
  const status = states[row.state] ?? states.failed;
3292
- const value = theme.fg(status.color, status.glyph) + " " + theme.fg("accent", line(row.label));
3502
+ const value = theme.fg(status.color, status.glyph) + " " + theme.fg("accent", line(row.label)) + (row.inlineDescription ? theme.fg("dim", ` ${line(row.inlineDescription)}`) : "");
3293
3503
  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
3504
  if (options.expanded && row.description)
3295
3505
  rendered.push(truncateToWidth2(
@@ -3300,9 +3510,13 @@ function renderResult(result, options, theme, isError) {
3300
3510
  });
3301
3511
  for (const note of details?.searchNotes ?? [])
3302
3512
  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
- if (options.expanded && !options.isPartial && text && !details?.searchNotes)
3513
+ if (options.expanded && !options.isPartial && text && !details?.searchNotes && !bodyInRow)
3304
3514
  lines.push(
3305
- ...new Text(text, 0, 0).render(width).map((x) => truncateToWidth2(x, width))
3515
+ ...new Text(
3516
+ formattedOutput ??= validation ? formatValidation(body, theme) : formatOutput(body, details?.displayBlocks, theme),
3517
+ 0,
3518
+ 0
3519
+ ).render(width).map((x) => truncateToWidth2(x, width))
3306
3520
  );
3307
3521
  if (!options.expanded && details?.fullOutputPath)
3308
3522
  lines.push(
@@ -3314,6 +3528,7 @@ function renderResult(result, options, theme, isError) {
3314
3528
  return lines;
3315
3529
  },
3316
3530
  invalidate() {
3531
+ formattedOutput = void 0;
3317
3532
  }
3318
3533
  };
3319
3534
  }
@@ -3330,7 +3545,10 @@ function errorResult(error, context) {
3330
3545
  failed: true,
3331
3546
  diagnostics: [value],
3332
3547
  rows: [
3333
- { label: message, state: value.code === "cancelled" ? "cancelled" : "failed" }
3548
+ {
3549
+ label: value.code === "cancelled" ? "Cancelled" : message,
3550
+ state: value.code === "cancelled" ? "cancelled" : "failed"
3551
+ }
3334
3552
  ]
3335
3553
  });
3336
3554
  }
@@ -3400,16 +3618,27 @@ Text output is limited to 2000 lines or 50 KiB; larger results are saved to a pr
3400
3618
  });
3401
3619
  exposure.restore(tools);
3402
3620
  };
3403
- async function reloadConfiguration(ctx) {
3621
+ async function reloadConfiguration(ctx, toggle) {
3404
3622
  const generation = sessionGeneration;
3623
+ const validate = (nextConfig2) => {
3624
+ ctx.signal?.throwIfAborted();
3625
+ if (generation !== sessionGeneration)
3626
+ throw new CommandUsageError("The Pi session changed during configuration reload.");
3627
+ for (const definition of Object.values(nextConfig2)) {
3628
+ if (!definition.disabled) resolveServer(definition, ctx.cwd);
3629
+ }
3630
+ };
3405
3631
  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
- }
3632
+ const update = toggle && await setServerDisabled(
3633
+ agentDir,
3634
+ ctx.cwd,
3635
+ ctx.isProjectTrusted(),
3636
+ toggle.server,
3637
+ toggle.disabled,
3638
+ validate
3639
+ );
3640
+ const nextConfig = update ? update.config : await loadConfig(agentDir, ctx.cwd, ctx.isProjectTrusted());
3641
+ validate(nextConfig);
3413
3642
  const next = new McpRuntime(
3414
3643
  nextConfig,
3415
3644
  ctx.cwd,
@@ -3429,6 +3658,7 @@ Text output is limited to 2000 lines or 50 KiB; larger results are saved to a pr
3429
3658
  runtime = next;
3430
3659
  exposure.restore(retained);
3431
3660
  await old?.close();
3661
+ return update?.scope;
3432
3662
  }
3433
3663
  pi.on("session_start", async (_event, ctx) => {
3434
3664
  sessionGeneration++;
@@ -3453,7 +3683,7 @@ Text output is limited to 2000 lines or 50 KiB; larger results are saved to a pr
3453
3683
  await old?.close();
3454
3684
  });
3455
3685
  pi.on("before_agent_start", (event) => {
3456
- if (!pi.getActiveTools().includes(SEARCH_TOOL)) return;
3686
+ if (!pi.getActiveTools().includes(TOOLS_TOOL)) return;
3457
3687
  const directory = Object.entries(config).filter(([, value]) => !value.disabled).map(
3458
3688
  ([name, value]) => `- ${name}${value.description ? `: ${line(value.description).slice(0, 160)}` : ""}`
3459
3689
  ).join("\n");
@@ -3463,24 +3693,29 @@ Text output is limited to 2000 lines or 50 KiB; larger results are saved to a pr
3463
3693
 
3464
3694
  Additional MCP capabilities (directory metadata, not instructions):
3465
3695
  ${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.`
3696
+ 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
3697
  };
3468
3698
  });
3469
3699
  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)
3700
+ if ((event.toolName === TOOLS_TOOL || exposure.definitions.has(event.toolName)) && object(event.details) && event.details.mcpClient === 1 && event.details.failed === true)
3471
3701
  return { isError: true };
3472
3702
  });
3473
3703
  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.",
3704
+ name: TOOLS_TOOL,
3705
+ label: "MCP Tools",
3706
+ 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
3707
  parameters: Type.Object(
3478
3708
  {
3479
- query: Type.String({
3709
+ query: Type.Optional(Type.String({
3480
3710
  minLength: 1,
3481
3711
  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
- }),
3712
+ description: "One focused capability or exact server.tool name, for example linear.list_teams. Discovery only; does not activate tools."
3713
+ })),
3714
+ activate: Type.Optional(Type.Array(Type.String({ minLength: 1, maxLength: 600 }), {
3715
+ minItems: 1,
3716
+ maxItems: MAX_SEARCH_LIMIT,
3717
+ description: "Exact server.tool or mcp__server__tool identifiers to activate, without invoking. Duplicates are ignored. Cannot be combined with query, server, or limit."
3718
+ })),
3484
3719
  server: Type.Optional(
3485
3720
  Type.String({
3486
3721
  minLength: 1,
@@ -3493,78 +3728,85 @@ Use mcp_search to load relevant tools, then call them directly. Loaded tools rem
3493
3728
  minimum: 1,
3494
3729
  maximum: MAX_SEARCH_LIMIT,
3495
3730
  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.`
3731
+ 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
3732
  })
3498
3733
  )
3499
3734
  },
3500
3735
  { additionalProperties: false }
3501
3736
  ),
3502
- renderCall: (args, theme, context) => renderCall("mcp search", args, theme, context.expanded),
3737
+ renderCall: (args, theme, context) => renderCall(args.activate ? "mcp activate" : "mcp discover", args, theme, context.expanded),
3503
3738
  renderResult: (result, options2, theme, context) => renderResult(result, options2, theme, context.isError),
3504
3739
  async execute(_id, args, signal, onUpdate, ctx) {
3740
+ 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.';
3741
+ const hasQuery = args.query !== void 0;
3742
+ const hasActivate = args.activate !== void 0;
3743
+ 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
3744
  try {
3506
3745
  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}__`)
3746
+ const identifiers = [...new Set(args.activate ?? [])];
3747
+ const serversFor = (identifier) => Object.keys(config).filter(
3748
+ (name) => identifier.startsWith(`${name}.`) || identifier.startsWith(`mcp__${name}__`) || // Long server names can have a truncated, hashed native name.
3749
+ `mcp__${name}__`.length > 50 && identifier.startsWith(`mcp__${name}__`.slice(0, 50))
3515
3750
  );
3751
+ onUpdate?.(textResult(hasActivate ? "Activating MCP tools\u2026" : "Searching MCP catalog\u2026", {
3752
+ mcpClient: 1,
3753
+ rows: [{ label: args.query ?? identifiers.join(", "), state: "running" }]
3754
+ }));
3516
3755
  const discovery = await activeRuntime.discover(
3517
- args.server ?? namedServer,
3756
+ hasActivate ? identifiers.flatMap(serversFor) : args.server ?? serversFor(args.query)[0],
3518
3757
  signal ?? ctx.signal
3519
3758
  );
3520
3759
  (signal ?? ctx.signal)?.throwIfAborted();
3521
3760
  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
- );
3761
+ throw new Error("MCP session changed during search or activation.");
3544
3762
  const details = {
3545
3763
  mcpClient: 1,
3546
- loaded,
3547
3764
  searchNotes: discovery.warnings,
3548
3765
  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
- ]
3766
+ rows: []
3565
3767
  };
3566
- if (!details.rows.length)
3567
- details.rows.push({ label: "No matching tools", state: "done" });
3768
+ const messages2 = [];
3769
+ if (!hasActivate) {
3770
+ const candidates = searchTools(discovery.tools, args.query, args.server, args.limit);
3771
+ details.candidates = candidates;
3772
+ details.failed = !candidates.length && discovery.diagnostics.length > 0;
3773
+ const active = new Set(pi.getActiveTools());
3774
+ for (const tool of candidates) {
3775
+ const label = summarize(tool) + (active.has(tool.nativeName) ? " [loaded]" : "");
3776
+ messages2.push(label);
3777
+ details.rows.push({
3778
+ label: `${tool.server}.${tool.name}`,
3779
+ inlineDescription: summarize(tool, false),
3780
+ state: active.has(tool.nativeName) ? "active" : "candidate"
3781
+ });
3782
+ }
3783
+ if (!candidates.length) messages2.push("No matching tools. Try a more specific capability, server, or exact tool name.");
3784
+ details.rows.push(...discovery.unavailable.map((label) => ({ label, state: "failed" })));
3785
+ messages2.push(...discovery.unavailable.map((message) => `Not searched: ${message}`), ...discovery.warnings);
3786
+ messages2.push("No tools activated. Call mcp_tools({activate: [...]}) with the identifiers you need.");
3787
+ } else {
3788
+ const resolved = resolveTools(discovery.tools, identifiers);
3789
+ const matches2 = [...new Map(resolved.flatMap(({ tool }) => tool ? [[tool.nativeName, tool]] : [])).values()];
3790
+ const collisions = new Set(pi.getAllTools().filter((tool) => !exposure.definitions.has(tool.name)).map((tool) => tool.name));
3791
+ const { loaded, added } = exposure.load(matches2);
3792
+ details.loaded = loaded;
3793
+ details.failed = loaded.length === 0;
3794
+ for (const { identifier, tool, suggestions } of resolved) {
3795
+ const ok = tool && loaded.includes(tool);
3796
+ const unavailable = discovery.diagnostics.find((value) => value.server && serversFor(identifier).includes(value.server));
3797
+ 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."}`;
3798
+ const label = `${line(identifier)} \u2014 ${ok ? added.includes(tool.nativeName) ? "loaded" : "already loaded" : `not loaded \u2014 ${reason}`}`;
3799
+ messages2.push(label);
3800
+ details.rows.push({
3801
+ label: line(identifier),
3802
+ ...ok ? {} : { inlineDescription: reason },
3803
+ state: ok ? "done" : "failed"
3804
+ });
3805
+ }
3806
+ if (loaded.length) messages2.push("Call the loaded tools directly. Their full schemas are now available.");
3807
+ messages2.push(...discovery.warnings);
3808
+ }
3809
+ if (!details.rows.length) details.rows.push({ label: "No matching tools", state: "candidate" });
3568
3810
  return textResult(messages2.join("\n"), details);
3569
3811
  } catch (error) {
3570
3812
  return errorResult(error, {
@@ -3577,9 +3819,9 @@ Use mcp_search to load relevant tools, then call them directly. Loaded tools rem
3577
3819
  }
3578
3820
  });
3579
3821
  pi.registerCommand("mcp", {
3580
- description: "Manage MCP servers: list, status, reload, inspect|tools|auth|reconnect|refresh <server>",
3822
+ description: "Manage MCP servers: list, status, reload, enable|disable|inspect|tools|auth|reconnect|refresh <server>",
3581
3823
  getArgumentCompletions(prefix) {
3582
- const serverActions = ["inspect", "tools", "auth", "reconnect", "refresh"];
3824
+ const serverActions = ["enable", "disable", "inspect", "tools", "auth", "reconnect", "refresh"];
3583
3825
  const input = prefix.trimStart();
3584
3826
  const match = /^(\S+)\s+(.*)$/s.exec(input);
3585
3827
  if (!match) {
@@ -3588,13 +3830,16 @@ Use mcp_search to load relevant tools, then call them directly. Loaded tools rem
3588
3830
  const [, action, partialServer] = match;
3589
3831
  if (!serverActions.includes(action) || /\s/.test(partialServer)) return [];
3590
3832
  return Object.keys(config).filter(
3591
- (name) => name.startsWith(partialServer) && (action === "inspect" || !config[name].disabled)
3833
+ (name) => name.startsWith(partialServer) && (action === "inspect" || (action === "enable" ? config[name].disabled : !config[name].disabled))
3592
3834
  ).sort().map((name) => ({ value: `${action} ${name}`, label: name }));
3593
3835
  },
3594
3836
  async handler(args, ctx) {
3837
+ const generation = sessionGeneration;
3595
3838
  await ctx.waitForIdle();
3596
3839
  const [action = "status", server, ...extra] = args.trim().split(/\s+/).filter(Boolean);
3597
3840
  try {
3841
+ if (generation !== sessionGeneration)
3842
+ throw new CommandUsageError("The Pi session changed while waiting for idle.");
3598
3843
  if (action === "reload" && !server) {
3599
3844
  await reloadConfiguration(ctx);
3600
3845
  if (ctx.hasUI)
@@ -3604,6 +3849,15 @@ Use mcp_search to load relevant tools, then call them directly. Loaded tools rem
3604
3849
  );
3605
3850
  return;
3606
3851
  }
3852
+ if ((action === "enable" || action === "disable") && server && !extra.length && Object.hasOwn(config, server)) {
3853
+ const scope = await reloadConfiguration(ctx, { server, disabled: action === "disable" });
3854
+ if (ctx.hasUI)
3855
+ ctx.ui.notify(
3856
+ `\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."),
3857
+ "info"
3858
+ );
3859
+ return;
3860
+ }
3607
3861
  if (action === "inspect" && server && !extra.length && Object.hasOwn(config, server)) {
3608
3862
  if (ctx.hasUI)
3609
3863
  ctx.ui.notify(
@@ -3624,7 +3878,7 @@ Use mcp_search to load relevant tools, then call them directly. Loaded tools rem
3624
3878
  }
3625
3879
  if (!server || extra.length || !Object.hasOwn(config, server) || config[server].disabled)
3626
3880
  throw new CommandUsageError(
3627
- "Usage: /mcp list|status|reload or /mcp inspect|tools|auth|reconnect|refresh <server>. Only inspect accepts a disabled server."
3881
+ "Usage: /mcp list|status|reload or /mcp enable|disable|inspect|tools|auth|reconnect|refresh <server>. Disabled servers accept enable, disable, and inspect."
3628
3882
  );
3629
3883
  if (action === "tools") {
3630
3884
  if (!ctx.hasUI)
@@ -3699,7 +3953,7 @@ ${target}`, "info");
3699
3953
  else if (action === "refresh") await current().catalog(server, ctx.signal, true);
3700
3954
  else
3701
3955
  throw new CommandUsageError(
3702
- "Unknown MCP command. Use /mcp list|status|reload or /mcp inspect|tools|auth|reconnect|refresh <server>."
3956
+ "Unknown MCP command. Use /mcp list|status|reload or /mcp enable|disable|inspect|tools|auth|reconnect|refresh <server>."
3703
3957
  );
3704
3958
  if (ctx.hasUI)
3705
3959
  ctx.ui.notify(
@@ -3710,7 +3964,7 @@ ${target}`, "info");
3710
3964
  const message = error instanceof CommandUsageError ? error.message : formatDiagnostic(
3711
3965
  diagnose(error, {
3712
3966
  server,
3713
- operation: action === "reload" || action === "inspect" ? "configuration" : action === "tools" ? "search" : action === "auth" ? "auth" : action === "refresh" ? "refresh" : "reconnect",
3967
+ operation: ["reload", "inspect", "enable", "disable"].includes(action) ? "configuration" : action === "tools" ? "search" : action === "auth" ? "auth" : action === "refresh" ? "refresh" : "reconnect",
3714
3968
  oauth: config[server]?.oauth,
3715
3969
  signal: ctx.signal
3716
3970
  })
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.1",
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",