@fitchmultz/pi-mcp-adapter 7.0.0 → 7.1.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.
@@ -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
@@ -15,11 +15,11 @@ import { createMcpRuntimeOwner, createOwnedUi, isAbortError } from "./runtime-ow
15
15
  import { publishMcpStatusShutdown } from "./mcp-status.js";
16
16
  import { DEFAULT_MCP_SCRIPT_TIMEOUT_MS, runMcpScript } from "./mcp-code.js";
17
17
  import { MAX_PAGE_SIZE, paginate, rankToolMatches } from "./search-ranking.js";
18
- import { createToolLoader, supportsNativeAsync } from "./tool-loader.js";
18
+ import { createToolLoader } 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,14 @@ 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
- let nativeAsyncSupported = false;
99
98
  let proxyToolRegistered = false;
100
99
  let proxyToolDescription = null;
101
100
  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
101
  function applyDirectToolConfigChanges(changes) {
117
102
  if (!state)
118
103
  return;
@@ -124,12 +109,9 @@ function installMcpAdapter(pi, options) {
124
109
  }
125
110
  }
126
111
  function syncToolSurface(ctx) {
127
- const config = state?.config ?? earlyConfig;
112
+ const config = state?.config ?? currentConfig;
128
113
  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
- });
114
+ toolLoader.sync(config, cache, envRaw === "__none__" ? null : envDirectToolOverride, specs => syncGateways(config, cache, specs));
133
115
  }
134
116
  const registeredPromptCommands = new Set();
