@fitchmultz/pi-mcp-adapter 7.0.0 → 7.1.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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [7.1.0] - 2026-09-27
11
+
12
+ ### Changed
13
+
14
+ - Keep full MCP gateway/script schemas inactive until selected with `mcp_search({ enable: ["gateway", "script"] })`. Typed discovery still loads matches in one call. Selections persist on the current session branch; configured pins, legacy resource pins, configuration restrictions, and loader-excluded host allowlists retain their behavior.
15
+ - `npm run typecheck` now also checks the test suite under the same strict settings as the source.
16
+
10
17
  ## [7.0.0] - 2026-09-26
11
18
 
12
19
  ### Breaking changes
package/OAUTH.md CHANGED
@@ -207,7 +207,7 @@ For browser authorization, this will:
207
207
 
208
208
  ### Remote/headless authentication
209
209
 
210
- When Pi runs over SSH or in a headless environment, use the proxy tool to retrieve the authorization URL instead of relying on OS browser launch:
210
+ When Pi runs over SSH or in a headless environment, use the proxy tool to retrieve the authorization URL instead of relying on OS browser launch. If `mcp` is inactive, first call `mcp_search({ enable: ["gateway"] })` (no query needed):
211
211
 
212
212
  ```
213
213
  mcp({ action: "auth-start", server: "my-oauth-server" })
package/README.md CHANGED
@@ -16,7 +16,7 @@ Mario wrote about [why you might not need MCP](https://mariozechner.at/posts/202
16
16
 
17
17
  His take: skip MCP entirely, write simple CLI tools instead.
18
18
 
19
- The MCP ecosystem has useful databases, browsers, and APIs. This adapter keeps their full tool schemas out of context until needed. `mcp_search` discovers and loads typed tools for the next request; `mcp` handles explicit gateway actions, and `mcp_script` composes MCP calls in JavaScript. Lazy servers stay disconnected until selected discovery or a call needs them.
19
+ The MCP ecosystem has useful databases, browsers, and APIs. This adapter keeps their full tool schemas out of context until needed. Only `mcp_search` and configured pins start active. `mcp_search` discovers and loads typed tools for the next request; `mcp_search({ enable: ["gateway"] })` loads `mcp` for gateway actions, and `mcp_search({ enable: ["script"] })` loads `mcp_script` for JavaScript composition. Lazy servers stay disconnected until selected discovery or a call needs them.
20
20
 
21
21
  ## Pi release qualification
22
22
 
@@ -124,7 +124,9 @@ mcp_search({ query: "take screenshot", server: "chrome-devtools" })
124
124
 
125
125
  The returned exact tool references become available with their full argument schemas on the next model request. The agent then calls the chosen typed tool normally. Discovery does not run it.
126
126
 
127
- If the host only permits `mcp`, use explicit gateway actions instead:
127
+ To use resources, authentication, UI messages, saved-result readback, or fallback calls, first load the gateway with `mcp_search({ enable: ["gateway"] })`. No query is required and enabling alone performs no integration action or remote connection. Use `enable: ["script"]` for composition, or enable both together. Normal typed search still takes just one discovery call before a typed call.
128
+
129
+ If the host only permits `mcp` (and excludes `mcp_search`), it stays eager. Use explicit gateway actions instead:
128
130
 
129
131
  ```js
130
132
  mcp({ action: "search", query: "take screenshot", server: "chrome-devtools" })
@@ -409,6 +411,8 @@ If Pi is running on a remote server and cannot open a local browser, start OAuth
409
411
 
410
412
  On Linux, if credential access fails because Pi inherited a revoked session keyring, the adapter uses a best-effort recovery path through `keyctl session - node <packaged helper>` so explicit re-authentication can write fresh credentials without killing a long-lived tmux server. This path requires `keyctl` and `node` on `PATH`; missing, locked, or otherwise unavailable credential stores still fail closed.
411
413
 
414
+ If the gateway is inactive, first call `mcp_search({ enable: ["gateway"] })`.
415
+
412
416
  ```js
413
417
  mcp({ action: "auth-start", server: "linear-server" })
414
418
  ```
@@ -481,7 +485,7 @@ Persistent OAuth is unsupported out of the box on Android/Termux because `@napi-
481
485
  | `mcpServers.<name>.oauth.authorizationParams` | Extra authorization URL parameters for provider-specific OAuth extensions. Flow-owned parameters such as `client_id`, `redirect_uri`, `scope`, `state`, `code_challenge`, `response_type`, and `resource` cannot be overridden. |
482
486
  | `directTools` | Global default for all servers (default: false). Per-server overrides this. |
483
487
  | `freezeDirectTools` | Keep direct-tool registration stable after the initial sync so automatic reconnects and list-change notifications do not rebuild the system prompt. Use `mcp({ action: "connect", server: "server" })` or `/mcp reconnect <server>` to refresh deliberately. Default: false. |
484
- | `scriptMode` | Register the MCP-only `mcp_script` plain-JavaScript tool (default: true). Set to `false` to hide it. |
488
+ | `scriptMode` | Make the MCP-only `mcp_script` plain-JavaScript tool loadable (default: true). Set to `false` to hide it. |
485
489
  | `disableProxyTool` | Hide `mcp` once configured pinned tools are available. Legacy resource pins keep it available for resource access. |
486
490
  | `autoAuth` | Auto-run OAuth on `connect`/tool calls when a server needs auth, then retry once (default: false). |
487
491
  | `sampling` | Allow MCP servers to sample through Pi models, honoring `modelPreferences.hints` before current/default fallback (default: true when UI approval is available). |
@@ -531,7 +535,7 @@ Tune the limits with the object form:
531
535
 
532
536
  Set `"outputGuard": false` — or the env kill switch `MCP_OUTPUT_GUARD=0` — to disable the guard and restore raw output behavior. Recovery notices remain visible even when a configured cap is too small to fit them. Saved files are created with mode `0600` under the system temp directory (or the SDK host's `outputDirectory`) and are not cleaned up automatically; note that spilled MCP output may contain sensitive data.
533
537
 
534
- Read a retained result without repeating the MCP operation:
538
+ Read a retained result without repeating the MCP operation. Load the gateway first if inactive with `mcp_search({ enable: ["gateway"] })`:
535
539
 
536
540
  ```js
