@fitchmultz/pi-mcp-adapter 5.0.0 → 6.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/CHANGELOG.md +25 -1
  2. package/OAUTH.md +3 -3
  3. package/README.md +153 -85
  4. package/app-bridge.bundle.js +75 -33
  5. package/commands.ts +9 -6
  6. package/direct-tools.ts +25 -358
  7. package/dist/app-bridge.bundle.js +75 -33
  8. package/dist/commands.js +9 -6
  9. package/dist/direct-tools.d.ts +2 -4
  10. package/dist/direct-tools.js +21 -319
  11. package/dist/gateway-arguments.d.ts +33 -0
  12. package/dist/gateway-arguments.js +63 -0
  13. package/dist/index.js +181 -276
  14. package/dist/init.js +6 -20
  15. package/dist/mcp-code.d.ts +2 -1
  16. package/dist/mcp-code.js +87 -41
  17. package/dist/mcp-output-guard.d.ts +57 -2
  18. package/dist/mcp-output-guard.js +129 -24
  19. package/dist/mcp-panel.d.ts +1 -1
  20. package/dist/mcp-panel.js +34 -97
  21. package/dist/mcp-script-worker.mjs +5 -2
  22. package/dist/mcp-status.js +2 -1
  23. package/dist/metadata-cache.d.ts +1 -1
  24. package/dist/metadata-cache.js +25 -88
  25. package/dist/prompts.d.ts +1 -1
  26. package/dist/prompts.js +1 -1
  27. package/dist/proxy-modes.d.ts +40 -6
  28. package/dist/proxy-modes.js +270 -485
  29. package/dist/resource-tools.d.ts +15 -0
  30. package/dist/resource-tools.js +26 -1
  31. package/dist/sampling-handler.js +4 -1
  32. package/dist/search-ranking.d.ts +0 -2
  33. package/dist/search-ranking.js +66 -137
  34. package/dist/state.d.ts +1 -1
  35. package/dist/tool-loader.d.ts +32 -0
  36. package/dist/tool-loader.js +152 -0
  37. package/dist/tool-metadata.d.ts +67 -0
  38. package/dist/tool-metadata.js +23 -17
  39. package/dist/tool-registrar.d.ts +4 -4
  40. package/dist/tool-registrar.js +18 -13
  41. package/dist/tool-result-renderer.d.ts +3 -0
  42. package/dist/tool-result-renderer.js +8 -3
  43. package/dist/types.d.ts +11 -36
  44. package/dist/utils.js +2 -4
  45. package/gateway-arguments.ts +73 -0
  46. package/index.ts +166 -299
  47. package/init.ts +6 -20
  48. package/mcp-code.ts +71 -45
  49. package/mcp-output-guard.ts +145 -27
  50. package/mcp-panel.ts +38 -109
  51. package/mcp-script-worker.mjs +5 -2
  52. package/mcp-status.ts +2 -1
  53. package/metadata-cache.ts +31 -93
  54. package/package.json +14 -9
  55. package/prompts.ts +1 -1
  56. package/proxy-modes.ts +265 -517
  57. package/resource-tools.ts +36 -1
  58. package/sampling-handler.ts +4 -1
  59. package/scripts/build-app-bridge.mjs +15 -0
  60. package/scripts/build.mjs +3 -2
  61. package/scripts/prepare.mjs +1 -0
  62. package/search-ranking.ts +71 -135
  63. package/skills/mcp-scripting/SKILL.md +67 -20
  64. package/state.ts +1 -1
  65. package/tool-loader.ts +153 -0
  66. package/tool-metadata.ts +22 -17
  67. package/tool-registrar.ts +18 -13
  68. package/tool-result-renderer.ts +10 -2
  69. package/types.ts +15 -40
  70. package/utils.ts +2 -4
  71. package/dist/ts-shape.d.ts +0 -2
  72. package/dist/ts-shape.js +0 -147
  73. package/ts-shape.ts +0 -145
package/index.ts CHANGED
@@ -5,20 +5,24 @@ import type { McpOAuthRuntime } from "./mcp-auth-flow.ts";
5
5
  import { Type } from "typebox";
6
6
  import { showStatus, showTools, showPrompts, reconnectServer, reconnectServers, authenticateServer, logoutServer, openMcpAuthPanel, openMcpPanel, openMcpSetup } from "./commands.ts";
7
7
  import { cloneMcpConfig, isPathInsideProject, loadMcpConfig, writeProjectServerDisabledOverride } from "./config.ts";
8
- import { buildProxyDescription, createDirectToolExecutor, getMissingConfiguredDirectToolServers, resolveDirectTools } from "./direct-tools.ts";
9
- import { flushMetadataCache, initializeMcp, updateStatusBar } from "./init.ts";
10
- import { getMetadataCachePath, loadMetadataCache, type MetadataCache } from "./metadata-cache.ts";
8
+ import { buildProxyDescription, directToolSelection, getMissingConfiguredDirectToolServers } from "./direct-tools.ts";
9
+ import { flushMetadataCache, initializeMcp, lazyConnect, updateStatusBar } from "./init.ts";
10
+ import { getMetadataCachePath, isServerCacheValid, loadMetadataCache, reconstructToolMetadata, type MetadataCache } from "./metadata-cache.ts";
11
11
  import { createPromptCommand, resolveCachedPrompts } from "./prompts.ts";