135
117
  function registerPromptCommands(specs) {
@@ -219,7 +201,6 @@ function installMcpAdapter(pi, options) {
219
201
  }
220
202
  async function startSession(ctx) {
221
203
  runtimeCwd = ctx.cwd;
222
- nativeAsyncSupported = supportsNativeAsync(ctx);
223
204
  directToolsFrozen = false;
224
205
  const generation = ++lifecycleGeneration;
225
206
  const previousState = state;
@@ -260,15 +241,13 @@ function installMcpAdapter(pi, options) {
260
241
  if (options.transformConfig) {
261
242
  runtimeConfig = cloneMcpConfig(options.transformConfig(cloneMcpConfig(runtimeConfig), ctx));
262
243
  }
244
+ currentConfig = runtimeConfig;
263
245
  const metadataCacheEnabled = ctx.isProjectTrusted()
264
246
  || !isPathInsideProject(getMetadataCachePath(), ctx.cwd);
265
247
  toolLoader.restore(ctx);
266
248
  registerPromptCommands(resolveCachedPrompts(runtimeConfig, metadataCacheEnabled));
267
249
  const runtimeCache = loadMetadataCache(metadataCacheEnabled);
268
- toolLoader.sync(runtimeConfig, runtimeCache, envRaw === "__none__" ? null : envDirectToolOverride, nativeAsyncSupported, specs => {
269
- syncProxyTool(runtimeConfig, runtimeCache, specs);
270
- syncScriptTool(runtimeConfig);
271
- });
250
+ toolLoader.sync(runtimeConfig, runtimeCache, envRaw === "__none__" ? null : envDirectToolOverride, specs => syncGateways(runtimeConfig, runtimeCache, specs));
272
251
  const initialization = startInitialization(ctx, owner, oauthRuntime, runtimeConfig, generation);
273
252
  if (envRaw !== undefined && envRaw !== "__none__") {
274
253
  const missingEnvDirectTools = getMissingConfiguredDirectToolServers(runtimeConfig, runtimeCache, envDirectToolOverride);
@@ -283,7 +262,6 @@ function installMcpAdapter(pi, options) {
283
262
  syncToolSurface(ctx);
284
263
  });
285
264
  pi.on("model_select", (_event, ctx) => {
286
- nativeAsyncSupported = supportsNativeAsync(ctx);
287
265
  if (state)
288
266
  syncToolSurface(ctx);
289
267
  });
@@ -538,7 +516,17 @@ function installMcpAdapter(pi, options) {
538
516
  owner?.throwIfInactive();
539
517
  return state ?? { content: [{ type: "text", text: "MCP not initialized" }], details: { error: "not_initialized" } };
540
518
  }
519
+ const unavailableFeatures = (enable = [], tools) => enable.filter(feature => !tools.some(tool => tool.name === (feature === "gateway" ? "mcp" : "mcp_script")));
541
520
  async function searchAndLoad(params, signal, ctx) {
521
+ throwIfAborted(signal);
522
+ if (params.query === undefined && !params.enable?.length)
523
+ throw new Error("Provide query or enable.");
524
+ // Loading front doors does not require initialization or connect to a server.
525
+ if (params.query === undefined) {
526
+ const tools = toolLoader.activate([], params.enable);
527
+ const unavailable = unavailableFeatures(params.enable, tools);
528
+ 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 };
529
+ }
542
530
  const ready = await getToolState(signal);
543
531
  if ("content" in ready)
544
532
  return { ...ready, tools: [] };
@@ -551,23 +539,27 @@ function installMcpAdapter(pi, options) {
551
539
  const matches = params.query.trim() ? rankToolMatches(ready, params.query, params.server)
552
540
  : 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
541
  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");
542
+ const tools = toolLoader.activate(page.items, params.enable);
543
+ const unavailable = unavailableFeatures(params.enable, tools);
544
+ const typedCount = tools.filter(tool => tool.name !== "mcp" && tool.name !== "mcp_script").length;
545
+ const result = executeSearch(ready, params.query, params.server, typedCount < page.items.length, params.limit ?? 5, params.offset, "mcp_search");
556
546
  const guarded = await guardMcpOutput([
557
547
  ...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." },
548
+ { 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.' },
549
+ ...(unavailable.length ? [{ type: "text", text: `Unavailable under configuration or host policy: ${unavailable.join(", ")}` }] : []),
559
550
  ], resolveMcpOutputGuardOptions(ready.config.settings, ready.outputDirectory));
560
- return { content: guarded.content, details: { ...result.details, ...guardedMcpDetails(guarded), loaded: tools }, tools };
551
+ return { content: guarded.content, details: { ...result.details, ...guardedMcpDetails(guarded), loaded: tools, ...(unavailable.length ? { unavailable } : {}) }, tools };
561
552
  }
562
553
  const searchDefinition = {
563
554
  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.",
555
+ 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
556
  parameters: Type.Object({
566
- query: Type.String(),
557
+ query: Type.Optional(Type.String()),
558
+ enable: Type.Optional(Type.Array(Type.Union([Type.Literal("gateway"), Type.Literal("script")]), { minItems: 1, uniqueItems: true })),
567
559
  server: Type.Optional(Type.String()),
568
560
  limit: Type.Optional(Type.Integer({ minimum: 1, maximum: MAX_PAGE_SIZE, description: "Tools to load (default 5)." })),
569
561
  offset: Type.Optional(Type.Integer({ minimum: 0 })),
570
- }),
562
+ }, { anyOf: [{ required: ["query"] }, { required: ["enable"] }] }),
571
563
  renderResult: renderMcpToolResult,
572
564
  async execute(toolCallId, params, signal, _onUpdate, ctx) {
573
565
  if (beforeExecute) {
@@ -579,15 +571,12 @@ function installMcpAdapter(pi, options) {
579
571
  return searchAndLoad(params, signal, ctx);
580
572
  },
581
573
  };
582
- if (toolLoader.native)
583
- pi.registerToolSearch(searchDefinition);
584
- else
585
- pi.registerTool(searchDefinition);
574
+ pi.registerTool(searchDefinition);
586
575
  let scriptToolRegistered = false;
587
576
  function registerScriptTool() {
588
577
  if (scriptToolRegistered)
589
578
  return;
590
- pi.registerTool({
579
+ toolLoader.registerFeature("script", {
591
580
  ...(beforeExecute ? { executionMode: "sequential" } : {}),
592
581
  name: "mcp_script",
593
582
  label: "MCP Script",
@@ -609,21 +598,8 @@ function installMcpAdapter(pi, options) {
609
598
  });
610
599
  scriptToolRegistered = true;
611
600
  }
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
601
  function registerProxyTool(description) {
626
- pi.registerTool({
602
+ toolLoader.registerFeature("gateway", {
627
603
  ...(beforeExecute ? { executionMode: "sequential" } : {}),
628
604
  name: "mcp", label: "MCP", description,
629
605
  promptSnippet: "MCP server operations, resources, saved result readback, and fallback tool calls",
@@ -684,7 +660,10 @@ function installMcpAdapter(pi, options) {
684
660
  proxyToolRegistered = true;
685
661
  proxyToolDescription = description;
686
662
  }
687
- function syncProxyTool(config, cache, directSpecs) {
663
+ function syncGateways(config, cache, directSpecs) {
664
+ const policy = {};
665
+ if (config.settings?.scriptMode !== false)
666
+ policy.script = {};
688
667
  const missingConfiguredDirectToolServers = getMissingConfiguredDirectToolServers(config, cache, envRaw === undefined || envRaw === "__none__" ? undefined : envDirectToolOverride);
689
668
  const hasPinnedResources = envRaw !== "__none__" && Object.entries(config.mcpServers).some(([server, definition]) => {
690
669
  if (definition.disabled === true)
@@ -706,21 +685,15 @@ function installMcpAdapter(pi, options) {
706
685
  if (!proxyToolRegistered || proxyToolDescription !== description) {
707
686
  registerProxyTool(description);
708
687
  }
709
- if (deactivatedTools.delete("mcp")) {
710
- const activeTools = pi.getActiveTools();
711
- if (!activeTools.includes("mcp")) {
712
- pi.setActiveTools([...activeTools, "mcp"]);
713
- }
714
- }
715
- return;
688
+ policy.gateway = { eager: hasPinnedResources };
716
689
  }
717
- if (proxyToolRegistered)
718
- deactivateTools(["mcp"]);
690
+ if (policy.script)
691
+ registerScriptTool();
692
+ return policy;
719
693
  }
720
694
  // A transform needs the session context before any configured tool surface is registered.
721
695
  if (!options.transformConfig) {
722
- syncProxyTool(earlyConfig, earlyCache, []);
723
- syncScriptTool(earlyConfig);
696
+ syncGateways(earlyConfig, earlyCache, []);
724
697
  }
725
698
  }
726
699
  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);
@@ -691,7 +691,8 @@ export class McpServerManager {
691
691
  let client;
692
692
  client = new ManagedClient({ name: `pi-mcp-${serverName}`, version: ADAPTER_VERSION }, {
693
693
  versionNegotiation: { mode: definition.protocolVersion ?? (definition.url ? "auto" : "legacy") },
694
- listMaxPages: 0,
694
+ // ponytail: reject catalogs over 1024 pages; raise this native cap if a real catalog needs more.
695
+ listMaxPages: 1024,
695
696
  ...(Object.keys(capabilities).length > 0 ? { capabilities } : {}),
696
697
  listChanged: {
697
698
  tools: {
@@ -1,32 +1,22 @@
1
- import type { AgentToolResult, ExtensionAPI, ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
1
+ import type { ExtensionAPI, ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
2
2
  import { type TSchema } from "typebox";
3
3
  import { type MetadataCache } from "./metadata-cache.ts";
4
4
  import type { McpExtensionState } from "./state.ts";
5
5
  import { type DirectToolSpec, type McpAdapterOptions, type McpConfig, type ToolMetadata } from "./types.ts";
6
- type Reference = {
7
- name: string;
8
- namespace?: string;
9
- };
10
- type SearchDefinition<TParams extends TSchema, TDetails> = Omit<ToolDefinition<TParams, TDetails>, "execute"> & {
11
- execute: (...args: Parameters<ToolDefinition<TParams, TDetails>["execute"]>) => Promise<AgentToolResult<TDetails> & {
12
- tools: Reference[];
13
- }>;
14
- };
15
- export type SearchAPI = ExtensionAPI & {
16
- registerToolSearch?: <TParams extends TSchema, TDetails>(definition: SearchDefinition<TParams, TDetails>) => void;
17
- getActiveToolReferences?: () => Reference[];
18
- setActiveToolReferences?: (tools: Reference[]) => void;
19
- };
20
- export declare function supportsNativeAsync(ctx: ExtensionContext): boolean;
21
- export declare function createToolLoader(pi: SearchAPI, getState: () => McpExtensionState | null, getInitPromise: () => Promise<McpExtensionState> | null, beforeExecute?: McpAdapterOptions["beforeExecute"]): {
22
- native: boolean;
6
+ export type Feature = "gateway" | "script";
7
+ export type FeaturePolicy = Partial<Record<Feature, {
8
+ eager?: boolean;
9
+ }>>;
10
+ export declare function createToolLoader(pi: ExtensionAPI, getState: () => McpExtensionState | null, getInitPromise: () => Promise<McpExtensionState> | null, beforeExecute?: McpAdapterOptions["beforeExecute"]): {
11
+ registerFeature: <TParams extends TSchema, TDetails>(feature: Feature, definition: ToolDefinition<TParams, TDetails>) => void;
23
12
  restore: (ctx: ExtensionContext) => void;
24
- sync: (config: McpConfig, cache: MetadataCache | null, env?: string[] | null, asyncSupported?: boolean, registerGateways?: (pins: DirectToolSpec[]) => void) => DirectToolSpec[];
13
+ sync: (config: McpConfig, cache: MetadataCache | null, env?: string[] | null, registerGateways?: (pins: DirectToolSpec[]) => FeaturePolicy) => DirectToolSpec[];
25
14
  activate: (matches: Array<{
26
15
  server: string;
27
16
  tool: ToolMetadata;
28
- }>) => Reference[];
17
+ }>, enable?: Feature[]) => {
18
+ name: string;
19
+ }[];
29
20
  persist: () => void;
30
21
  selectedServers: () => string[];
31
22
  };
32
- export {};
@@ -4,42 +4,50 @@ import { isServerCacheValid, reconstructToolMetadata } from "./metadata-cache.js
4
4
  import { createMcpDirectToolCallRenderer, renderMcpToolResult } from "./tool-result-renderer.js";
5
5
  import { isServerDisabled } from "./types.js";
6
6
  import { normalizeDirectToolInputSchema } from "./utils.js";
7
- import { isToolCallApprovalRequired } from "./tool-approval.js";
7
+ const featureNames = { gateway: "mcp", script: "mcp_script" };
8
8
  const ENTRY = "mcp-tool-selection";
9
9
  const key = (value) => JSON.stringify([value.server, value.tool]);
10
- const refKey = (value) => JSON.stringify([value.namespace ?? null, value.name]);
11
10
  const reserved = new Set(["read", "bash", "powershell", "edit", "write", "grep", "find", "ls", "mcp", "mcp_search", "mcp_script"]);
12
- export function supportsNativeAsync(ctx) {
13
- const compat = ctx.model?.compat;
14
- return "getPendingToolCalls" in ctx && typeof ctx.getPendingToolCalls === "function"
15
- && !!compat && "supportsAsyncTools" in compat && compat.supportsAsyncTools === true;
16
- }
17
11
  export function createToolLoader(pi, getState, getInitPromise, beforeExecute) {
18
- const native = typeof pi.registerToolSearch === "function"
19
- && typeof pi.getActiveToolReferences === "function"
20
- && typeof pi.setActiveToolReferences === "function";
21
12
  pi.registerEntryRenderer(ENTRY, () => undefined);
22
13
  const registered = new Map();
23
14
  let available = new Set();
24
15
  let selected = new Map();
25
16
  let inactive = new Map();
17
+ let features = {};
18
+ let selectedFeatures = new Set();
19
+ let inactiveFeatures = new Set();
20
+ const registeredFeatures = new Set();
26
21
  let restoring = true;
27
- const active = () => native ? pi.getActiveToolReferences() : pi.getActiveTools().map(name => ({ name }));
28
- const setActive = (refs) => {
29
- const unique = [...new Map(refs.map(ref => [refKey(ref), ref])).values()];
22
+ const active = () => pi.getActiveTools();
23
+ const setActive = (names) => {
24
+ const unique = [...new Set(names)];
30
25
  if (JSON.stringify(active()) === JSON.stringify(unique))
31
26
  return;
32
- if (native)
33
- pi.setActiveToolReferences(unique);
34
- else
35
- pi.setActiveTools(unique.map(ref => ref.name));
27
+ pi.setActiveTools(unique);
36
28
  };
29
+ function registerFeature(feature, definition) {
30
+ pi.registerTool(definition);
31
+ registeredFeatures.add(feature);
32
+ }
37
33
  function capture() {
38
- const current = new Set(active().map(refKey));
39
- for (const [id, { selection, ref, pinned }] of registered) {
34
+ const current = new Set(active());
35
+ for (const feature of Object.keys(features)) {
36
+ if (current.has(featureNames[feature])) {
37
+ if (!features[feature]?.eager)
38
+ selectedFeatures.add(feature);
39
+ inactiveFeatures.delete(feature);
40
+ }
41
+ else {
42
+ selectedFeatures.delete(feature);
43
+ if (features[feature]?.eager)
44
+ inactiveFeatures.add(feature);
45
+ }
46
+ }
47
+ for (const [id, { selection, name, pinned }] of registered) {
40
48
  if (!available.has(id))
41
49
  continue;
42
- if (current.has(refKey(ref))) {
50
+ if (current.has(name)) {
43
51
  if (!pinned)
44
52
  selected.set(id, selection);
45
53
  inactive.delete(id);
@@ -53,15 +61,19 @@ export function createToolLoader(pi, getState, getInitPromise, beforeExecute) {
53
61
  }
54
62
  function persist() {
55
63
  capture();
56
- pi.appendEntry(ENTRY, { selected: [...selected.values()], inactive: [...inactive.values()] });
64
+ pi.appendEntry(ENTRY, { selected: [...selected.values()], inactive: [...inactive.values()], features: [...selectedFeatures], inactiveFeatures: [...inactiveFeatures] });
57
65
  }
58
66
  function restore(ctx) {
59
67
  selected = new Map();
60
68
  inactive = new Map();
69
+ selectedFeatures = new Set();
70
+ inactiveFeatures = new Set();
61
71
  for (const entry of ctx.sessionManager.getBranch()) {
62
72
  if (entry.type !== "custom" || entry.customType !== ENTRY || !entry.data || typeof entry.data !== "object")
63
73
  continue;
64
74
  const data = entry.data;
75
+ selectedFeatures = new Set(Array.isArray(data.features) ? data.features.filter(value => value === "gateway" || value === "script") : []);
76
+ inactiveFeatures = new Set(Array.isArray(data.inactiveFeatures) ? data.inactiveFeatures.filter(value => value === "gateway" || value === "script") : []);
65
77
  for (const [field, target] of [[data.selected, selected], [data.inactive, inactive]]) {
66
78
  target.clear();
67
79
  for (const selection of Array.isArray(field) ? field : []) {
@@ -72,11 +84,11 @@ export function createToolLoader(pi, getState, getInitPromise, beforeExecute) {
72
84
  }
73
85
  restoring = true;
74
86
  }
75
- function sync(config, cache, env, asyncSupported = false, registerGateways) {
87
+ function sync(config, cache, env, registerGateways) {
76
88
  if (!restoring)
77
89
  capture();
78
- const previousRefs = new Set([...registered.values()].map(item => refKey(item.ref)));
79
- const existing = new Set(pi.getAllTools().map(tool => refKey(tool)));
90
+ const previousNames = new Set([...registered.values()].map(item => item.name));
91
+ const existing = new Set(pi.getAllTools().map(tool => tool.name));
80
92
  const specs = [];
81
93
  for (const [server, definition] of Object.entries(env === null ? {} : config.mcpServers)) {
82
94
  if (isServerDisabled(definition))
@@ -89,27 +101,26 @@ export function createToolLoader(pi, getState, getInitPromise, beforeExecute) {
89
101
  if (tool.resourceUri)
90
102
  continue;
91
103
  const pinned = selection === true || (Array.isArray(selection) && selection.includes(tool.originalName));
92
- const ref = native && !pinned ? { namespace: `mcp_${server}`, name: tool.originalName } : { name: prefixedName };
93
- specs.push({ spec: { ...tool, prefixedName, serverName: server }, selection: { server, tool: tool.originalName }, ref, pinned });
104
+ specs.push({ spec: { ...tool, prefixedName, serverName: server }, selection: { server, tool: tool.originalName }, name: prefixedName, pinned });
94
105
  }
95
106
  }
96
107
  const counts = new Map();
97
- for (const { ref } of specs)
98
- counts.set(refKey(ref), (counts.get(refKey(ref)) ?? 0) + 1);
108
+ for (const { name } of specs)
109
+ counts.set(name, (counts.get(name) ?? 0) + 1);
99
110
  available = new Set();
100
111
  const enabled = [];
101
- for (const { spec, selection, ref, pinned } of specs) {
112
+ for (const { spec, selection, name, pinned } of specs) {
102
113
  const id = key(selection);
103
- const refId = refKey(ref);
104
- if ((!ref.namespace && reserved.has(ref.name)) || counts.get(refId) > 1 || (existing.has(refId) && !previousRefs.has(refId)))
114
+ if (reserved.has(name) || counts.get(name) > 1 || (existing.has(name) && !previousNames.has(name)))
105
115
  continue;
106
116
  available.add(id);
107
- const asyncTool = asyncSupported && !beforeExecute && !spec.uiResourceUri && !isToolCallApprovalRequired(config, spec.serverName, spec);
108
- const fingerprint = JSON.stringify({ spec, ref, asyncTool });
117
+ const fingerprint = JSON.stringify({ spec, name });
109
118
  if (registered.get(id)?.fingerprint !== fingerprint) {
110
119
  pi.registerTool({
111
- ...ref,
112
- ...(asyncTool ? { async: true } : {}),
120
+ name,
121
+ namespace: { name: `mcp_${spec.serverName}` },
122
+ // Inactive direct tools are not callable through codemode or nested execution.
123
+ defaultActive: false,
113
124
  ...(beforeExecute ? { executionMode: "sequential" } : {}),
114
125
  label: spec.title ?? `MCP: ${spec.originalName}`,
115
126
  description: spec.description || "(no description)",
@@ -119,34 +130,60 @@ export function createToolLoader(pi, getState, getInitPromise, beforeExecute) {
119
130
  renderResult: renderMcpToolResult,
120
131
  });
121
132
  }
122
- registered.set(id, { selection, ref, fingerprint, pinned });
133
+ registered.set(id, { selection, name, fingerprint, pinned });
123
134
  if (selected.has(id) || (pinned && !inactive.has(id)))
124
- enabled.push(ref);
135
+ enabled.push(name);
136
+ }
137
+ const permitted = new Set(pi.getAllTools().map(tool => tool.name));
138
+ const pins = specs.filter(item => item.pinned && available.has(key(item.selection)) && permitted.has(item.name)).map(item => item.spec);
139
+ const policy = registerGateways?.(pins);
140
+ if (policy) {
141
+ const permittedFeatures = new Set(pi.getAllTools().map(tool => tool.name));
142
+ const loaderMissing = !permittedFeatures.has("mcp_search");
143
+ features = {};
144
+ for (const feature of Object.keys(featureNames)) {
145
+ if (policy[feature] && permittedFeatures.has(featureNames[feature])) {
146
+ features[feature] = { eager: policy[feature].eager || loaderMissing };
147
+ if (selectedFeatures.has(feature) || (features[feature]?.eager && !inactiveFeatures.has(feature)))
148
+ enabled.push(featureNames[feature]);
149
+ }
150
+ else {
151
+ selectedFeatures.delete(feature);
152
+ inactiveFeatures.delete(feature);
153
+ }
154
+ }
125
155
  }
126
- const permitted = new Set(pi.getAllTools().map(refKey));
127
- const pins = specs.filter(item => item.pinned && available.has(key(item.selection)) && permitted.has(refKey(item.ref))).map(item => item.spec);
128
- registerGateways?.(pins);
129
156
  // Official 0.87 reactivates allowlisted definitions on registration. Apply our loadout last.
130
- const ownedRefs = new Set([...previousRefs, ...[...registered.values()].map(item => refKey(item.ref))]);
131
- setActive([...active().filter(ref => !ownedRefs.has(refKey(ref))), ...enabled]);
157
+ const ownedNames = new Set([...previousNames, ...[...registered.values()].map(item => item.name)]);
158
+ for (const feature of registeredFeatures)
159
+ ownedNames.add(featureNames[feature]);
160
+ setActive([...active().filter(name => !ownedNames.has(name)), ...enabled]);
132
161
  restoring = false;
133
162
  return pins;
134
163
  }
135
- function activate(matches) {
164
+ function activate(matches, enable = []) {
136
165
  const requested = matches.flatMap(({ server, tool }) => {
137
166
  const id = key({ server, tool: tool.originalName });
138
167
  const registration = available.has(id) ? registered.get(id) : undefined;
139
168
  return registration ? [registration] : [];
140
169
  });
141
170
  // No await between reading and extending the current loadout: parallel searches union their selections.
142
- setActive([...active(), ...requested.map(item => item.ref)]);
143
- const current = new Set(active().map(refKey));
144
- const tools = requested.filter(item => current.has(refKey(item.ref))).map(({ selection, ref }) => {
171
+ const permitted = new Set(pi.getAllTools().map(tool => tool.name));
172
+ const requestedFeatures = [...new Set(enable)].filter(feature => features[feature] && permitted.has(featureNames[feature]));
173
+ setActive([...active(), ...requested.map(item => item.name), ...requestedFeatures.map(feature => featureNames[feature])]);
174
+ const current = new Set(active());
175
+ const tools = requested.filter(item => current.has(item.name)).map(({ selection, name }) => {
145
176
  selected.set(key(selection), selection);
146
- return ref;
177
+ return { name };
147
178
  });
179
+ for (const feature of requestedFeatures) {
180
+ if (!current.has(featureNames[feature]))
181
+ continue;
182
+ selectedFeatures.add(feature);
183
+ tools.push({ name: featureNames[feature] });
184
+ }
148
185
  persist();
149
186
  return tools;
150
187
  }
151
- return { native, restore, sync, activate, persist, selectedServers: () => [...new Set([...selected.values()].map(item => item.server))] };
188
+ return { registerFeature, restore, sync, activate, persist, selectedServers: () => [...new Set([...selected.values()].map(item => item.server))] };
152
189
  }
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);