537
541
  mcp({ action: "read-result", ref: "/returned/result/path", path: "/structuredContent/rows", fields: ["id", "title"], offset: 0, limit: 12000 })
@@ -541,7 +545,7 @@ mcp({ action: "read-result", ref: "/returned/result/path", path: "/structuredCon
541
545
 
542
546
  ### MCP Scripting
543
547
 
544
- For multi-call MCP work, write ordinary JavaScript: discover, inspect, call, loop, filter, chain, or fan out, then return one result. Run that code with the default-on `mcp_script` tool. Use `mcp_search` for typed discovery, a loaded typed tool for a single call, and `mcp` for explicit gateway actions. Set `settings.scriptMode` to `false` to hide the scripting tool.
548
+ For multi-call MCP work, write ordinary JavaScript: discover, inspect, call, loop, filter, chain, or fan out, then return one result. Load the script tool with `mcp_search({ enable: ["script"] })`, then run that code with `mcp_script`. Use `mcp_search` for typed discovery, a loaded typed tool for a single call, and `mcp` for explicit gateway actions. Set `settings.scriptMode` to `false` to hide the scripting tool.
545
549
 
546
550
  The bundled `mcp-scripting` skill is a separate Pi package resource. To hide that skill while keeping the adapter extension installed, replace the package entry in Pi settings with the object form and disable package skills:
547
551
 
@@ -606,7 +610,7 @@ See the bundled `mcp-scripting` skill for the workflow and exact return shapes.
606
610
 
607
611
  ### MCP Resources
608
612
 
609
- Resources are catalog entries addressed by URI, separate from tools:
613
+ Resources are catalog entries addressed by URI, separate from tools. Load the gateway first if inactive with `mcp_search({ enable: ["gateway"] })`:
610
614
 
611
615
  ```js
612
616
  mcp({ action: "resources", server: "docs", limit: 12, offset: 0 })
@@ -637,7 +641,7 @@ URL mode is advertised only in TUI mode. The adapter displays the requesting ser
637
641
 
638
642
  `mcp_search({ query, server?, limit?, offset? })` searches real tools and loads the matching typed functions for the next request. It defaults to five matches, with a maximum of 100 and zero-based pagination. Use a specific query, or an empty query with `server` to browse that server. It does not execute a hit or rewrite dynamic instructions into the system prompt.
639
643
 
640
- Cached eligible schemas register inactive; only pins and tools selected for the current branch are active. The adapter persists canonical `{ server, tool }` selections in native Pi session entries and restores them across resume, reload, branch navigation, and working-directory changes. Host tool allowlists remain binding.
644
+ Cached eligible schemas and the full gateway/script definitions register inactive; only pins and tools selected for the current branch are active. Legacy resource pins keep the gateway eager. Allowed gateway/script tools also stay eager when the host excludes the loader, so restricted hosts remain usable. The adapter persists canonical `{ server, tool }` selections and enabled gateway/script features in native Pi session entries and restores them across resume, reload, branch navigation, and working-directory changes. Host tool allowlists remain binding. Manual deselection survives reload. Loaded schemas remain available rather than unloading each turn; activation changes declarations and can invalidate the prompt-cache prefix.
641
645
 
642
646
  On hosts with native tool-search support, lazy tools use exact references in namespace `mcp_<server>` with the original tool name as the leaf. Existing direct pins retain their flat prefixed names. On official Pi 0.87.1, which has no native tool search, the ordinary `mcp_search` loader activates flat tools with the same discover-then-call workflow. Always use the exact reference returned by discovery rather than constructing names.
643
647
 
@@ -748,7 +752,7 @@ MCP servers can ship interactive UIs via [MCP Apps](https://github.com/modelcont
748
752
 
749
753
  **Native rendering:** On macOS, if [Glimpse](https://github.com/hazat/glimpse) is installed (`pi install npm:glimpseui`), UIs open in a native WKWebView window instead of a browser tab. Set `MCP_UI_VIEWER=browser` to force the browser, `MCP_UI_VIEWER=glimpse` to require native rendering, or `MCP_UI_VIEWER=none` (also accepts `off` / `disabled`) to suppress the window entirely — the tool still runs and its inline result is returned to the agent, but no browser or native window opens. This is useful for headless setups, CI, or users who want the tool output delivered inline as text only. When suppressed, a one-line info notification shows the UI URL so it can still be opened manually if needed.
750
754
 
751
- **Bidirectional communication:** The UI talks back. When it sends a prompt or intent, the message is stored and `triggerTurn()` wakes the agent. The agent retrieves messages via `mcp({ action: "ui-messages" })` and responds, enabling conversational UIs where the app and agent collaborate in real-time.
755
+ **Bidirectional communication:** The UI talks back. When it sends a prompt or intent, the message is stored and `triggerTurn()` wakes the agent. The agent loads the gateway if inactive with `mcp_search({ enable: ["gateway"] })`, then retrieves messages via `mcp({ action: "ui-messages" })` and responds, enabling conversational UIs where the app and agent collaborate in real-time.
752
756
 
753
757
  **Session reuse:** When the agent calls the same tool again while its UI is already open, the adapter pushes the new result to the existing window instead of replacing it. This enables live updates — the agent can refine a chart, add data, or respond to user input without losing the current view. Different tools still replace the session as before.
754
758
 
@@ -820,6 +824,7 @@ Prefer `.mcp.json` for project-local shared MCP config. Use `.pi/fitch-mcp-adapt
820
824
  | Action | Example |
821
825
  |--------|---------|
822
826
  | Discover and load typed tools | `mcp_search({ query: "screenshot", server: "chrome-devtools", limit: 5 })` |
827
+ | Load gateway / script | `mcp_search({ enable: ["gateway", "script"] })` |
823
828
  | Status | `mcp({ action: "status" })` |
824
829
  | List server tools | `mcp({ action: "list", server: "name", limit: 12, offset: 0 })` |
825
830
  | Search without activation | `mcp({ action: "search", query: "screenshot navigate", limit: 12 })` |
@@ -834,7 +839,7 @@ Prefer `.mcp.json` for project-local shared MCP config. Use `.pi/fitch-mcp-adapt
834
839
  | Auth start | `mcp({ action: "auth-start", server: "name" })` |
835
840
  | Auth complete | `mcp({ action: "auth-complete", server: "name" })` after the browser callback, or supply `args: { redirectUrl: "..." }` |
836
841
 
837
- `action` is required. Search requires `query`; describe/call require `tool`; list/connect/instructions/resources/auth require `server`; resource reads require `server` and `uri`; saved-result reads require `ref`. Pass object `args`. Optional `server` disambiguates tool calls and descriptions.
842
+ Load `mcp` first if inactive using the row above. `action` is required. Search requires `query`; describe/call require `tool`; list/connect/instructions/resources/auth require `server`; resource reads require `server` and `uri`; saved-result reads require `ref`. Pass object `args`. Optional `server` disambiguates tool calls and descriptions.
838
843
 
839
844
  `connect` refreshes an already connected server, including tools, resources, prompts, and instructions. Server-scoped discovery connects that server if its catalog is unknown. Global search uses known catalogs and returns `coverage: { complete, knownServers, unknownServers }`; partial coverage is never presented as an exhaustive result. Gateway search provides schemas without activating functions, so it works for gateway-only hosts.
840
845
 
@@ -875,7 +880,7 @@ Advertised tool `outputSchema` values support JSON Schema draft-07 and 2020-12.
875
880
 
876
881
  ## How It Works
877
882
 
878
- - Stable `mcp_search`, `mcp`, and optional `mcp_script` entry points; full typed schemas load when selected
883
+ - Eager `mcp_search`; full `mcp`, optional `mcp_script`, and typed schemas load when selected
879
884
  - Lazy servers connect on selected discovery or calls; configured pins and eager/keep-alive servers can bootstrap intentionally
880
885
  - Tool metadata is cached to disk so search/list/describe work without live connections
881
886
  - Idle servers disconnect after 10 minutes (configurable), reconnect automatically on next use
@@ -15,7 +15,7 @@ export function directToolSelection(config, serverName, envOverride) {
15
15
  export function buildProxyDescription(config) {
16
16
  const servers = Object.entries(config.mcpServers).filter(([, definition]) => !isServerDisabled(definition)).map(([name]) => name).sort();
17
17
  return [
18
- "MCP gateway. Use mcp_search to discover and load typed tools; mcp_script for multi-call composition. Native Pi tools are called directly.",
18
+ 'MCP gateway. Use mcp_search to discover typed tools; enable mcp_script with mcp_search({ enable: ["script"] }) for multi-call composition. Native Pi tools are called directly.',
19
19
  `Configured servers: ${servers.join(", ") || "none"}. Catalog discovery is lazy.`,
20
20
  "Actions: status; list/search/describe; call (tool,args,server?); connect (server); instructions (server); resources (server); read-resource (server,uri); read-result (ref,path?,fields?,offset?,limit?); auth-start/auth-complete (server,args?); ui-messages.",
21
21
  "Use call when a discovered direct tool is unavailable in this host. read-result reads a retained response without repeating the remote operation.",
package/dist/index.js CHANGED
@@ -19,7 +19,7 @@ import { createToolLoader, supportsNativeAsync } from "./tool-loader.js";
19
19
  import { gatewayParameters, prepareGatewayArguments } from "./gateway-arguments.js";
20
20
  import { executeResourceList, executeResourceRead } from "./resource-tools.js";
21
21
  import { guardMcpOutput, guardedMcpDetails, readMcpResult, resolveMcpOutputGuardOptions } from "./mcp-output-guard.js";
22
- import { abortable } from "./abort.js";
22
+ import { abortable, throwIfAborted } from "./abort.js";
23
23
  import { onSessionCheckpoint, prepareMcpCheckpoint } from "./checkpoint.js";
24
24
  export { MCP_STATUS_EVENT, MCP_STATUS_SNAPSHOT_VERSION, } from "./types.js";
25
25
  const INIT_WAIT_TIMEOUT_MS = 30_000;
@@ -90,29 +90,15 @@ function installMcpAdapter(pi, options) {
90
90
  ? cloneMcpConfig(sessionConfig)
91
91
  : { mcpServers: {} };
92
92
  const earlyCache = null;
93
+ let currentConfig = earlyConfig;
93
94
  const envRaw = process.env.MCP_DIRECT_TOOLS;
94
95
  const envDirectToolOverride = envRaw?.split(",").map(s => s.trim()).filter(Boolean);
95
96
  const toolLoader = createToolLoader(pi, () => state, () => initPromise, beforeExecute);
96
- const deactivatedTools = new Set();
97
97
  let runtimeCwd;
98
98
  let nativeAsyncSupported = false;
99
99
  let proxyToolRegistered = false;
100
100
  let proxyToolDescription = null;
101
101
  let directToolsFrozen = false;
102
- function deactivateTools(toolNames) {
103
- if (toolNames.length === 0)
104
- return;
105
- const remove = new Set(toolNames);
106
- const activeTools = pi.getActiveTools();
107
- for (const toolName of toolNames) {
108
- if (activeTools.includes(toolName))
109
- deactivatedTools.add(toolName);
110
- }
111
- const nextActiveTools = activeTools.filter((name) => !remove.has(name));
112
- if (nextActiveTools.length !== activeTools.length) {
113
- pi.setActiveTools(nextActiveTools);
114
- }
115
- }
116
102
  function applyDirectToolConfigChanges(changes) {
117
103
  if (!state)
118
104
  return;
@@ -124,12 +110,9 @@ function installMcpAdapter(pi, options) {
124
110
  }
125
111
  }
126
112
  function syncToolSurface(ctx) {
127
- const config = state?.config ?? earlyConfig;
113
+ const config = state?.config ?? currentConfig;
128
114
  const cache = loadMetadataCache(state?.metadataCacheEnabled ?? false);
129
- toolLoader.sync(config, cache, envRaw === "__none__" ? null : envDirectToolOverride, nativeAsyncSupported, specs => {
130
- syncProxyTool(config, cache, specs);
131
- syncScriptTool(config);
132
- });
115
+ toolLoader.sync(config, cache, envRaw === "__none__" ? null : envDirectToolOverride, nativeAsyncSupported, specs => syncGateways(config, cache, specs));
133
116
  }
134
117
  const registeredPromptCommands = new Set();
135
118
  function registerPromptCommands(specs) {
@@ -260,15 +243,13 @@ function installMcpAdapter(pi, options) {
260
243
  if (options.transformConfig) {
261
244
  runtimeConfig = cloneMcpConfig(options.transformConfig(cloneMcpConfig(runtimeConfig), ctx));
262
245
  }
246
+ currentConfig = runtimeConfig;
263
247
  const metadataCacheEnabled = ctx.isProjectTrusted()
264
248
  || !isPathInsideProject(getMetadataCachePath(), ctx.cwd);
265
249
  toolLoader.restore(ctx);
266
250
  registerPromptCommands(resolveCachedPrompts(runtimeConfig, metadataCacheEnabled));
267
251
  const runtimeCache = loadMetadataCache(metadataCacheEnabled);
268
- toolLoader.sync(runtimeConfig, runtimeCache, envRaw === "__none__" ? null : envDirectToolOverride, nativeAsyncSupported, specs => {
269
- syncProxyTool(runtimeConfig, runtimeCache, specs);
270
- syncScriptTool(runtimeConfig);
271
- });
252
+ toolLoader.sync(runtimeConfig, runtimeCache, envRaw === "__none__" ? null : envDirectToolOverride, nativeAsyncSupported, specs => syncGateways(runtimeConfig, runtimeCache, specs));
272
253
  const initialization = startInitialization(ctx, owner, oauthRuntime, runtimeConfig, generation);
273
254
  if (envRaw !== undefined && envRaw !== "__none__") {
274
255
  const missingEnvDirectTools = getMissingConfiguredDirectToolServers(runtimeConfig, runtimeCache, envDirectToolOverride);
@@ -538,7 +519,17 @@ function installMcpAdapter(pi, options) {
538
519
  owner?.throwIfInactive();
539
520
  return state ?? { content: [{ type: "text", text: "MCP not initialized" }], details: { error: "not_initialized" } };
540
521
  }
522
+ const unavailableFeatures = (enable = [], tools) => enable.filter(feature => !tools.some(tool => tool.name === (feature === "gateway" ? "mcp" : "mcp_script") && !tool.namespace));
541
523
  async function searchAndLoad(params, signal, ctx) {
524
+ throwIfAborted(signal);
525
+ if (params.query === undefined && !params.enable?.length)
526
+ throw new Error("Provide query or enable.");
527
+ // Loading front doors does not require initialization or connect to a server.
528
+ if (params.query === undefined) {
529
+ const tools = toolLoader.activate([], params.enable);
530
+ const unavailable = unavailableFeatures(params.enable, tools);
531
+ return { content: [{ type: "text", text: `Loaded for the next request: ${JSON.stringify(tools)}${unavailable.length ? `. Unavailable under configuration or host policy: ${unavailable.join(", ")}` : ""}` }], details: { loaded: tools, unavailable }, tools };
532
+ }
542
533
  const ready = await getToolState(signal);
543
534
  if ("content" in ready)
544
535
  return { ...ready, tools: [] };
@@ -551,23 +542,27 @@ function installMcpAdapter(pi, options) {
551
542
  const matches = params.query.trim() ? rankToolMatches(ready, params.query, params.server)
552
543
  : params.server ? (ready.toolMetadata.get(params.server) ?? []).filter(tool => !tool.resourceUri).map(tool => ({ server: params.server, tool })).sort((a, b) => a.tool.name.localeCompare(b.tool.name)) : [];
553
544
  const page = paginate(matches, params.offset ?? 0, params.limit ?? 5);
554
- const tools = toolLoader.activate(page.items);
555
- const result = executeSearch(ready, params.query, params.server, tools.length < page.items.length, params.limit ?? 5, params.offset, "mcp_search");
545
+ const tools = toolLoader.activate(page.items, params.enable);
546
+ const unavailable = unavailableFeatures(params.enable, tools);
547
+ const typedCount = tools.filter(tool => tool.namespace || (tool.name !== "mcp" && tool.name !== "mcp_script")).length;
548
+ const result = executeSearch(ready, params.query, params.server, typedCount < page.items.length, params.limit ?? 5, params.offset, "mcp_search");
556
549
  const guarded = await guardMcpOutput([
557
550
  ...result.content,
558
- { type: "text", text: tools.length ? `Loaded for the next request: ${JSON.stringify(tools)}` : "No direct tools loaded. Use mcp action call or tools.call in mcp_script with the returned exact names." },
551
+ { type: "text", text: tools.length ? `Loaded for the next request: ${JSON.stringify(tools)}` : 'No direct tools loaded. Enable gateway or script with mcp_search({ enable: ["gateway"] }) or mcp_search({ enable: ["script"] }), then use mcp action call or tools.call with the returned exact names.' },
552
+ ...(unavailable.length ? [{ type: "text", text: `Unavailable under configuration or host policy: ${unavailable.join(", ")}` }] : []),
559
553
  ], resolveMcpOutputGuardOptions(ready.config.settings, ready.outputDirectory));
560
- return { content: guarded.content, details: { ...result.details, ...guardedMcpDetails(guarded), loaded: tools }, tools };
554
+ return { content: guarded.content, details: { ...result.details, ...guardedMcpDetails(guarded), loaded: tools, ...(unavailable.length ? { unavailable } : {}) }, tools };
561
555
  }
562
556
  const searchDefinition = {
563
557
  name: "mcp_search", label: "MCP Search",
564
- description: "Discover MCP tools by name, description, and parameter guidance, and load matching tools with their complete schemas for the next request. Start with a specific task query. Specify server to discover an uncached server; an empty query with server browses it. Results report incomplete catalog coverage. Resources use mcp action resources/read-resource.",
558
+ description: "Discover and load typed MCP tools for the next request. Use query; server discovers an uncached server (empty query browses it). Results report incomplete coverage. Or enable gateway for mcp status/list/search/describe/call/connect/instructions, resources/read-resource, read-result, auth-start/auth-complete and ui-messages; enable script for JavaScript composition, resources and saved-result readback. Enable alone performs no integration action. Loaded schemas stay available in this session/branch.",
565
559
  parameters: Type.Object({
566
- query: Type.String(),
560
+ query: Type.Optional(Type.String()),
561
+ enable: Type.Optional(Type.Array(Type.Union([Type.Literal("gateway"), Type.Literal("script")]), { minItems: 1, uniqueItems: true })),
567
562
  server: Type.Optional(Type.String()),
568
563
  limit: Type.Optional(Type.Integer({ minimum: 1, maximum: MAX_PAGE_SIZE, description: "Tools to load (default 5)." })),
569
564
  offset: Type.Optional(Type.Integer({ minimum: 0 })),
570
- }),
565
+ }, { anyOf: [{ required: ["query"] }, { required: ["enable"] }] }),
571
566
  renderResult: renderMcpToolResult,
572
567
  async execute(toolCallId, params, signal, _onUpdate, ctx) {
573
568
  if (beforeExecute) {
@@ -587,7 +582,7 @@ function installMcpAdapter(pi, options) {
587
582
  function registerScriptTool() {
588
583
  if (scriptToolRegistered)
589
584
  return;
590
- pi.registerTool({
585
+ toolLoader.registerFeature("script", {
591
586
  ...(beforeExecute ? { executionMode: "sequential" } : {}),
592
587
  name: "mcp_script",
593
588
  label: "MCP Script",
@@ -609,21 +604,8 @@ function installMcpAdapter(pi, options) {
609
604
  });
610
605
  scriptToolRegistered = true;
611
606
  }
612
- function syncScriptTool(config) {
613
- if (config.settings?.scriptMode === false) {
614
- if (scriptToolRegistered)
615
- deactivateTools(["mcp_script"]);
616
- return;
617
- }
618
- registerScriptTool();
619
- if (deactivatedTools.delete("mcp_script")) {
620
- const activeTools = pi.getActiveTools();
621
- if (!activeTools.includes("mcp_script"))
622
- pi.setActiveTools([...activeTools, "mcp_script"]);
623
- }
624
- }
625
607
  function registerProxyTool(description) {
626
- pi.registerTool({
608
+ toolLoader.registerFeature("gateway", {
627
609
  ...(beforeExecute ? { executionMode: "sequential" } : {}),
628
610
  name: "mcp", label: "MCP", description,
629
611
  promptSnippet: "MCP server operations, resources, saved result readback, and fallback tool calls",
@@ -684,7 +666,10 @@ function installMcpAdapter(pi, options) {
684
666
  proxyToolRegistered = true;
685
667
  proxyToolDescription = description;
686
668
  }
687
- function syncProxyTool(config, cache, directSpecs) {
669
+ function syncGateways(config, cache, directSpecs) {
670
+ const policy = {};
671
+ if (config.settings?.scriptMode !== false)
672
+ policy.script = {};
688
673
  const missingConfiguredDirectToolServers = getMissingConfiguredDirectToolServers(config, cache, envRaw === undefined || envRaw === "__none__" ? undefined : envDirectToolOverride);
689
674
  const hasPinnedResources = envRaw !== "__none__" && Object.entries(config.mcpServers).some(([server, definition]) => {
690
675
  if (definition.disabled === true)
@@ -706,21 +691,15 @@ function installMcpAdapter(pi, options) {
706
691
  if (!proxyToolRegistered || proxyToolDescription !== description) {
707
692
  registerProxyTool(description);
708
693
  }
709
- if (deactivatedTools.delete("mcp")) {
710
- const activeTools = pi.getActiveTools();
711
- if (!activeTools.includes("mcp")) {
712
- pi.setActiveTools([...activeTools, "mcp"]);
713
- }
714
- }
715
- return;
694
+ policy.gateway = { eager: hasPinnedResources };
716
695
  }
717
- if (proxyToolRegistered)
718
- deactivateTools(["mcp"]);
696
+ if (policy.script)
697
+ registerScriptTool();
698
+ return policy;
719
699
  }
720
700
  // A transform needs the session context before any configured tool surface is registered.
721
701
  if (!options.transformConfig) {
722
- syncProxyTool(earlyConfig, earlyCache, []);
723
- syncScriptTool(earlyConfig);
702
+ syncGateways(earlyConfig, earlyCache, []);
724
703
  }
725
704
  }
726
705
  export function createMcpAdapter(options = {}) {
@@ -223,7 +223,7 @@ export function formatMcpPayloadFile(file) {
223
223
  return `[MCP ${file.kind === "audio" ? "audio" : "binary resource"}${file.uri ? ` (${file.uri})` : ""}: ${file.mimeType ?? "unknown MIME type"}; not rendered. ${file.path ? `Saved file: ${file.path}` : `Could not save: ${file.error}`}]`;
224
224
  }
225
225
  export function formatMcpResultReference(ref) {
226
- return `[MCP result saved: ${JSON.stringify(ref)}. Inspect without repeating the call using mcp action "read-result" or tools.readResult with this ref. Optionally select a JSON Pointer with path.]`;
226
+ return `[MCP result saved: ${JSON.stringify(ref)}. Inspect without repeating the call using mcp action "read-result" or tools.readResult with this ref. If inactive, first mcp_search({ enable: ["gateway"] }) or mcp_search({ enable: ["script"] }). Optionally select a JSON Pointer with path.]`;
227
227
  }
228
228
  function summarizeMcpResult(result, rawBytes, artifact) {
229
229
  const { path: fullResultPath, error: resultWriteError } = artifact;
@@ -21,7 +21,7 @@ function disabledResult(mode, serverName) {
21
21
  details: { mode, error: "server_disabled", server: serverName, message },
22
22
  };
23
23
  }
24
- function getAuthRequiredMessage(state, serverName, defaultMessage = `Server "${serverName}" requires OAuth authentication. Run mcp({ action: "auth-start", server: "${serverName}" }) to get a browser URL, or /mcp-auth ${serverName} in an interactive local session.`) {
24
+ function getAuthRequiredMessage(state, serverName, defaultMessage = `Server "${serverName}" requires OAuth authentication. If mcp is inactive, first mcp_search({ enable: ["gateway"] }). Run mcp({ action: "auth-start", server: "${serverName}" }) to get a browser URL, or /mcp-auth ${serverName} in an interactive local session.`) {
25
25
  const oauth = state.config.mcpServers[serverName]?.oauth;
26
26
  if (oauth && oauth.crossAppAccess)
27
27
  defaultMessage = `Server "${serverName}" requires enterprise authorization. Check oauth.crossAppAccess IdP configuration and ID-token source, then retry.`;
@@ -33,7 +33,7 @@ function getAuthFailedMessage(state, serverName, message) {
33
33
  if (customGuidance || (oauth && oauth.crossAppAccess)) {
34
34
  return `OAuth authentication failed for "${serverName}": ${message}. ${getAuthRequiredMessage(state, serverName)}`;
35
35
  }
36
- return `OAuth authentication failed for "${serverName}": ${message}. Run mcp({ action: "auth-start", server: "${serverName}" }) to get a browser URL, or /mcp-auth ${serverName} in an interactive local session.`;
36
+ return `OAuth authentication failed for "${serverName}": ${message}. If mcp is inactive, first mcp_search({ enable: ["gateway"] }). Run mcp({ action: "auth-start", server: "${serverName}" }) to get a browser URL, or /mcp-auth ${serverName} in an interactive local session.`;
37
37
  }
38
38
  function getRedirectPort(authorizationUrl) {
39
39
  try {
@@ -91,7 +91,7 @@ async function attemptAutoAuth(state, serverName, signal) {
91
91
  if (!state.ui && !isNonInteractiveOAuth(definition.oauth)) {
92
92
  return {
93
93
  status: "failed",
94
- message: getAuthRequiredMessage(state, serverName, `Server "${serverName}" requires OAuth authentication. Run mcp({ action: "auth-start", server: "${serverName}" }) to get a browser URL, or /mcp-auth ${serverName} in an interactive local session.`),
94
+ message: getAuthRequiredMessage(state, serverName, `Server "${serverName}" requires OAuth authentication. If mcp is inactive, first mcp_search({ enable: ["gateway"] }). Run mcp({ action: "auth-start", server: "${serverName}" }) to get a browser URL, or /mcp-auth ${serverName} in an interactive local session.`),
95
95
  };
96
96
  }
97
97
  try {
@@ -268,7 +268,7 @@ export async function executeAuthStart(state, serverName, signal) {
268
268
  const definition = state.config.mcpServers[serverName];
269
269
  if (!definition) {
270
270
  return {
271
- content: [{ type: "text", text: `Server "${serverName}" not found. Use mcp({ action: "status" }) to see available servers.` }],
271
+ content: [{ type: "text", text: `Server "${serverName}" not found. Use mcp({ action: "status" }) to see available servers (load it first with mcp_search({ enable: ["gateway"] }) if inactive).` }],
272
272
  details: { mode: "auth-start", error: "not_found", server: serverName },
273
273
  };
274
274
  }
@@ -314,7 +314,7 @@ export async function executeAuthComplete(state, serverName, input, signal) {
314
314
  const definition = state.config.mcpServers[serverName];
315
315
  if (!definition) {
316
316
  return {
317
- content: [{ type: "text", text: `Server "${serverName}" not found. Use mcp({ action: "status" }) to see available servers.` }],
317
+ content: [{ type: "text", text: `Server "${serverName}" not found. Use mcp({ action: "status" }) to see available servers (load it first with mcp_search({ enable: ["gateway"] }) if inactive).` }],
318
318
  details: { mode: "auth-complete", error: "not_found", server: serverName },
319
319
  };
320
320
  }
@@ -372,7 +372,7 @@ export function executeDescribe(state, toolName, serverFilter) {
372
372
  return disabledResult("describe", disabled[0]);
373
373
  const suggestions = rankSuggestions(state, toolName, 5, serverFilter);
374
374
  const hint = suggestions.length > 0
375
- ? `Did you mean: ${suggestions.join(", ")}. Inspect with mcp({ action: "describe", tool: "${suggestions[0]}" }).`
375
+ ? `Did you mean: ${suggestions.join(", ")}. Inspect with mcp({ action: "describe", tool: "${suggestions[0]}" }). Load if inactive: mcp_search({ enable: ["gateway"] }).`
376
376
  : `Use mcp_search({ query: "..." }) to search.`;
377
377
  return {
378
378
  content: [{ type: "text", text: `Tool "${toolName}" not found. ${hint}` }],
@@ -461,7 +461,7 @@ export function executeList(state, server, limit = 12, offset = 0) {
461
461
  const definition = state.config.mcpServers[server];
462
462
  if (!definition) {
463
463
  return {
464
- content: [{ type: "text", text: `Server "${server}" not found. Use mcp({ action: "status" }) to see available servers.` }],
464
+ content: [{ type: "text", text: `Server "${server}" not found. Use mcp({ action: "status" }) to see available servers (load it first with mcp_search({ enable: ["gateway"] }) if inactive).` }],
465
465
  details: { mode: "list", server, tools: [], count: 0, error: "not_found" },
466
466
  };
467
467
  }
@@ -534,7 +534,7 @@ export function executeInstructions(state, server) {
534
534
  const definition = state.config.mcpServers[server];
535
535
  if (!definition) {
536
536
  return {
537
- content: [{ type: "text", text: `Server "${server}" not found. Use mcp({ action: "status" }) to see available servers.` }],
537
+ content: [{ type: "text", text: `Server "${server}" not found. Use mcp({ action: "status" }) to see available servers (load it first with mcp_search({ enable: ["gateway"] }) if inactive).` }],
538
538
  details: { mode: "instructions", server, error: "not_found" },
539
539
  };
540
540
  }
@@ -565,7 +565,7 @@ export async function executeConnect(state, serverName, signal, limit, offset) {
565
565
  const definition = state.config.mcpServers[serverName];
566
566
  if (!definition) {
567
567
  return {
568
- content: [{ type: "text", text: `Server "${serverName}" not found. Use mcp({ action: "status" }) to see available servers.` }],
568
+ content: [{ type: "text", text: `Server "${serverName}" not found. Use mcp({ action: "status" }) to see available servers (load it first with mcp_search({ enable: ["gateway"] }) if inactive).` }],
569
569
  details: { mode: "connect", error: "not_found", server: serverName },
570
570
  };
571
571
  }
@@ -962,6 +962,7 @@ export async function executeCall(state, toolName, args, serverOverride, getPiTo
962
962
  let hint = suggestions.length
963
963
  ? ` Did you mean: ${suggestions.join(", ")}. Inspect with mcp({ action: "describe", tool: ${JSON.stringify(suggestions[0])} }).`
964
964
  : serverName ? ` Search with mcp({ action: "search", query: ${JSON.stringify(toolName)}, server: ${JSON.stringify(serverName)} }).` : ' Use mcp({ action: "search", query: "..." }) to search.';
965
+ hint += ' If mcp is inactive, load it with mcp_search({ enable: ["gateway"] }).';
965
966
  if (serverName && state.toolMetadata.has(serverName) && state.manager.getConnection(serverName)?.status !== "connected")
966
967
  hint += ` Refresh a stale catalog with mcp({ action: "connect", server: ${JSON.stringify(serverName)} }).`;
967
968
  return {
@@ -973,7 +974,7 @@ export async function executeCall(state, toolName, args, serverOverride, getPiTo
973
974
  return missing();
974
975
  const definition = state.config.mcpServers[serverName];
975
976
  if (!definition)
976
- return { content: [{ type: "text", text: `Server "${serverName}" not found. Use mcp({ action: "status" }) to see available servers.` }], details: { mode: "call", error: "server_not_found", server: serverName, requestedTool: toolName } };
977
+ return { content: [{ type: "text", text: `Server "${serverName}" not found. Use mcp({ action: "status" }) to see available servers (load it first with mcp_search({ enable: ["gateway"] }) if inactive).` }], details: { mode: "call", error: "server_not_found", server: serverName, requestedTool: toolName } };
977
978
  if (isServerDisabled(definition))
978
979
  return disabledResult("call", serverName);
979
980
  const cached = state.toolMetadata.get(serverName);
@@ -7,6 +7,10 @@ type Reference = {
7
7
  name: string;
8
8
  namespace?: string;
9
9
  };
10
+ export type Feature = "gateway" | "script";
11
+ export type FeaturePolicy = Partial<Record<Feature, {
12
+ eager?: boolean;
13
+ }>>;
10
14
  type SearchDefinition<TParams extends TSchema, TDetails> = Omit<ToolDefinition<TParams, TDetails>, "execute"> & {
11
15
  execute: (...args: Parameters<ToolDefinition<TParams, TDetails>["execute"]>) => Promise<AgentToolResult<TDetails> & {
12
16
  tools: Reference[];
@@ -20,12 +24,13 @@ export type SearchAPI = ExtensionAPI & {
20
24
  export declare function supportsNativeAsync(ctx: ExtensionContext): boolean;
21
25
  export declare function createToolLoader(pi: SearchAPI, getState: () => McpExtensionState | null, getInitPromise: () => Promise<McpExtensionState> | null, beforeExecute?: McpAdapterOptions["beforeExecute"]): {
22
26
  native: boolean;
27
+ registerFeature: <TParams extends TSchema, TDetails>(feature: Feature, definition: ToolDefinition<TParams, TDetails>) => void;
23
28
  restore: (ctx: ExtensionContext) => void;
24
- sync: (config: McpConfig, cache: MetadataCache | null, env?: string[] | null, asyncSupported?: boolean, registerGateways?: (pins: DirectToolSpec[]) => void) => DirectToolSpec[];
29
+ sync: (config: McpConfig, cache: MetadataCache | null, env?: string[] | null, asyncSupported?: boolean, registerGateways?: (pins: DirectToolSpec[]) => FeaturePolicy) => DirectToolSpec[];
25
30
  activate: (matches: Array<{
26
31
  server: string;
27
32
  tool: ToolMetadata;
28
- }>) => Reference[];
33
+ }>, enable?: Feature[]) => Reference[];
29
34
  persist: () => void;
30
35
  selectedServers: () => string[];
31
36
  };
@@ -5,6 +5,7 @@ import { createMcpDirectToolCallRenderer, renderMcpToolResult } from "./tool-res
5
5
  import { isServerDisabled } from "./types.js";
6
6
  import { normalizeDirectToolInputSchema } from "./utils.js";
7
7
  import { isToolCallApprovalRequired } from "./tool-approval.js";
8
+ const featureRefs = { gateway: { name: "mcp" }, script: { name: "mcp_script" } };
8
9
  const ENTRY = "mcp-tool-selection";
9
10
  const key = (value) => JSON.stringify([value.server, value.tool]);
10
11
  const refKey = (value) => JSON.stringify([value.namespace ?? null, value.name]);
@@ -23,6 +24,10 @@ export function createToolLoader(pi, getState, getInitPromise, beforeExecute) {
23
24
  let available = new Set();
24
25
  let selected = new Map();
25
26
  let inactive = new Map();
27
+ let features = {};
28
+ let selectedFeatures = new Set();
29
+ let inactiveFeatures = new Set();
30
+ const registeredFeatures = new Set();
26
31
  let restoring = true;
27
32
  const active = () => native ? pi.getActiveToolReferences() : pi.getActiveTools().map(name => ({ name }));
28
33
  const setActive = (refs) => {
@@ -34,8 +39,24 @@ export function createToolLoader(pi, getState, getInitPromise, beforeExecute) {
34
39
  else
35
40
  pi.setActiveTools(unique.map(ref => ref.name));
36
41
  };
42
+ function registerFeature(feature, definition) {
43
+ pi.registerTool(definition);
44
+ registeredFeatures.add(feature);
45
+ }
37
46
  function capture() {
38
47
  const current = new Set(active().map(refKey));
48
+ for (const feature of Object.keys(features)) {
49
+ if (current.has(refKey(featureRefs[feature]))) {
50
+ if (!features[feature]?.eager)
51
+ selectedFeatures.add(feature);
52
+ inactiveFeatures.delete(feature);
53
+ }
54
+ else {
55
+ selectedFeatures.delete(feature);
56
+ if (features[feature]?.eager)
57
+ inactiveFeatures.add(feature);
58
+ }
59
+ }
39
60
  for (const [id, { selection, ref, pinned }] of registered) {
40
61
  if (!available.has(id))
41
62
  continue;
@@ -53,15 +74,19 @@ export function createToolLoader(pi, getState, getInitPromise, beforeExecute) {
53
74
  }
54
75
  function persist() {
55
76
  capture();
56
- pi.appendEntry(ENTRY, { selected: [...selected.values()], inactive: [...inactive.values()] });
77
+ pi.appendEntry(ENTRY, { selected: [...selected.values()], inactive: [...inactive.values()], features: [...selectedFeatures], inactiveFeatures: [...inactiveFeatures] });
57
78
  }
58
79
  function restore(ctx) {
59
80
  selected = new Map();
60
81
  inactive = new Map();
82
+ selectedFeatures = new Set();
83
+ inactiveFeatures = new Set();
61
84
  for (const entry of ctx.sessionManager.getBranch()) {
62
85
  if (entry.type !== "custom" || entry.customType !== ENTRY || !entry.data || typeof entry.data !== "object")
63
86
  continue;
64
87
  const data = entry.data;
88
+ selectedFeatures = new Set(Array.isArray(data.features) ? data.features.filter(value => value === "gateway" || value === "script") : []);
89
+ inactiveFeatures = new Set(Array.isArray(data.inactiveFeatures) ? data.inactiveFeatures.filter(value => value === "gateway" || value === "script") : []);
65
90
  for (const [field, target] of [[data.selected, selected], [data.inactive, inactive]]) {
66
91
  target.clear();
67
92
  for (const selection of Array.isArray(field) ? field : []) {
@@ -125,28 +150,54 @@ export function createToolLoader(pi, getState, getInitPromise, beforeExecute) {
125
150
  }
126
151
  const permitted = new Set(pi.getAllTools().map(refKey));
127
152
  const pins = specs.filter(item => item.pinned && available.has(key(item.selection)) && permitted.has(refKey(item.ref))).map(item => item.spec);
128
- registerGateways?.(pins);
153
+ const policy = registerGateways?.(pins);
154
+ if (policy) {
155
+ const permittedFeatures = new Set(pi.getAllTools().map(refKey));
156
+ const loaderMissing = !permittedFeatures.has(refKey({ name: "mcp_search" }));
157
+ features = {};
158
+ for (const feature of Object.keys(featureRefs)) {
159
+ if (policy[feature] && permittedFeatures.has(refKey(featureRefs[feature]))) {
160
+ features[feature] = { eager: policy[feature].eager || loaderMissing };
161
+ if (selectedFeatures.has(feature) || (features[feature]?.eager && !inactiveFeatures.has(feature)))
162
+ enabled.push(featureRefs[feature]);
163
+ }
164
+ else {
165
+ selectedFeatures.delete(feature);
166
+ inactiveFeatures.delete(feature);
167
+ }
168
+ }
169
+ }
129
170
  // Official 0.87 reactivates allowlisted definitions on registration. Apply our loadout last.
130
171
  const ownedRefs = new Set([...previousRefs, ...[...registered.values()].map(item => refKey(item.ref))]);
172
+ for (const feature of registeredFeatures)
173
+ ownedRefs.add(refKey(featureRefs[feature]));
131
174
  setActive([...active().filter(ref => !ownedRefs.has(refKey(ref))), ...enabled]);
132
175
  restoring = false;
133
176
  return pins;
134
177
  }
135
- function activate(matches) {
178
+ function activate(matches, enable = []) {
136
179
  const requested = matches.flatMap(({ server, tool }) => {
137
180
  const id = key({ server, tool: tool.originalName });
138
181
  const registration = available.has(id) ? registered.get(id) : undefined;
139
182
  return registration ? [registration] : [];
140
183
  });
141
184
  // No await between reading and extending the current loadout: parallel searches union their selections.
142
- setActive([...active(), ...requested.map(item => item.ref)]);
185
+ const permitted = new Set(pi.getAllTools().map(refKey));
186
+ const requestedFeatures = [...new Set(enable)].filter(feature => features[feature] && permitted.has(refKey(featureRefs[feature])));
187
+ setActive([...active(), ...requested.map(item => item.ref), ...requestedFeatures.map(feature => featureRefs[feature])]);
143
188
  const current = new Set(active().map(refKey));
144
189
  const tools = requested.filter(item => current.has(refKey(item.ref))).map(({ selection, ref }) => {
145
190
  selected.set(key(selection), selection);
146
191
  return ref;
147
192
  });
193
+ for (const feature of requestedFeatures) {
194
+ if (!current.has(refKey(featureRefs[feature])))
195
+ continue;
196
+ selectedFeatures.add(feature);
197
+ tools.push(featureRefs[feature]);
198
+ }
148
199
  persist();
149
200
  return tools;
150
201
  }
151
- return { native, restore, sync, activate, persist, selectedServers: () => [...new Set([...selected.values()].map(item => item.server))] };
202
+ return { native, registerFeature, restore, sync, activate, persist, selectedServers: () => [...new Set([...selected.values()].map(item => item.server))] };
152
203
  }
package/dist/ui-server.js CHANGED
@@ -481,7 +481,7 @@ export async function startUiServer(options) {
481
481
  }
482
482
  catch (error) {
483
483
  if (error instanceof SessionRecoveryAuthRequiredError) {
484
- const fallback = `Server "${options.serverName}" requires OAuth authentication. Run mcp({ action: "auth-start", server: "${options.serverName}" }) to get a browser URL, or /mcp-auth ${options.serverName} in an interactive local session.`;
484
+ const fallback = `Server "${options.serverName}" requires OAuth authentication. If mcp is inactive, first mcp_search({ enable: ["gateway"] }). Run mcp({ action: "auth-start", server: "${options.serverName}" }) to get a browser URL, or /mcp-auth ${options.serverName} in an interactive local session.`;
485
485
  const message = error.authMessage ?? (options.config
486
486
  ? formatAuthRequiredMessage(options.config, options.serverName, fallback)
487
487
  : fallback);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fitchmultz/pi-mcp-adapter",
3
- "version": "7.0.0",
3
+ "version": "7.1.0",
4
4
  "description": "Fitch's independently maintained MCP adapter for Pi coding agent",
5
5
  "type": "module",
6
6
  "types": "./dist/index.d.ts",
@@ -7,6 +7,8 @@ description: Write mcp_script JavaScript for discovering, inspecting, and callin
7
7
 
8
8
  Use `mcp_script` for MCP calls with loops, filtering, chaining, fan-out, or other logic between calls. Pass ordinary JavaScript as its `code` argument. For a single call, use an already loaded typed tool. Use `mcp_search({ query, server?, limit?, offset? })` to discover and load typed tools for the next request, or explicit `mcp` actions for gateway-only workflows. Discovery never executes a search hit.
9
9
 
10
+ If `mcp_script` is inactive, first call `mcp_search({ enable: ["script"] })`. For gateway actions, load `mcp` with `mcp_search({ enable: ["gateway"] })`. Enable both in one call when needed. Enabling does not connect to a server or run an integration action; selections stay active for the session/branch. Hosts permitting only one gateway without the loader keep that tool eager.
11
+
10
12
  ## Discover, inspect, then call
11
13
 
12
14
  ```js