12
12
  import { logger } from "./logger.ts";
13
13
  import { executeAuthComplete, executeAuthStart, executeCall, executeConnect, executeDescribe, executeInstructions, executeList, executeSearch, executeStatus, executeUiMessages } from "./proxy-modes.ts";
14
- import { formatTerminalError, normalizeDirectToolInputSchema, truncateAtWord } from "./utils.ts";
14
+ import { formatTerminalError } from "./utils.ts";
15
15
  import { createOAuthRuntime, shutdownOAuth } from "./mcp-auth-flow.ts";
16
- import { createMcpDirectToolCallRenderer, renderMcpProxyToolCall, renderMcpToolResult } from "./tool-result-renderer.ts";
16
+ import { renderMcpProxyToolCall, renderMcpToolResult } from "./tool-result-renderer.ts";
17
17
  import { toolErrorOverride } from "./error-signal.ts";
18
18
  import { createMcpRuntimeOwner, createOwnedUi, isAbortError, type McpRuntimeOwner } from "./runtime-owner.ts";
19
19
  import { publishMcpStatusShutdown } from "./mcp-status.ts";
20
20
  import { DEFAULT_MCP_SCRIPT_TIMEOUT_MS, runMcpScript } from "./mcp-code.ts";
21
- import { MAX_PAGE_SIZE, MAX_TOOL_NAME_LENGTH } from "./search-ranking.ts";
21
+ import { MAX_PAGE_SIZE, paginate, rankToolMatches } from "./search-ranking.ts";
22
+ import { createToolLoader, supportsNativeAsync, type SearchAPI } from "./tool-loader.ts";
23
+ import { gatewayParameters, prepareGatewayArguments, type GatewayArguments } from "./gateway-arguments.ts";
24
+ import { executeResourceList, executeResourceRead } from "./resource-tools.ts";
25
+ import { guardMcpOutput, guardedMcpDetails, readMcpResult, resolveMcpOutputGuardOptions } from "./mcp-output-guard.ts";
22
26
  import { abortable } from "./abort.ts";
23
27
  import { onSessionCheckpoint, prepareMcpCheckpoint } from "./checkpoint.ts";
24
28
 
@@ -33,13 +37,7 @@ export {
33
37
 
34
38
  const INIT_WAIT_TIMEOUT_MS = 30_000;
35
39
  const INIT_WAIT_TIMED_OUT: unique symbol = Symbol("init-wait-timed-out");
36
- const inactiveDirectToolsKey = Symbol.for("fitch-mcp-adapter.inactive-direct-tools");
37
- const inactiveDirectToolsBySession = (() => {
38
- const shared = globalThis as typeof globalThis & {
39
- [inactiveDirectToolsKey]?: Map<string, Set<string>>;
40
- };
41
- return shared[inactiveDirectToolsKey] ??= new Map<string, Set<string>>();
42
- })();
40
+
43
41
 
44
42
  async function awaitWithTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T | typeof INIT_WAIT_TIMED_OUT> {
45
43
  let timer: ReturnType<typeof setTimeout> | undefined;
@@ -91,55 +89,14 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
91
89
  const earlyCache: MetadataCache | null = null;
92
90
  const envRaw = process.env.MCP_DIRECT_TOOLS;
93
91
  const envDirectToolOverride = envRaw?.split(",").map(s => s.trim()).filter(Boolean);
94
- const registeredDirectTools = new Map<string, string>();
92
+ const toolLoader = createToolLoader(pi, () => state, () => initPromise, beforeExecute);
95
93
  const deactivatedTools = new Set<string>();
96
- const preservedInactiveTools = new Set<string>();
97
- let activationKey: string | null = null;
94
+ let runtimeCwd: string | undefined;
95
+ let nativeAsyncSupported = false;
98
96
  let proxyToolRegistered = false;
99
97
  let proxyToolDescription: string | null = null;
100
98
  let directToolsFrozen = false;
101
99
 
102
- function directToolFingerprint(spec: DirectToolSpec): string {
103
- return JSON.stringify({
104
- serverName: spec.serverName,
105
- originalName: spec.originalName,
106
- prefixedName: spec.prefixedName,
107
- description: spec.description,
108
- inputSchema: spec.inputSchema,
109
- annotations: spec.annotations,
110
- resourceUri: spec.resourceUri,
111
- uiResourceUri: spec.uiResourceUri,
112
- uiStreamMode: spec.uiStreamMode,
113
- });
114
- }
115
-
116
- function registerDirectTool(spec: DirectToolSpec): void {
117
- const execute = createDirectToolExecutor(() => state, () => initPromise, spec, beforeExecute);
118
- pi.registerTool({
119
- ...(beforeExecute ? { executionMode: "sequential" as const } : {}),
120
- name: spec.prefixedName,
121
- label: `MCP: ${spec.originalName}`,
122
- description: spec.description || "(no description)",
123
- promptSnippet: truncateAtWord(spec.description, 100) || `MCP tool from ${spec.serverName}`,
124
- parameters: Type.Unsafe<Record<string, unknown>>(normalizeDirectToolInputSchema(spec.inputSchema)),
125
- execute,
126
- renderCall: createMcpDirectToolCallRenderer(spec.prefixedName),
127
- renderResult: renderMcpToolResult,
128
- });
129
- if (preservedInactiveTools.has(spec.prefixedName)) {
130
- const activeTools = pi.getActiveTools();
131
- if (activeTools.includes(spec.prefixedName)) {
132
- pi.setActiveTools(activeTools.filter((name) => name !== spec.prefixedName));
133
- }
134
- }
135
- }
136
-
137
- function resolveCurrentDirectTools(config: McpConfig, cache: MetadataCache | null): DirectToolSpec[] {
138
- if (envRaw === "__none__") return [];
139
- const prefix = config.settings?.toolPrefix ?? "server";
140
- return resolveDirectTools(config, cache, prefix, envDirectToolOverride);
141
- }
142
-
143
100
  function deactivateTools(toolNames: string[]): void {
144
101
  if (toolNames.length === 0) return;
145
102
  const remove = new Set(toolNames);
@@ -153,44 +110,6 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
153
110
  }
154
111
  }
155
112
 
156
- function syncDirectTools(config: McpConfig, cache: MetadataCache | null): {
157
- specs: DirectToolSpec[];
158
- added: string[];
159
- updated: string[];
160
- deactivated: string[];
161
- } {
162
- const specs = resolveCurrentDirectTools(config, cache);
163
- const nextNames = new Set(specs.map((spec) => spec.prefixedName));
164
- const added: string[] = [];
165
- const updated: string[] = [];
166
- const deactivated: string[] = [];
167
-
168
- for (const spec of specs) {
169
- const fingerprint = directToolFingerprint(spec);
170
- const previous = registeredDirectTools.get(spec.prefixedName);
171
- if (previous !== fingerprint) {
172
- registerDirectTool(spec);
173
- registeredDirectTools.set(spec.prefixedName, fingerprint);
174
- if (deactivatedTools.delete(spec.prefixedName)) {
175
- const activeTools = pi.getActiveTools();
176
- if (!activeTools.includes(spec.prefixedName)) {
177
- pi.setActiveTools([...activeTools, spec.prefixedName]);
178
- }
179
- }
180
- (previous ? updated : added).push(spec.prefixedName);
181
- }
182
- }
183
-
184
- for (const toolName of [...registeredDirectTools.keys()]) {
185
- if (nextNames.has(toolName)) continue;
186
- registeredDirectTools.delete(toolName);
187
- deactivated.push(toolName);
188
- }
189
-
190
- deactivateTools(deactivated);
191
- return { specs, added, updated, deactivated };
192
- }
193
-
194
113
  function applyDirectToolConfigChanges(changes: Map<string, true | string[] | false>): void {
195
114
  if (!state) return;
196
115
  for (const [serverName, value] of changes) {
@@ -203,16 +122,10 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
203
122
  function syncToolSurface(ctx?: ExtensionContext): void {
204
123
  const config = state?.config ?? earlyConfig;
205
124
  const cache = loadMetadataCache(state?.metadataCacheEnabled ?? false);
206
- const result = syncDirectTools(config, cache);
207
- syncProxyTool(config, cache, result.specs);
208
- syncScriptTool(config);
209
- const changed = result.added.length + result.updated.length + result.deactivated.length;
210
- if (changed > 0 && ctx?.hasUI) {
211
- ctx.ui.notify(
212
- `MCP: direct tools refreshed (+${result.added.length}, ~${result.updated.length}, -${result.deactivated.length})`,
213
- "info",
214
- );
215
- }
125
+ toolLoader.sync(config, cache, envRaw === "__none__" ? null : envDirectToolOverride, nativeAsyncSupported, specs => {
126
+ syncProxyTool(config, cache, specs);
127
+ syncScriptTool(config);
128
+ });
216
129
  }
217
130
 
218
131
  const registeredPromptCommands = new Set<string>();
@@ -277,13 +190,17 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
277
190
  }
278
191
  syncToolSurface(ctx);
279
192
  };
193
+ for (const server of toolLoader.selectedServers()) {
194
+ if (!nextState.toolMetadata.has(server) && nextState.config.mcpServers[server]) await lazyConnect(nextState, server, owner.signal);
195
+ }
196
+ owner.throwIfInactive();
280
197
  syncPromptCommands();
281
198
  syncToolSurface(ctx);
282
199
  updateStatusBar(nextState);
283
200
  initPromise = null;
284
201
  if (nextState.config.settings?.freezeDirectTools === true) {
285
202
  directToolsFrozen = true;
286
- logger.info("MCP: direct tools frozen after initial sync — reconnects won't rebuild the system prompt; use mcp({ connect: \"server\" }) to rediscover");
203
+ logger.info("MCP: direct tools frozen after initial sync — reconnects won't rebuild the system prompt; use mcp({ action: \"connect\", server: \"server\" }) to rediscover");
287
204
  }
288
205
  }).catch(async err => {
289
206
  if (!owner.isActive() || generation !== lifecycleGeneration) {
@@ -307,7 +224,10 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
307
224
  });
308
225
  }
309
226
 
310
- pi.on("session_start", async (_event, ctx) => {
227
+ async function startSession(ctx: ExtensionContext) {
228
+ runtimeCwd = ctx.cwd;
229
+ nativeAsyncSupported = supportsNativeAsync(ctx);
230
+ directToolsFrozen = false;
311
231
  const generation = ++lifecycleGeneration;
312
232
  const previousState = state;
313
233
  const previousOwner = currentOwner;
@@ -353,16 +273,13 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
353
273
  }
354
274
  const metadataCacheEnabled = ctx.isProjectTrusted()
355
275
  || !isPathInsideProject(getMetadataCachePath(), ctx.cwd);
356
- activationKey = `${ctx.cwd}\0${currentConfigPath ?? ""}`;
357
- preservedInactiveTools.clear();
358
- for (const toolName of inactiveDirectToolsBySession.get(activationKey) ?? []) {
359
- preservedInactiveTools.add(toolName);
360
- }
276
+ toolLoader.restore(ctx);
361
277
  registerPromptCommands(resolveCachedPrompts(runtimeConfig, metadataCacheEnabled));
362
278
  const runtimeCache = loadMetadataCache(metadataCacheEnabled);
363
- const cachedDirectTools = syncDirectTools(runtimeConfig, runtimeCache).specs;
364
- syncProxyTool(runtimeConfig, runtimeCache, cachedDirectTools);
365
- syncScriptTool(runtimeConfig);
279
+ toolLoader.sync(runtimeConfig, runtimeCache, envRaw === "__none__" ? null : envDirectToolOverride, nativeAsyncSupported, specs => {
280
+ syncProxyTool(runtimeConfig, runtimeCache, specs);
281
+ syncScriptTool(runtimeConfig);
282
+ });
366
283
 
367
284
  const initialization = startInitialization(ctx, owner, oauthRuntime, runtimeConfig, generation);
368
285
  if (envRaw !== undefined && envRaw !== "__none__") {
@@ -375,6 +292,19 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
375
292
  await initialization;
376
293
  }
377
294
  }
295
+ }
296
+
297
+ pi.on("session_start", (_event, ctx) => startSession(ctx));
298
+ pi.on("session_tree", (_event, ctx) => {
299
+ toolLoader.restore(ctx);
300
+ syncToolSurface(ctx);
301
+ });
302
+ pi.on("model_select", (_event, ctx) => {
303
+ nativeAsyncSupported = supportsNativeAsync(ctx);
304
+ if (state) syncToolSurface(ctx);
305
+ });
306
+ pi.on("before_agent_start", async (_event, ctx) => {
307
+ if (ctx.cwd !== runtimeCwd) await startSession(ctx);
378
308
  });
379
309
 
380
310
  onSessionCheckpoint(pi, async event => {
@@ -384,14 +314,8 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
384
314
  });
385
315
 
386
316
  pi.on("session_shutdown", async () => {
387
- if (activationKey) {
388
- const activeTools = new Set(pi.getActiveTools());
389
- const inactiveTools = new Set(
390
- [...registeredDirectTools.keys()].filter((name) => !activeTools.has(name)),
391
- );
392
- if (inactiveTools.size > 0) inactiveDirectToolsBySession.set(activationKey, inactiveTools);
393
- else inactiveDirectToolsBySession.delete(activationKey);
394
- }
317
+ // Cached tool selections exist before background server initialization finishes.
318
+ toolLoader.persist();
395
319
  ++lifecycleGeneration;
396
320
  const currentState = state;
397
321
  const owner = currentOwner;
@@ -615,6 +539,68 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
615
539
  },
616
540
  });
617
541
 
542
+ async function getToolState(signal?: AbortSignal) {
543
+ const owner = currentOwner;
544
+ if (!state && initPromise) {
545
+ try {
546
+ const initialized = await awaitWithTimeout(abortable(initPromise, signal), INIT_WAIT_TIMEOUT_MS);
547
+ if (initialized === INIT_WAIT_TIMED_OUT) return {
548
+ content: [{ type: "text" as const, text: "MCP initialization is still in progress. Try again shortly." }],
549
+ details: { error: "init_timeout", timeoutMs: INIT_WAIT_TIMEOUT_MS },
550
+ };
551
+ owner?.throwIfInactive();
552
+ state = initialized;
553
+ } catch (error) {
554
+ if (signal?.aborted || (owner && isAbortError(error, owner.signal))) throw error;
555
+ const message = error instanceof Error ? error.message : String(error);
556
+ return { content: [{ type: "text" as const, text: `MCP initialization failed: ${message}` }], details: { error: "init_failed", message } };
557
+ }
558
+ }
559
+ owner?.throwIfInactive();
560
+ return state ?? { content: [{ type: "text" as const, text: "MCP not initialized" }], details: { error: "not_initialized" } };
561
+ }
562
+
563
+ async function searchAndLoad(params: { query: string; server?: string; limit?: number; offset?: number }, signal: AbortSignal | undefined, ctx: ExtensionContext) {
564
+ const ready = await getToolState(signal);
565
+ if ("content" in ready) return { ...ready, tools: [] };
566
+ if (params.server && !ready.toolMetadata.has(params.server)) {
567
+ const discovery = await executeConnect(ready, params.server, signal);
568
+ if (discovery.details.error) return { ...discovery, tools: [] };
569
+ }
570
+ syncToolSurface(ctx);
571
+ const matches = params.query.trim() ? rankToolMatches(ready, params.query, params.server)
572
+ : 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)) : [];
573
+ const page = paginate(matches, params.offset ?? 0, params.limit ?? 5);
574
+ const tools = toolLoader.activate(page.items);
575
+ const result = executeSearch(ready, params.query, params.server, tools.length < page.items.length, params.limit ?? 5, params.offset);
576
+ const guarded = await guardMcpOutput([
577
+ ...result.content,
578
+ { 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." },
579
+ ], resolveMcpOutputGuardOptions(ready.config.settings, ready.outputDirectory));
580
+ return { content: guarded.content, details: { ...result.details, ...guardedMcpDetails(guarded), loaded: tools }, tools };
581
+ }
582
+
583
+ const searchDefinition = {
584
+ name: "mcp_search", label: "MCP Search",
585
+ 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.",
586
+ parameters: Type.Object({
587
+ query: Type.String(),
588
+ server: Type.Optional(Type.String()),
589
+ limit: Type.Optional(Type.Integer({ minimum: 1, maximum: MAX_PAGE_SIZE, description: "Tools to load (default 5)." })),
590
+ offset: Type.Optional(Type.Integer({ minimum: 0 })),
591
+ }),
592
+ renderResult: renderMcpToolResult,
593
+ async execute(toolCallId: string, params: { query: string; server?: string; limit?: number; offset?: number }, signal: AbortSignal | undefined, _onUpdate: AgentToolUpdateCallback<Record<string, unknown>> | undefined, ctx: ExtensionContext) {
594
+ if (beforeExecute) {
595
+ if (currentOwner) await currentOwner.runCallback("beforeExecute", () => beforeExecute(toolCallId, ctx));
596
+ else await beforeExecute(toolCallId, ctx);
597
+ }
598
+ return searchAndLoad(params, signal, ctx);
599
+ },
600
+ };
601
+ if (toolLoader.native) (pi as SearchAPI).registerToolSearch!(searchDefinition);
602
+ else pi.registerTool(searchDefinition);
603
+
618
604
  let scriptToolRegistered = false;
619
605
 
620
606
  function registerScriptTool(): void {
@@ -623,7 +609,7 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
623
609
  ...(beforeExecute ? { executionMode: "sequential" as const } : {}),
624
610
  name: "mcp_script",
625
611
  label: "MCP Script",
626
- description: "Run trusted JavaScript that makes multiple MCP tool calls in one request — loop, filter, chain, or fan out between calls. For a single MCP call, search, describe, status check, or auth action, use the mcp tool instead. Discover with await tools.search({ query }) — resolves to { items: [{ path, name, server, description? }], total, hasMore, nextOffset }, not an { ok, data } envelope. Inspect with await tools.describe({ path }) — resolves to the tool descriptor with inputTypeScript when representable, otherwise the original JSON inputSchema, or { path, error: { code, message, suggestions } }. Then call tools.call(path, args) — resolves to { ok: true, data } or { ok: false, error: { code, message } }; data is the raw MCP result: tool calls usually return { content, structuredContent? }, while resource reads return { contents }. The sandbox has no Node, filesystem, or network globals. Use direct flat calls when the name is already known; use emit(value) for user-visible output. Load the mcp-scripting skill for the full workflow guide.",
612
+ description: "Compose MCP calls with trusted JavaScript: await tools.search({query, server?}), tools.describe({path}), tools.call(path,args). Describe returns complete JSON schemas. Calls return {ok,data} with raw MCP results, or {ok:false,error,data?}. Resources: tools.resources({server}), tools.readResource({server,uri}). Saved results: tools.readResult({ref,path?,fields?,offset?,limit?}); never repeats a remote call. Use emit(value) to return selected data or content blocks. No Node, filesystem, or network globals. Prefer a direct tool for one call; use scripts for loops, filtering and chains.",
627
613
  promptSnippet: "Batch multiple MCP tool calls in one JavaScript request (loop, filter, chain)",
628
614
  parameters: Type.Object({
629
615
  code: Type.String({ description: "Trusted JavaScript MCP script. Use tools.<prefixedToolName>(args) and emit(value)." }),
@@ -633,35 +619,9 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
633
619
  }),
634
620
  renderResult: renderMcpToolResult,
635
621
  async execute(toolCallId: string, params: { code: string; timeoutMs?: number }, signal: AbortSignal | undefined, _onUpdate: AgentToolUpdateCallback<Record<string, unknown>> | undefined, ctx: ExtensionContext) {
636
- const executeOwner = currentOwner;
637
- if (!state && initPromise) {
638
- try {
639
- const initialized = await awaitWithTimeout(abortable(initPromise, signal), INIT_WAIT_TIMEOUT_MS);
640
- if (initialized === INIT_WAIT_TIMED_OUT) {
641
- return {
642
- content: [{ type: "text" as const, text: "MCP initialization is still in progress. Try again shortly." }],
643
- details: { mode: "script", error: "init_timeout", timeoutMs: INIT_WAIT_TIMEOUT_MS },
644
- };
645
- }
646
- executeOwner?.throwIfInactive();
647
- state = initialized;
648
- } catch (error) {
649
- if (signal?.aborted || (executeOwner && isAbortError(error, executeOwner.signal))) throw error;
650
- const message = error instanceof Error ? error.message : String(error);
651
- return {
652
- content: [{ type: "text" as const, text: `MCP initialization failed: ${message}` }],
653
- details: { mode: "script", error: "init_failed", message },
654
- };
655
- }
656
- }
657
- if (!state) {
658
- return {
659
- content: [{ type: "text" as const, text: "MCP not initialized" }],
660
- details: { mode: "script", error: "not_initialized" },
661
- };
662
- }
663
- executeOwner?.throwIfInactive();
664
- return runMcpScript(state, params.code, params.timeoutMs ?? options.defaultScriptTimeoutMs, getPiTools, signal, toolCallId,
622
+ const ready = await getToolState(signal);
623
+ if ("content" in ready) return ready;
624
+ return runMcpScript(ready, params.code, params.timeoutMs ?? options.defaultScriptTimeoutMs, getPiTools, signal, toolCallId,
665
625
  beforeExecute ? (callSignal, operation) => beforeExecute(toolCallId, { ...ctx, signal: callSignal }, operation) : undefined);
666
626
  },
667
627
  });
@@ -684,157 +644,55 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
684
644
  function registerProxyTool(description: string): void {
685
645
  pi.registerTool({
686
646
  ...(beforeExecute ? { executionMode: "sequential" as const } : {}),
687
- name: "mcp",
688
- label: "MCP",
689
- description,
690
- promptSnippet: "MCP gateway — status, search, describe, auth, and single MCP tool calls",
647
+ name: "mcp", label: "MCP", description,
648
+ promptSnippet: "MCP server operations, resources, saved result readback, and fallback tool calls",
691
649
  renderCall: renderMcpProxyToolCall,
692
- parameters: Type.Object({
693
- tool: Type.Optional(Type.String({ maxLength: MAX_TOOL_NAME_LENGTH, description: "Tool name to call (e.g., 'xcodebuild_list_sims')" })),
694
- args: Type.Optional(Type.Union([
695
- Type.String({ description: "Arguments as a JSON string (e.g., '{\"key\": \"value\"}')" }),
696
- Type.Object({}, {
697
- additionalProperties: true,
698
- description: 'Arguments as a JSON object (e.g., { "key": "value" })',
699
- }),
700
- ], { description: "Tool arguments as a JSON object, or as a JSON string encoding one" })),
701
- connect: Type.Optional(Type.String({ description: "Server name to connect (lazy connect + metadata refresh)" })),
702
- describe: Type.Optional(Type.String({ maxLength: MAX_TOOL_NAME_LENGTH, description: "Tool name to describe (shows parameters)" })),
703
- instructions: Type.Optional(Type.String({ description: "Server name to show that server's usage instructions" })),
704
- search: Type.Optional(Type.String({ description: "Search tools by name/description" })),
705
- includeSchemas: Type.Optional(Type.Boolean({ description: "Include parameter schemas in search results (default: true)" })),
706
- limit: Type.Optional(Type.Number({ minimum: 1, maximum: MAX_PAGE_SIZE, description: "Maximum search or server-list results to return (default: 12, max: 100)" })),
707
- offset: Type.Optional(Type.Number({ minimum: 0, description: "Search or server-list result offset (default: 0)" })),
708
- server: Type.Optional(Type.String({ description: "Filter to specific server (also disambiguates tool calls)" })),
709
- action: Type.Optional(Type.String({ description: "Action: 'ui-messages', 'auth-start', or 'auth-complete'" })),
710
- }),
650
+ parameters: gatewayParameters,
651
+ prepareArguments: prepareGatewayArguments,
711
652
  renderResult: renderMcpToolResult,
712
- async execute(toolCallId: string, params: {
713
- tool?: string;
714
- args?: string | Record<string, unknown>;
715
- connect?: string;
716
- describe?: string;
717
- instructions?: string;
718
- search?: string;
719
- includeSchemas?: boolean;
720
- limit?: number;
721
- offset?: number;
722
- server?: string;
723
- action?: string;
724
- }, signal: AbortSignal | undefined, _onUpdate: AgentToolUpdateCallback<Record<string, unknown>> | undefined, ctx: ExtensionContext) {
725
- // Non-call modes have no resolved MCP operation; hosts keep their conservative policy.
726
- if (!params.tool || params.action === "ui-messages" || params.action === "auth-start" || params.action === "auth-complete") {
727
- if (beforeExecute) {
728
- if (currentOwner) await currentOwner.runCallback("beforeExecute", () => beforeExecute(toolCallId, ctx));
729
- else await beforeExecute(toolCallId, ctx);
730
- }
731
- }
732
- const executeOwner = currentOwner;
733
- let parsedArgs: Record<string, unknown> | undefined;
734
- if (params.args !== undefined && params.args !== "") {
735
- let args: unknown;
736
- if (typeof params.args === "string") {
737
- try {
738
- args = JSON.parse(params.args);
739
- } catch (error) {
740
- if (error instanceof SyntaxError) {
741
- throw new Error(`Invalid args JSON: ${error.message}`, { cause: error });
742
- }
743
- throw error;
744
- }
745
- } else {
746
- args = params.args;
747
- }
748
-
749
- if (typeof args !== "object" || args === null || Array.isArray(args)) {
750
- const gotType = Array.isArray(args) ? "array" : args === null ? "null" : typeof args;
751
- throw new Error(`Invalid args: expected a JSON object, got ${gotType}`);
752
- }
753
- parsedArgs = args as Record<string, unknown>;
754
- }
755
-
756
- if (!state && initPromise) {
757
- try {
758
- const initialized = await awaitWithTimeout(abortable(initPromise, signal), INIT_WAIT_TIMEOUT_MS);
759
- if (initialized === INIT_WAIT_TIMED_OUT) {
760
- return {
761
- content: [{ type: "text" as const, text: "MCP initialization is still in progress. Try again shortly." }],
762
- details: { error: "init_timeout", timeoutMs: INIT_WAIT_TIMEOUT_MS },
763
- };
764
- }
765
- executeOwner?.throwIfInactive();
766
- state = initialized;
767
- } catch (error) {
768
- if (signal?.aborted || (executeOwner && isAbortError(error, executeOwner.signal))) throw error;
769
- const message = error instanceof Error ? error.message : String(error);
770
- return {
771
- content: [{ type: "text" as const, text: `MCP initialization failed: ${message}` }],
772
- details: { error: "init_failed", message },
773
- };
774
- }
775
- }
776
- if (!state) {
777
- return {
778
- content: [{ type: "text" as const, text: "MCP not initialized" }],
779
- details: { error: "not_initialized" },
780
- };
781
- }
782
- executeOwner?.throwIfInactive();
783
-
784
- if (params.action === "ui-messages") {
785
- return executeUiMessages(state);
653
+ async execute(toolCallId: string, rawParams: GatewayArguments, signal: AbortSignal | undefined, _onUpdate: AgentToolUpdateCallback<Record<string, unknown>> | undefined, ctx: ExtensionContext) {
654
+ const params = prepareGatewayArguments(rawParams);
655
+ if (beforeExecute && params.action !== "call" && params.action !== "read-resource") {
656
+ if (currentOwner) await currentOwner.runCallback("beforeExecute", () => beforeExecute(toolCallId, ctx));
657
+ else await beforeExecute(toolCallId, ctx);
786
658
  }
787
- if (params.action === "auth-start") {
788
- if (!params.server) {
789
- return {
790
- content: [{ type: "text" as const, text: "auth-start requires `server`. Example: mcp({ action: \"auth-start\", server: \"linear-server\" })" }],
791
- details: { mode: "auth-start", error: "missing_server" },
792
- };
793
- }
794
- return signal
795
- ? executeAuthStart(state, params.server, signal)
796
- : executeAuthStart(state, params.server);
659
+ const ready = await getToolState(signal);
660
+ if ("content" in ready) return ready;
661
+ const present = async (result: Awaited<ReturnType<typeof executeCall>>) => {
662
+ const guarded = await guardMcpOutput(result.content, resolveMcpOutputGuardOptions(ready.config.settings, ready.outputDirectory));
663
+ return { content: guarded.content, details: { ...result.details, ...guardedMcpDetails(guarded) } };
664
+ };
665
+ const beforeDispatch = beforeExecute
666
+ ? (callSignal: AbortSignal | undefined, operation: import("./types.ts").McpOperationContext) => beforeExecute(toolCallId, { ...ctx, signal: callSignal }, operation)
667
+ : undefined;
668
+ if (params.server && ["list", "search", "describe", "instructions"].includes(params.action) && !ready.toolMetadata.has(params.server)) {
669
+ const discovery = await executeConnect(ready, params.server, signal);
670
+ if (discovery.details.error) return discovery;
671
+ syncToolSurface(ctx);
797
672
  }
798
- if (params.action === "auth-complete") {
799
- if (!params.server) {
800
- return {
801
- content: [{ type: "text" as const, text: "auth-complete requires `server`." }],
802
- details: { mode: "auth-complete", error: "missing_server" },
803
- };
673
+ switch (params.action) {
674
+ case "ui-messages": return executeUiMessages(ready);
675
+ case "auth-start": return executeAuthStart(ready, params.server!, signal);
676
+ case "auth-complete": {
677
+ const input = params.args?.redirectUrl ?? params.args?.code ?? params.args?.input;
678
+ if (input !== undefined && (typeof input !== "string" || input.trim().length === 0)) throw new Error("auth-complete requires a non-empty redirectUrl, code, or input");
679
+ return executeAuthComplete(ready, params.server!, input, signal);
804
680
  }
805
- const input = parsedArgs?.redirectUrl ?? parsedArgs?.code ?? parsedArgs?.input;
806
- if (input !== undefined && (typeof input !== "string" || input.trim().length === 0)) {
807
- return {
808
- content: [{ type: "text" as const, text: "auth-complete accepts a non-empty `redirectUrl`, `code`, or `input`, or no args to use the browser callback." }],
809
- details: { mode: "auth-complete", error: "missing_input" },
810
- };
681
+ case "call": return executeCall(ready, params.tool!, params.args, params.server, getPiTools, signal, { toolCallId }, beforeDispatch);
682
+ case "connect": {
683
+ const result = await executeConnect(ready, params.server!, signal, params.limit, params.offset);
684
+ syncToolSurface(ctx);
685
+ return present(result);
811
686
  }
812
- return signal
813
- ? executeAuthComplete(state, params.server, input, signal)
814
- : executeAuthComplete(state, params.server, input);
815
- }
816
- if (params.tool) {
817
- return executeCall(state, params.tool, parsedArgs, params.server, getPiTools, signal, undefined, { toolCallId },
818
- beforeExecute ? (callSignal, operation) => beforeExecute(toolCallId, { ...ctx, signal: callSignal }, operation) : undefined);
819
- }
820
- if (params.connect) {
821
- const result = await executeConnect(state, params.connect, signal, params.limit, params.offset);
822
- syncToolSurface(ctx);
823
- return result;
687
+ case "describe": return present(executeDescribe(ready, params.tool!, params.server));
688
+ case "instructions": return present(executeInstructions(ready, params.server!));
689
+ case "search": return present(executeSearch(ready, params.query!, params.server, params.includeSchemas, params.limit, params.offset));
690
+ case "list": return present(executeList(ready, params.server!, params.limit, params.offset));
691
+ case "resources": return present(await executeResourceList(ready, params.server!, params.limit, params.offset, signal));
692
+ case "read-resource": return executeResourceRead(ready, params.server!, params.uri!, signal, { toolCallId }, beforeDispatch);
693
+ case "read-result": return readMcpResult({ ref: params.ref!, ...(params.path !== undefined ? { path: params.path } : {}), ...(params.fields ? { fields: params.fields } : {}), ...(params.offset !== undefined ? { offset: params.offset } : {}), ...(params.limit !== undefined ? { limit: params.limit } : {}) }, resolveMcpOutputGuardOptions(ready.config.settings, ready.outputDirectory));
694
+ case "status": return present(executeStatus(ready));
824
695
  }
825
- if (params.describe) {
826
- return executeDescribe(state, params.describe);
827
- }
828
- if (params.instructions) {
829
- return executeInstructions(state, params.instructions);
830
- }
831
- if (params.search !== undefined) {
832
- return executeSearch(state, params.search, params.server, params.includeSchemas, params.limit, params.offset);
833
- }
834
- if (params.server) {
835
- return executeList(state, params.server, params.limit, params.offset);
836
- }
837
- return executeStatus(state);
838
696
  },
839
697
  });
840
698
  proxyToolRegistered = true;
@@ -847,8 +705,18 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
847
705
  cache,
848
706
  envRaw === undefined || envRaw === "__none__" ? undefined : envDirectToolOverride,
849
707
  );
708
+ const hasPinnedResources = envRaw !== "__none__" && Object.entries(config.mcpServers).some(([server, definition]) => {
709
+ if (definition.disabled === true) return false;
710
+ const selection = directToolSelection(config, server, envDirectToolOverride);
711
+ if (!selection) return false;
712
+ const entry = cache?.servers[server];
713
+ const metadata = state?.toolMetadata.get(server) ?? (entry && isServerCacheValid(entry, definition)
714
+ ? reconstructToolMetadata(server, entry, config.settings?.toolPrefix ?? "server", definition) : []);
715
+ return metadata.some(tool => tool.resourceUri && (selection === true || selection.includes(tool.originalName)));
716
+ });
850
717
  const shouldRegisterProxyTool =
851
718
  config.settings?.disableProxyTool !== true
719
+ || hasPinnedResources
852
720
  || directSpecs.length === 0
853
721
  || missingConfiguredDirectToolServers.length > 0;
854
722
 
@@ -871,8 +739,7 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
871
739
 
872
740
  // A transform needs the session context before any configured tool surface is registered.
873
741
  if (!options.transformConfig) {
874
- const initialDirectTools = syncDirectTools(earlyConfig, earlyCache).specs;
875
- syncProxyTool(earlyConfig, earlyCache, initialDirectTools);
742
+ syncProxyTool(earlyConfig, earlyCache, []);
876
743
  syncScriptTool(earlyConfig);
877
744
  }
878
745
  }