@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/proxy-modes.ts CHANGED
@@ -3,16 +3,15 @@ import type { Client } from "@modelcontextprotocol/client";
3
3
  import { DEFAULT_REQUEST_TIMEOUT_MSEC, UrlElicitationRequiredError } from "@modelcontextprotocol/client";
4
4
  import type { McpExtensionState } from "./state.ts";
5
5
  import type { ServerConnection } from "./server-manager.ts";
6
- import type { ToolMetadata, McpContent, McpOperationContext, McpToolCallEvent, McpToolCallIdentity } from "./types.ts";
6
+ import type { ToolMetadata, McpOperationContext, McpToolCallEvent, McpToolCallIdentity } from "./types.ts";
7
7
  import { getServerPrefix, isServerDisabled, isNonInteractiveOAuth, parseUiPromptHandoff, resolveToolPrefix } from "./types.ts";
8
8
  import { lazyConnect, markKeepAliveAfterConnect, notifyToolMetadataUpdated, updateServerMetadata, updateMetadataCache, getFailureAgeSeconds, updateStatusBar, clearFailure, recordFailure } from "./init.ts";
9
9
  import { abortable, throwIfAborted } from "./abort.ts";
10
10
  import { combineAbortSignals, isAbortError } from "./runtime-owner.ts";
11
- import { buildToolMetadata, findToolByName, formatSchema } from "./tool-metadata.ts";
12
- import { renderTsShape } from "./ts-shape.ts";
11
+ import { buildToolMetadata, catalogCoverage, findToolByName, formatSchema, toToolDescriptor } from "./tool-metadata.ts";
13
12
  import { reconstructPromptMetadata } from "./metadata-cache.ts";
14
- import { resolveMcpResultContent, transformMcpContent } from "./tool-registrar.ts";
15
- import { guardMcpOutput, guardedMcpDetails, resolveMcpOutputGuardOptions } from "./mcp-output-guard.ts";
13
+ import { renderMcpResultContent } from "./tool-registrar.ts";
14
+ import { guardMcpOutput, guardedMcpDetails, retainMcpResult, resolveMcpOutputGuardOptions, formatMcpResultReference } from "./mcp-output-guard.ts";
16
15
  import { maybeStartUiSession, summarizeUiSessionResult, type UiSessionRuntime } from "./ui-session.ts";
17
16
  import { formatAuthRequiredMessage, formatMcpStatus, resolveServerUrl, truncateAtWord } from "./utils.ts";
18
17
  import { authenticate, completeAuthFromInput, startAuth, supportsOAuth } from "./mcp-auth-flow.ts";
@@ -87,7 +86,7 @@ function formatManualAuthInstructions(serverName: string, authorizationUrl: stri
87
86
  "If the browser cannot reach the callback, copy the full redirected localhost URL from your address bar and send it back with:",
88
87
  `mcp({ action: "auth-complete", server: "${serverName}", args: { redirectUrl: "PASTE_REDIRECT_URL_HERE" } })`,
89
88
  "",
90
- 'You can also pass just the `code` query parameter as `args: { code: "PASTE_CODE_HERE" }`. JSON-string args remain supported.',
89
+ 'You can also pass just the `code` query parameter as `args: { code: "PASTE_CODE_HERE" }`.',
91
90
  portNote.trimEnd(),
92
91
  ].filter(Boolean).join("\n");
93
92
  }
@@ -252,7 +251,7 @@ export function executeStatus(state: McpExtensionState): ProxyToolResult {
252
251
  const disabled = isServerDisabled(definition);
253
252
  const connection = disabled ? undefined : state.manager.getConnection(name);
254
253
  const metadata = disabled ? undefined : state.toolMetadata.get(name);
255
- const toolCount = metadata?.length ?? 0;
254
+ const toolCount = metadata?.filter(tool => !tool.resourceUri).length ?? 0;
256
255
  const failedAgo = disabled ? null : getFailureAgeSeconds(state, name);
257
256
  let status = disabled ? "disabled" : "not connected";
258
257
  if (!disabled && connection?.status === "connected") {
@@ -272,6 +271,7 @@ export function executeStatus(state: McpExtensionState): ProxyToolResult {
272
271
  const enabledServers = servers.filter(s => !s.disabled);
273
272
  const totalTools = enabledServers.reduce((sum, s) => sum + s.toolCount, 0);
274
273
  const connectedCount = enabledServers.filter(s => s.status === "connected").length;
274
+ const coverage = catalogCoverage(state);
275
275
 
276
276
  let text = `MCP: ${connectedCount}/${enabledServers.length} connected, ${totalTools} tools available (calls connect lazily)`;
277
277
  if (disabledCount > 0) text += ` (${disabledCount} disabled)`;
@@ -297,16 +297,17 @@ export function executeStatus(state: McpExtensionState): ProxyToolResult {
297
297
  text += `✗ ${server.name} (failed ${server.failedAgo ?? 0}s ago)\n`;
298
298
  continue;
299
299
  }
300
- text += `○ ${server.name} (not connected)\n`;
300
+ text += `○ ${server.name} (not discovered)\n`;
301
301
  }
302
302
 
303
303
  if (servers.length > 0) {
304
- text += `\nmcp({ server: "name" }) to browse tools, mcp({ search: "..." }) to search`;
304
+ text += `\nmcp_search({ query: "...", server: "name" }) to discover tools; mcp({ action: "resources", server: "name" }) for resources.`;
305
+ if (!coverage.complete) text += `\nPartial catalog: ${coverage.unknownServers.length} undiscovered server(s).`;
305
306
  }
306
307
 
307
308
  return {
308
309
  content: [{ type: "text" as const, text: text.trim() }],
309
- details: { mode: "status", servers, totalTools, connectedCount, disabledCount },
310
+ details: { mode: "status", servers, totalTools, connectedCount, disabledCount, coverage },
310
311
  };
311
312
  }
312
313
 
@@ -316,7 +317,7 @@ export async function executeAuthStart(state: McpExtensionState, serverName: str
316
317
  const definition = state.config.mcpServers[serverName];
317
318
  if (!definition) {
318
319
  return {
319
- content: [{ type: "text" as const, text: `Server "${serverName}" not found. Use mcp({}) to see available servers.` }],
320
+ content: [{ type: "text" as const, text: `Server "${serverName}" not found. Use mcp({ action: "status" }) to see available servers.` }],
320
321
  details: { mode: "auth-start", error: "not_found", server: serverName },
321
322
  };
322
323
  }
@@ -364,7 +365,7 @@ export async function executeAuthComplete(state: McpExtensionState, serverName:
364
365
  const definition = state.config.mcpServers[serverName];
365
366
  if (!definition) {
366
367
  return {
367
- content: [{ type: "text" as const, text: `Server "${serverName}" not found. Use mcp({}) to see available servers.` }],
368
+ content: [{ type: "text" as const, text: `Server "${serverName}" not found. Use mcp({ action: "status" }) to see available servers.` }],
368
369
  details: { mode: "auth-complete", error: "not_found", server: serverName },
369
370
  };
370
371
  }
@@ -390,7 +391,7 @@ export async function executeAuthComplete(state: McpExtensionState, serverName:
390
391
  clearFailure(state, serverName);
391
392
  updateStatusBar(state);
392
393
  return {
393
- content: [{ type: "text" as const, text: `OAuth authentication successful for "${serverName}". Run mcp({ connect: "${serverName}" }) to connect with the new token.` }],
394
+ content: [{ type: "text" as const, text: `OAuth authentication successful for "${serverName}". Run mcp({ action: "connect", server: "${serverName}" }) to connect with the new token.` }],
394
395
  details: { mode: "auth-complete", server: serverName, authenticated: true },
395
396
  };
396
397
  } catch (error) {
@@ -402,57 +403,36 @@ export async function executeAuthComplete(state: McpExtensionState, serverName:
402
403
  }
403
404
  }
404
405
 
405
- export function executeDescribe(state: McpExtensionState, toolName: string): ProxyToolResult {
406
- let serverName: string | undefined;
407
- let toolMeta: ToolMetadata | undefined;
408
- let disabledMatch: string | undefined;
409
-
410
- for (const [server, metadata] of state.toolMetadata.entries()) {
411
- const found = findToolByName(metadata, toolName);
412
- if (!found) continue;
413
- if (isServerDisabled(state.config.mcpServers[server])) {
414
- disabledMatch ??= server;
415
- continue;
416
- }
417
- serverName = server;
418
- toolMeta = found;
419
- break;
420
- }
421
-
422
- if (!serverName || !toolMeta) {
423
- if (disabledMatch) return disabledResult("describe", disabledMatch);
424
- const suggestions = rankSuggestions(state, toolName, 5);
406
+ export function executeDescribe(state: McpExtensionState, toolName: string, serverFilter?: string): ProxyToolResult {
407
+ if (serverFilter && isServerDisabled(state.config.mcpServers[serverFilter])) return disabledResult("describe", serverFilter);
408
+ const catalog = [...state.toolMetadata].flatMap(([server, tools]) =>
409
+ (!serverFilter || serverFilter === server) && state.config.mcpServers[server] && !isServerDisabled(state.config.mcpServers[server])
410
+ ? tools.map(tool => ({ server, tool })) : []);
411
+ const originals = serverFilter ? catalog.filter(({ tool }) => tool.originalName === toolName) : [];
412
+ const exact = originals.length ? originals : catalog.filter(({ tool }) => tool.name === toolName);
413
+ const matches = exact.length ? exact : catalog.filter(({ tool }) => tool.name.replace(/-/g, "_") === toolName.replace(/-/g, "_"));
414
+ if (matches.length > 1) return {
415
+ content: [{ type: "text", text: `Tool ${JSON.stringify(toolName)} is ambiguous. Specify server and original tool name: ${JSON.stringify(matches.map(({ server, tool }) => ({ server, tool: tool.originalName })))}` }],
416
+ details: { mode: "describe", error: "ambiguous_tool", requestedTool: toolName },
417
+ };
418
+ const match = matches[0];
419
+ if (!match) {
420
+ const disabled = [...state.toolMetadata].find(([server, tools]) => isServerDisabled(state.config.mcpServers[server]) && findToolByName(tools, toolName));
421
+ if (disabled) return disabledResult("describe", disabled[0]);
422
+ const suggestions = rankSuggestions(state, toolName, 5, serverFilter);
425
423
  const hint = suggestions.length > 0
426
- ? `Did you mean: ${suggestions.join(", ")}. Inspect with mcp({ describe: "${suggestions[0]}" }).`
427
- : `Use mcp({ search: "..." }) to search.`;
424
+ ? `Did you mean: ${suggestions.join(", ")}. Inspect with mcp({ action: "describe", tool: "${suggestions[0]}" }).`
425
+ : `Use mcp_search({ query: "..." }) to search.`;
428
426
  return {
429
- content: [{ type: "text" as const, text: `Tool "${toolName}" not found. ${hint}` }],
427
+ content: [{ type: "text", text: `Tool "${toolName}" not found. ${hint}` }],
430
428
  details: { mode: "describe", error: "tool_not_found", requestedTool: toolName, suggestions },
431
429
  };
432
430
  }
433
-
434
- const approvalMarker = isToolCallApprovalRequired(state.config, serverName, toolMeta)
435
- ? " (requires approval)"
436
- : "";
437
- let text = `${toolMeta.name}${approvalMarker}\n`;
438
- text += `Server: ${serverName}\n`;
439
- if (toolMeta.resourceUri) {
440
- text += `Type: Resource (reads from ${toolMeta.resourceUri})\n`;
441
- }
442
- text += `\n${toolMeta.description || "(no description)"}\n`;
443
- if (toolMeta.annotations) text += `\nAnnotations: ${JSON.stringify(toolMeta.annotations)}\n`;
444
-
445
- if (toolMeta.inputSchema && !toolMeta.resourceUri) {
446
- const shape = renderTsShape(toolMeta.inputSchema);
447
- text += shape === null ? `\nParameters:\n${formatSchema(toolMeta.inputSchema)}` : `\nShape:\n${shape}`;
448
- } else if (toolMeta.resourceUri) {
449
- text += `\nNo parameters required (resource tool).`;
450
- } else {
451
- text += `\nNo parameters defined.`;
452
- }
453
-
431
+ const { server: serverName, tool: toolMeta } = match;
432
+ const descriptor = toToolDescriptor(serverName, toolMeta);
433
+ const approvalRequired = isToolCallApprovalRequired(state.config, serverName, toolMeta);
454
434
  return {
455
- content: [{ type: "text" as const, text: text.trim() }],
435
+ content: [{ type: "text", text: JSON.stringify({ ...descriptor, ...(toolMeta.resourceUri ? { resourceUri: toolMeta.resourceUri, readWith: { action: "read-resource", server: serverName, uri: toolMeta.resourceUri } } : {}), ...(approvalRequired ? { approvalRequired: true } : {}) }, null, 2) }],
456
436
  details: { mode: "describe", tool: toolMeta, server: serverName },
457
437
  };
458
438
  }
@@ -467,7 +447,7 @@ export function executeSearch(
467
447
  ): ProxyToolResult {
468
448
  const showSchemas = includeSchemas !== false;
469
449
  const searchCall = (nextOffset: number | null) =>
470
- `mcp({ search: ${JSON.stringify(query)}${server ? `, server: ${JSON.stringify(server)}` : ""}${showSchemas ? "" : ", includeSchemas: false"}, limit: ${limit}, offset: ${nextOffset} })`;
450
+ `mcp({ action: "search", query: ${JSON.stringify(query)}${server ? `, server: ${JSON.stringify(server)}` : ""}${showSchemas ? "" : ", includeSchemas: false"}, limit: ${limit}, offset: ${nextOffset} })`;
471
451
  if (server && isServerDisabled(state.config.mcpServers[server])) return disabledResult("search", server);
472
452
 
473
453
  let matches: Array<{ server: string; tool: ToolMetadata; score: number }>;
@@ -478,7 +458,7 @@ export function executeSearch(
478
458
  details: { mode: "search", error: "empty_query" },
479
459
  };
480
460
  }
481
- matches = (state.toolMetadata.get(server) ?? [])
461
+ matches = (state.toolMetadata.get(server) ?? []).filter(tool => !tool.resourceUri)
482
462
  .map(tool => ({ server, tool, score: 0 }))
483
463
  .sort((a, b) => a.tool.name.localeCompare(b.tool.name));
484
464
  } else {
@@ -486,11 +466,13 @@ export function executeSearch(
486
466
  }
487
467
 
488
468
  const page = paginate(matches, offset, limit);
469
+ const coverage = catalogCoverage(state, server);
470
+ const coverageNotice = coverage.complete ? "" : `\nCatalog is incomplete. Undiscovered servers: ${coverage.unknownServers.join(", ")}. Search with server to discover one.`;
489
471
  if (page.total === 0) {
490
472
  const msg = server ? `No tools matching "${query}" in "${server}"` : `No tools matching "${query}"`;
491
473
  return {
492
- content: [{ type: "text" as const, text: msg }],
493
- details: { mode: "search", matches: [], count: 0, hasMore: false, nextOffset: null, query },
474
+ content: [{ type: "text" as const, text: msg + coverageNotice }],
475
+ details: { mode: "search", matches: [], count: 0, hasMore: false, nextOffset: null, query, coverage },
494
476
  };
495
477
  }
496
478
  if (page.items.length === 0) {
@@ -507,17 +489,7 @@ export function executeSearch(
507
489
  ? " (requires approval)"
508
490
  : "";
509
491
  if (showSchemas) {
510
- text += `${match.tool.name}${approvalMarker}\n`;
511
- text += ` ${match.tool.description || "(no description)"}\n`;
512
- if (match.tool.inputSchema && !match.tool.resourceUri) {
513
- const shape = renderTsShape(match.tool.inputSchema);
514
- text += shape === null
515
- ? `\n Parameters:\n${formatSchema(match.tool.inputSchema, " ")}\n`
516
- : `\n Shape:\n${shape.split("\n").map(line => ` ${line}`).join("\n")}\n`;
517
- } else if (match.tool.resourceUri) {
518
- text += " No parameters (resource tool).\n";
519
- }
520
- text += "\n";
492
+ text += `${JSON.stringify({ ...toToolDescriptor(match.server, match.tool), ...(approvalMarker ? { approvalRequired: true } : {}) }, null, 2)}\n\n`;
521
493
  } else {
522
494
  text += `- ${match.tool.name}${approvalMarker}`;
523
495
  if (match.tool.description) text += ` - ${truncateAtWord(match.tool.description, 50)}`;
@@ -531,9 +503,10 @@ export function executeSearch(
531
503
  : `\n${first}-${first + page.items.length - 1} of ${page.total} — end\n`;
532
504
 
533
505
  return {
534
- content: [{ type: "text" as const, text: text.trim() }],
506
+ content: [{ type: "text" as const, text: text.trim() + coverageNotice }],
535
507
  details: {
536
508
  mode: "search",
509
+ coverage,
537
510
  matches: page.items.map(match => ({ server: match.server, tool: match.tool.name, score: match.score })),
538
511
  count: page.total,
539
512
  hasMore: page.hasMore,
@@ -547,13 +520,13 @@ export function executeList(state: McpExtensionState, server: string, limit = 12
547
520
  const definition = state.config.mcpServers[server];
548
521
  if (!definition) {
549
522
  return {
550
- content: [{ type: "text" as const, text: `Server "${server}" not found. Use mcp({}) to see available servers.` }],
523
+ content: [{ type: "text" as const, text: `Server "${server}" not found. Use mcp({ action: "status" }) to see available servers.` }],
551
524
  details: { mode: "list", server, tools: [], count: 0, error: "not_found" },
552
525
  };
553
526
  }
554
527
  if (isServerDisabled(definition)) return disabledResult("list", server);
555
528
 
556
- const metadata = state.toolMetadata.get(server);
529
+ const metadata = state.toolMetadata.get(server)?.filter(tool => !tool.resourceUri);
557
530
  const connection = state.manager.getConnection(server);
558
531
  const instructions = state.serverInstructions.get(server);
559
532
  let instructionsText = "";
@@ -561,7 +534,7 @@ export function executeList(state: McpExtensionState, server: string, limit = 12
561
534
  const preview = truncateAtWord(instructions, INSTRUCTIONS_PREVIEW_LENGTH);
562
535
  instructionsText = `\n\nServer instructions:\n${preview}`;
563
536
  if (preview !== instructions) {
564
- instructionsText += `\nUse mcp({ instructions: "${server}" }) for the full text.`;
537
+ instructionsText += `\nUse mcp({ action: "instructions", server: "${server}" }) for the full text.`;
565
538
  }
566
539
  }
567
540
 
@@ -579,7 +552,7 @@ export function executeList(state: McpExtensionState, server: string, limit = 12
579
552
  };
580
553
  }
581
554
  return {
582
- content: [{ type: "text" as const, text: `Server "${server}" is configured but not connected. Use mcp({ connect: "${server}" }) or /mcp reconnect ${server} to retry.${instructionsText}` }],
555
+ content: [{ type: "text" as const, text: `Server "${server}" is configured but not connected. Use mcp({ action: "connect", server: "${server}" }) or /mcp reconnect ${server} to retry.${instructionsText}` }],
583
556
  details: { mode: "list", server, tools: [], count: 0, error: "not_connected", hasInstructions: Boolean(instructions) },
584
557
  };
585
558
  }
@@ -587,7 +560,7 @@ export function executeList(state: McpExtensionState, server: string, limit = 12
587
560
  const page = paginate(metadata, offset, limit);
588
561
  if (page.items.length === 0) {
589
562
  return {
590
- content: [{ type: "text" as const, text: `No tools at offset ${offset}; "${server}" has ${page.total} tools. Retry with mcp({ server: ${JSON.stringify(server)}, limit: ${limit}, offset: 0 }).` }],
563
+ content: [{ type: "text" as const, text: `No tools at offset ${offset}; "${server}" has ${page.total} tools. Retry with mcp({ action: "list", server: ${JSON.stringify(server)}, limit: ${limit}, offset: 0 }).` }],
591
564
  details: { mode: "list", error: "offset_out_of_range", server, tools: [], count: page.total, hasMore: false, nextOffset: null, hasInstructions: Boolean(instructions) },
592
565
  };
593
566
  }
@@ -603,7 +576,7 @@ export function executeList(state: McpExtensionState, server: string, limit = 12
603
576
  }
604
577
  const first = (Number.isFinite(offset) ? Math.max(0, Math.trunc(offset)) : 0) + 1;
605
578
  text += page.hasMore
606
- ? `\n${first}-${first + page.items.length - 1} of ${page.total} — mcp({ server: ${JSON.stringify(server)}, limit: ${limit}, offset: ${page.nextOffset} }) for more`
579
+ ? `\n${first}-${first + page.items.length - 1} of ${page.total} — mcp({ action: "list", server: ${JSON.stringify(server)}, limit: ${limit}, offset: ${page.nextOffset} }) for more`
607
580
  : `\n${first}-${first + page.items.length - 1} of ${page.total} — end`;
608
581
  text += instructionsText;
609
582
 
@@ -625,7 +598,7 @@ export function executeInstructions(state: McpExtensionState, server: string): P
625
598
  const definition = state.config.mcpServers[server];
626
599
  if (!definition) {
627
600
  return {
628
- content: [{ type: "text" as const, text: `Server "${server}" not found. Use mcp({}) to see available servers.` }],
601
+ content: [{ type: "text" as const, text: `Server "${server}" not found. Use mcp({ action: "status" }) to see available servers.` }],
629
602
  details: { mode: "instructions", server, error: "not_found" },
630
603
  };
631
604
  }
@@ -648,7 +621,7 @@ export function executeInstructions(state: McpExtensionState, server: string): P
648
621
  }
649
622
 
650
623
  return {
651
- content: [{ type: "text" as const, text: `No instructions cached for "${server}". Use mcp({ connect: "${server}" }) to connect and refresh.` }],
624
+ content: [{ type: "text" as const, text: `No instructions cached for "${server}". Use mcp({ action: "connect", server: "${server}" }) to connect and refresh.` }],
652
625
  details: { mode: "instructions", server, error: "not_connected" },
653
626
  };
654
627
  }
@@ -665,7 +638,7 @@ export async function executeConnect(
665
638
  const definition = state.config.mcpServers[serverName];
666
639
  if (!definition) {
667
640
  return {
668
- content: [{ type: "text" as const, text: `Server "${serverName}" not found. Use mcp({}) to see available servers.` }],
641
+ content: [{ type: "text" as const, text: `Server "${serverName}" not found. Use mcp({ action: "status" }) to see available servers.` }],
669
642
  details: { mode: "connect", error: "not_found", server: serverName },
670
643
  };
671
644
  }
@@ -702,6 +675,7 @@ export async function executeConnect(
702
675
  const prefix = state.config.settings?.toolPrefix ?? "server";
703
676
  const { metadata } = buildToolMetadata(connection.tools, connection.resources, definition, serverName, prefix);
704
677
  state.toolMetadata.set(serverName, metadata);
678
+ state.resourceCounts?.set(serverName, connection.resources.length);
705
679
  if (!connection.promptDiscoveryFailed) {
706
680
  state.promptMetadata?.set(serverName, reconstructPromptMetadata(serverName, connection.prompts ?? [], prefix, definition));
707
681
  state.promptMetadataLive?.add(serverName);
@@ -741,15 +715,14 @@ interface ToolCallOptions extends McpToolCallIdentity {
741
715
  recoverAuthConnection: NonNullable<SessionRecoveryDeps["onNeedsAuth"]>;
742
716
  authRequiredMessage: () => string;
743
717
  autoAuthAttempted: () => boolean;
744
- onRawResult?: (result: unknown) => void;
718
+ raw?: boolean;
745
719
  }
746
720
 
747
721
  /**
748
722
  * Executes an already-approved tool call against a connected server.
749
723
  *
750
- * Both the `mcp` proxy and direct tools reach this with a resolved target and a
751
- * live connection; everything before this point (name resolution, connect and
752
- * auth strategy, approval) differs between them and stays with the caller.
724
+ * Direct, gateway, resource and script calls share executeCall preparation.
725
+ * Raw scripts skip content rendering but retain protocol results and artifacts.
753
726
  */
754
727
  export async function runToolCall(
755
728
  state: McpExtensionState,
@@ -758,7 +731,7 @@ export async function runToolCall(
758
731
  args: Record<string, unknown> | undefined,
759
732
  options: ToolCallOptions,
760
733
  ): Promise<ProxyToolResult> {
761
- const { detailsBase, signal, recoverAuthConnection, authRequiredMessage, autoAuthAttempted, onRawResult } = options;
734
+ const { detailsBase, signal, recoverAuthConnection, authRequiredMessage, autoAuthAttempted } = options;
762
735
  const configuredOptions = state.manager.getRequestOptions?.(serverName, options.ownedSignal);
763
736
  const callerSignal = combineAbortSignals(configuredOptions?.signal, options.ownedSignal);
764
737
  const outputGuardOptions = resolveMcpOutputGuardOptions(state.config.settings, state.outputDirectory);
@@ -801,8 +774,7 @@ export async function runToolCall(
801
774
  })
802
775
  : null;
803
776
 
804
- // Frozen direct-tool schemas may be older than the live catalog. Never use
805
- // stale cached hints in preference to the connected server's annotations.
777
+ // UI preparation can replace the connection; use its current annotations.
806
778
  const liveTools = state.manager.getConnection(serverName)?.tools;
807
779
  const annotations = liveTools
808
780
  ? liveTools.find(tool => tool.name === target.originalName)?.annotations
@@ -841,62 +813,38 @@ export async function runToolCall(
841
813
  return result;
842
814
  };
843
815
 
844
- if (target.resourceUri) {
845
- const result = await dispatch(() => withSessionRecovery(
846
- recovery,
847
- serverName,
848
- (conn) => conn.client.readResource({ uri: target.resourceUri! }, requestOptions),
849
- ));
850
- onRawResult?.(result);
851
- const contents = (result.contents ?? []).map(c => ({
852
- type: "text" as const,
853
- text: "text" in c ? c.text : ("blob" in c ? `[Binary data: ${(c as { mimeType?: string }).mimeType ?? "unknown"}]` : JSON.stringify(c)),
854
- }));
855
- const guarded = await guardMcpOutput(contents.length > 0 ? contents : [{ type: "text" as const, text: "(empty resource)" }], outputGuardOptions);
856
- return { content: guarded.content, details: { ...detailsBase, ...guardedMcpDetails(guarded) } };
857
- }
858
-
859
- const result = await dispatch(() => withSessionRecovery<ClientCallToolResult>(
860
- { ...recovery, retryOnTransportFailure: annotations?.readOnlyHint === true || annotations?.idempotentHint === true },
861
- serverName,
862
- (conn) => abortable(conn.client.callTool({
863
- name: target.originalName,
864
- arguments: args ?? {},
865
- _meta: uiSession?.requestMeta,
866
- }, requestOptions), ownedSignal),
867
- ));
868
- uiSession?.sendToolResult(result);
869
-
870
- if (result.isError) {
871
- const content = transformMcpContent((result.content ?? []) as McpContent[]);
872
- const guarded = await guardMcpOutput(
873
- content.length > 0 ? content : [{ type: "text" as const, text: "(empty result)" }],
874
- { ...outputGuardOptions, prefix: "Error: ", suffix: schemaSuffix(), emptyTextFallback: "Tool execution failed", rawMcpResult: result },
875
- );
876
- return { content: guarded.content, details: { ...detailsBase, error: "tool_error", ...guardedMcpDetails(guarded) } };
877
- }
878
-
879
- onRawResult?.(result);
880
- const content = resolveMcpResultContent(result as Record<string, unknown>);
881
- const outputContent = content.length > 0 ? content : [{ type: "text" as const, text: "(empty result)" }];
882
-
883
- if (target.uiResourceUri) {
884
- const uiSummary = summarizeUiSessionResult(uiSession);
885
- const guarded = await guardMcpOutput(outputContent, { ...outputGuardOptions, suffix: `\n\n${uiSummary.message}`, rawMcpResult: result });
886
- return {
887
- content: guarded.content,
888
- details: {
889
- ...detailsBase,
890
- ...guardedMcpDetails(guarded),
891
- uiOpen: uiSummary.uiOpen,
892
- uiViewer: uiSummary.uiViewer,
893
- uiUrl: uiSummary.uiUrl,
894
- },
895
- };
816
+ const result = target.resourceUri
817
+ ? await dispatch(() => withSessionRecovery(
818
+ recovery, serverName,
819
+ conn => abortable(conn.client.readResource({ uri: target.resourceUri! }, requestOptions), ownedSignal),
820
+ ))
821
+ : await dispatch(() => withSessionRecovery<ClientCallToolResult>(
822
+ { ...recovery, retryOnTransportFailure: annotations?.readOnlyHint === true || annotations?.idempotentHint === true },
823
+ serverName,
824
+ conn => abortable(conn.client.callTool({ name: target.originalName, arguments: args ?? {}, _meta: uiSession?.requestMeta }, requestOptions), ownedSignal),
825
+ ));
826
+ if (!target.resourceUri) uiSession?.sendToolResult(result as ClientCallToolResult);
827
+ const record = result as Record<string, unknown>;
828
+ const isError = record.isError === true;
829
+ const uiSummary = target.uiResourceUri ? summarizeUiSessionResult(uiSession) : undefined;
830
+ const details = {
831
+ ...detailsBase,
832
+ ...(isError ? { error: "tool_error" } : {}),
833
+ ...(uiSummary ? { uiOpen: uiSummary.uiOpen, uiViewer: uiSummary.uiViewer, uiUrl: uiSummary.uiUrl } : {}),
834
+ };
835
+ const retained = await retainMcpResult(result, outputGuardOptions, options.raw);
836
+ if (options.raw) {
837
+ const message = isError
838
+ ? (Array.isArray(record.content) ? record.content.filter(block => block.type === "text").map(block => block.text).join("\n") : "") || "Tool execution failed" : undefined;
839
+ return { content: [], details: { ...details, ...retained, ...(message ? { message } : {}) } };
896
840
  }
897
-
898
- const guarded = await guardMcpOutput(outputContent, { ...outputGuardOptions, rawMcpResult: result });
899
- return { content: guarded.content, details: { ...detailsBase, ...guardedMcpDetails(guarded) } };
841
+ const content = renderMcpResultContent(record, retained.payloadFiles);
842
+ const guarded = await guardMcpOutput(content.length ? content : [{ type: "text", text: target.resourceUri ? "(empty resource)" : "(empty result)" }], {
843
+ ...outputGuardOptions, retainedMcpResult: retained,
844
+ ...(isError ? { prefix: "Error: ", suffix: schemaSuffix(), emptyTextFallback: "Tool execution failed" }
845
+ : uiSummary ? { suffix: `\n\n${uiSummary.message}` } : {}),
846
+ });
847
+ return { content: guarded.content, details: { ...details, ...guardedMcpDetails(guarded) } };
900
848
  } catch (error) {
901
849
  // Direct/proxy host errors throw; scripts retain their failed-call envelope.
902
850
  if (beforeDispatchPending && options.innerCallId === undefined) throw error;
@@ -906,9 +854,11 @@ export async function runToolCall(
906
854
  ? "MCP call capture failed before dispatch; the tool did not run. Restore capture before continuing."
907
855
  : "MCP outcome capture failed. Use the retained outcome to continue the original work; do not repeat a completed call or rerun the script.";
908
856
  uiSession?.sendToolCancelled(message);
857
+ const retained = "result" in captureFailure && captureFailure.result !== undefined
858
+ ? await retainMcpResult(captureFailure.result, { ...outputGuardOptions, enabled: true, detailsMaxBytes: 1 }) : {};
909
859
  return {
910
- content: [{ type: "text", text: message }],
911
- details: { ...detailsBase, error: isAbortError(error, callerSignal) ? "aborted" : "call_capture_failed", message, recovery: context },
860
+ content: [{ type: "text", text: message }, ...(retained.resultRef ? [{ type: "text" as const, text: formatMcpResultReference(retained.resultRef) }] : [])],
861
+ details: { ...detailsBase, ...retained, error: isAbortError(error, callerSignal) ? "aborted" : "call_capture_failed", message, recovery: context },
912
862
  };
913
863
  }
914
864
  if (isToolTransportFailure(error)) {
@@ -959,6 +909,82 @@ export async function runToolCall(
959
909
  }
960
910
  }
961
911
 
912
+ export interface ExecuteCallOptions {
913
+ exactOriginalName?: boolean;
914
+ resourceUri?: string;
915
+ /** Scripts receive the raw protocol result without model-facing content transformation. */
916
+ raw?: boolean;
917
+ }
918
+
919
+ /** Connect/auth once for calls and resource discovery. Recovery stays bounded by withSessionRecovery. */
920
+ export async function prepareMcpConnection(state: McpExtensionState, serverName: string, signal?: AbortSignal) {
921
+ const ownedSignal = combineAbortSignals(state.owner?.signal, signal);
922
+ throwIfAborted(ownedSignal);
923
+ let autoAuthAttempted = false;
924
+ const recoverAuthConnection: NonNullable<SessionRecoveryDeps["onNeedsAuth"]> = async (_name, recoverySignal = ownedSignal, challenge) => {
925
+ throwIfAborted(recoverySignal);
926
+ const current = state.manager.getConnection(serverName);
927
+ if (current?.status === "connected" && current !== challenge?.connection) return current;
928
+ if (!autoAuthAttempted) {
929
+ autoAuthAttempted = true;
930
+ const auth = await attemptAutoAuth(state, serverName, recoverySignal);
931
+ throwIfAborted(recoverySignal);
932
+ if (auth.status === "failed") throw new SessionRecoveryAuthRequiredError(serverName, auth.message);
933
+ if (auth.status === "success" && current) {
934
+ clearFailure(state, serverName);
935
+ const definition = state.config.mcpServers[serverName];
936
+ if (!definition) return undefined;
937
+ state.ui?.setStatus("mcp", formatMcpStatus(state.config, `connecting to ${serverName}...`));
938
+ await state.manager.reconnect(serverName, definition, current, recoverySignal);
939
+ if (await lazyConnect(state, serverName, recoverySignal)) {
940
+ updateStatusBar(state);
941
+ return state.manager.getConnection(serverName);
942
+ }
943
+ }
944
+ }
945
+ return challenge ? undefined : state.manager.getConnection(serverName);
946
+ };
947
+ const failure = (error: string, message: string) => ({
948
+ content: [{ type: "text" as const, text: message }],
949
+ details: { mode: "call", error, server: serverName, message },
950
+ });
951
+ const definition = state.config.mcpServers[serverName];
952
+ if (!definition) return { error: failure("server_not_found", `Server "${serverName}" not found.`) };
953
+ if (isServerDisabled(definition)) return { error: disabledResult("call", serverName) };
954
+ try {
955
+ // Connected metadata is read below, so it does not require another connection attempt.
956
+ let connection = state.manager.getConnection(serverName);
957
+ if (connection?.status !== "connected") {
958
+ await lazyConnect(state, serverName, ownedSignal);
959
+ connection = state.manager.getConnection(serverName);
960
+ } else {
961
+ markKeepAliveAfterConnect(state, serverName);
962
+ }
963
+ if (connection?.status === "needs-auth") connection = await recoverAuthConnection(serverName, ownedSignal);
964
+ if (connection?.status === "needs-auth") throw new SessionRecoveryAuthRequiredError(serverName);
965
+ if (connection?.status !== "connected") {
966
+ const failedAgo = getFailureAgeSeconds(state, serverName);
967
+ return { error: failure(failedAgo === null ? "server_unavailable" : "server_backoff", `Server "${serverName}" not available${failedAgo === null ? "" : ` (last failed ${failedAgo}s ago)`}`) };
968
+ }
969
+ // Use the live descriptor for visibility, filtering, approval, schema and UI hints.
970
+ if (connection.tools) {
971
+ const { metadata } = buildToolMetadata(connection.tools, connection.resources ?? [], definition, serverName, state.config.settings?.toolPrefix ?? "server");
972
+ state.toolMetadata.set(serverName, metadata);
973
+ }
974
+ return { connection, recoverAuthConnection, autoAuthAttempted: () => autoAuthAttempted };
975
+ } catch (error) {
976
+ if (isAbortError(error, ownedSignal)) throw error;
977
+ const message = error instanceof SessionRecoveryAuthRequiredError
978
+ ? error.authMessage ?? getAuthRequiredMessage(state, serverName)
979
+ : error instanceof Error ? error.message : String(error);
980
+ if (!(error instanceof SessionRecoveryAuthRequiredError)) {
981
+ recordFailure(state, serverName, message);
982
+ updateStatusBar(state);
983
+ }
984
+ return { error: failure(error instanceof SessionRecoveryAuthRequiredError ? "auth_required" : "connect_failed", message) };
985
+ }
986
+ }
987
+
962
988
  export async function executeCall(
963
989
  state: McpExtensionState,
964
990
  toolName: string,
@@ -966,396 +992,118 @@ export async function executeCall(
966
992
  serverOverride?: string,
967
993
  getPiTools?: () => ToolInfo[],
968
994
  signal?: AbortSignal,
969
- onRawResult?: (result: unknown) => void,
970
995
  identity: McpToolCallIdentity = {},
971
996
  beforeDispatch?: (signal: AbortSignal | undefined, operation: McpOperationContext) => Promise<void>,
997
+ options: ExecuteCallOptions = {},
972
998
  ): Promise<ProxyToolResult> {
973
999
  const ownedSignal = combineAbortSignals(state.owner?.signal, signal);
974
1000
  throwIfAborted(ownedSignal);
975
- let serverName: string | undefined = serverOverride;
976
- let toolMeta: ToolMetadata | undefined;
977
- let autoAuthAttempted = false;
978
- const prefixMode = state.config.settings?.toolPrefix ?? "server";
979
- const disabledCallResult = (disabledServer: string, metadata?: ToolMetadata): ProxyToolResult => {
980
- if (!metadata) {
981
- const message = `Server "${disabledServer}" is disabled. Run /mcp enable ${disabledServer} and /reload to enable it.`;
982
- return {
983
- content: [{ type: "text" as const, text: message }],
984
- details: { mode: "call", error: "server_disabled", server: disabledServer, requestedTool: toolName, message },
985
- };
986
- }
987
- const message = `Server "${disabledServer}" is disabled. Run /mcp enable ${disabledServer} and /reload to enable it.`;
988
- const identity = metadata.resourceUri
989
- ? { server: disabledServer, resourceUri: metadata.resourceUri }
990
- : { server: disabledServer, tool: metadata.originalName };
991
- return {
992
- content: [{ type: "text" as const, text: message }],
993
- details: { mode: "call", error: "server_disabled", ...identity, message },
994
- };
1001
+ if (typeof toolName !== "string" || !toolName.trim() || (serverOverride !== undefined && (typeof serverOverride !== "string" || !serverOverride))) return {
1002
+ content: [{ type: "text", text: "Specify a non-empty tool name and, when supplied, server." }],
1003
+ details: { mode: "call", error: "invalid_arguments" },
995
1004
  };
996
-
997
- if (serverName && !state.config.mcpServers[serverName]) {
998
- return {
999
- content: [{ type: "text" as const, text: `Server "${serverName}" not found. Use mcp({}) to see available servers.` }],
1000
- details: { mode: "call", error: "server_not_found", server: serverName, requestedTool: toolName },
1001
- };
1002
- }
1003
- if (serverName) {
1004
- toolMeta = findToolByName(state.toolMetadata.get(serverName), toolName);
1005
- if (isServerDisabled(state.config.mcpServers[serverName])) {
1006
- return disabledCallResult(serverName, toolMeta);
1007
- }
1008
- } else {
1009
- let disabledMatch: { serverName: string; toolMeta: ToolMetadata } | undefined;
1010
- for (const [server, metadata] of state.toolMetadata.entries()) {
1011
- const found = findToolByName(metadata, toolName);
1012
- if (!found) continue;
1013
- if (isServerDisabled(state.config.mcpServers[server])) {
1014
- disabledMatch ??= { serverName: server, toolMeta: found };
1015
- continue;
1016
- }
1017
- serverName = server;
1018
- toolMeta = found;
1019
- break;
1020
- }
1021
- if (!toolMeta && disabledMatch) return disabledCallResult(disabledMatch.serverName, disabledMatch.toolMeta);
1022
- }
1023
-
1024
- if (serverName && !toolMeta && !state.toolMetadata.has(serverName)) {
1025
- const connected = await lazyConnect(state, serverName, ownedSignal);
1026
- if (connected) {
1027
- toolMeta = findToolByName(state.toolMetadata.get(serverName), toolName);
1028
- } else {
1029
- const needsAuthConnection = state.manager.getConnection(serverName);
1030
- if (needsAuthConnection?.status === "needs-auth") {
1031
- if (!autoAuthAttempted) {
1032
- autoAuthAttempted = true;
1033
- const autoAuth = await attemptAutoAuth(state, serverName, ownedSignal);
1034
- if (autoAuth.status === "failed") {
1035
- return {
1036
- content: [{ type: "text" as const, text: autoAuth.message }],
1037
- details: { mode: "call", error: "auth_required", server: serverName, requestedTool: toolName, message: autoAuth.message },
1038
- };
1039
- }
1040
- if (autoAuth.status === "success") {
1041
- clearFailure(state, serverName);
1042
- let connectedAfterAuth = false;
1043
- try {
1044
- const definition = state.config.mcpServers[serverName];
1045
- if (definition) {
1046
- await state.manager.reconnect(serverName, definition, needsAuthConnection, ownedSignal);
1047
- connectedAfterAuth = true;
1048
- }
1049
- } catch (error) {
1050
- if (isAbortError(error, ownedSignal)) throwIfAborted(ownedSignal);
1051
- recordFailure(state, serverName, error instanceof Error ? error.message : String(error));
1052
- updateStatusBar(state);
1053
- }
1054
- if (connectedAfterAuth) connectedAfterAuth = await lazyConnect(state, serverName, ownedSignal);
1055
- if (connectedAfterAuth) {
1056
- toolMeta = findToolByName(state.toolMetadata.get(serverName), toolName);
1057
- if (!toolMeta) {
1058
- const suggestions = rankSuggestions(state, toolName, 5, serverName);
1059
- const suggestionText = suggestions.length > 0 ? ` Did you mean: ${suggestions.join(", ")}` : "";
1060
- return {
1061
- content: [{ type: "text" as const, text: `Tool "${toolName}" not found on "${serverName}" after reconnect.${suggestionText}` }],
1062
- details: { mode: "call", error: "tool_not_found_after_reconnect", server: serverName, requestedTool: toolName, suggestions },
1063
- };
1064
- }
1065
- }
1066
- }
1067
- }
1068
-
1069
- if (!toolMeta && state.manager.getConnection(serverName)?.status === "needs-auth") {
1070
- const message = getAuthRequiredMessage(state, serverName);
1071
- return {
1072
- content: [{ type: "text" as const, text: message }],
1073
- details: { mode: "call", error: "auth_required", server: serverName, requestedTool: toolName, message },
1074
- };
1075
- }
1076
- }
1077
-
1078
- if (!toolMeta) {
1079
- const failedAgo = getFailureAgeSeconds(state, serverName);
1080
- if (failedAgo !== null) {
1081
- return {
1082
- content: [{ type: "text" as const, text: `Server "${serverName}" not available (last failed ${failedAgo}s ago)` }],
1083
- details: { mode: "call", error: "server_backoff", server: serverName, requestedTool: toolName },
1084
- };
1085
- }
1086
- }
1005
+ if (args !== undefined && (args === null || typeof args !== "object" || Array.isArray(args))) return {
1006
+ content: [{ type: "text", text: "Tool arguments must be an object." }],
1007
+ details: { mode: "call", error: "invalid_arguments", requestedTool: toolName },
1008
+ };
1009
+ let serverName = serverOverride;
1010
+ const matches = (metadata: ToolMetadata[] = []) => {
1011
+ if (options.resourceUri !== undefined) return metadata.filter(tool => tool.resourceUri === options.resourceUri);
1012
+ if (options.exactOriginalName) return metadata.filter(tool => tool.originalName === toolName && !tool.resourceUri);
1013
+ if (serverOverride) {
1014
+ const originals = metadata.filter(tool => tool.originalName === toolName);
1015
+ if (originals.length) return originals;
1087
1016
  }
1088
- }
1089
-
1090
- let prefixMatchedServer: string | undefined;
1091
-
1092
- if (!serverName && !toolMeta) {
1093
- const candidates = Object.keys(state.config.mcpServers)
1094
- .filter(name => !isServerDisabled(state.config.mcpServers[name]))
1095
- .map(name => ({
1096
- name,
1097
- prefix: getServerPrefix(name, resolveToolPrefix(state.config.mcpServers[name], prefixMode)),
1098
- }))
1099
- .filter(c => c.prefix && toolName.startsWith(c.prefix + "_"))
1100
- .sort((a, b) => b.prefix.length - a.prefix.length)
1101
- .slice(0, 1);
1102
-
1103
- for (const { name: configuredServer } of candidates) {
1104
- const existingConnection = state.manager.getConnection(configuredServer);
1105
- const failedAgo = getFailureAgeSeconds(state, configuredServer);
1106
- if (failedAgo !== null && existingConnection?.status !== "needs-auth") continue;
1107
- if (state.toolMetadata.has(configuredServer)) {
1108
- prefixMatchedServer ??= configuredServer;
1109
- continue;
1110
- }
1111
-
1112
- let connected = await lazyConnect(state, configuredServer, ownedSignal);
1113
- const needsAuthConnection = state.manager.getConnection(configuredServer);
1114
- if (!connected && needsAuthConnection?.status === "needs-auth" && !autoAuthAttempted) {
1115
- autoAuthAttempted = true;
1116
- const autoAuth = await attemptAutoAuth(state, configuredServer, ownedSignal);
1117
- if (autoAuth.status === "failed") {
1118
- return {
1119
- content: [{ type: "text" as const, text: autoAuth.message }],
1120
- details: { mode: "call", error: "auth_required", server: configuredServer, requestedTool: toolName, message: autoAuth.message },
1121
- };
1122
- }
1123
- if (autoAuth.status === "success") {
1124
- clearFailure(state, configuredServer);
1125
- try {
1126
- const definition = state.config.mcpServers[configuredServer];
1127
- if (definition) {
1128
- await state.manager.reconnect(configuredServer, definition, needsAuthConnection, ownedSignal);
1129
- connected = true;
1130
- }
1131
- } catch (error) {
1132
- if (isAbortError(error, ownedSignal)) throwIfAborted(ownedSignal);
1133
- recordFailure(state, configuredServer, error instanceof Error ? error.message : String(error));
1134
- updateStatusBar(state);
1135
- }
1136
- if (connected) connected = await lazyConnect(state, configuredServer, ownedSignal);
1137
- }
1138
- }
1139
-
1140
- if (!connected) continue;
1141
- if (!prefixMatchedServer) prefixMatchedServer = configuredServer;
1142
- toolMeta = findToolByName(state.toolMetadata.get(configuredServer), toolName);
1143
- if (toolMeta) {
1144
- serverName = configuredServer;
1145
- break;
1146
- }
1017
+ const exact = metadata.filter(tool => tool.name === toolName);
1018
+ if (exact.length) return exact;
1019
+ return metadata.filter(tool => tool.name.replace(/-/g, "_") === toolName.replace(/-/g, "_"));
1020
+ };
1021
+ const ambiguous = (): ProxyToolResult => ({
1022
+ content: [{ type: "text", text: `Tool "${toolName}" is ambiguous. Specify its exact server and tool name; no call was dispatched.` }],
1023
+ details: { mode: "call", error: "ambiguous_tool", requestedTool: toolName, ...(serverName ? { server: serverName } : {}) },
1024
+ });
1025
+ if (!serverName) {
1026
+ const candidates = [...state.toolMetadata].flatMap(([server, metadata]) =>
1027
+ isServerDisabled(state.config.mcpServers[server]) ? [] : matches(metadata).map(tool => ({ server, tool })));
1028
+ if (candidates.length > 1) return ambiguous();
1029
+ serverName = candidates[0]?.server;
1030
+ if (!serverName) {
1031
+ const disabled = [...state.toolMetadata].find(([server, metadata]) => isServerDisabled(state.config.mcpServers[server]) && matches(metadata).length > 0);
1032
+ if (disabled) return disabledResult("call", disabled[0]);
1033
+ const prefixes = Object.entries(state.config.mcpServers)
1034
+ .filter(([, definition]) => !isServerDisabled(definition))
1035
+ .map(([server, definition]) => ({ server, prefix: getServerPrefix(server, resolveToolPrefix(definition, state.config.settings?.toolPrefix ?? "server")) }))
1036
+ .filter(({ prefix }) => prefix && toolName.startsWith(`${prefix}_`))
1037
+ .sort((a, b) => b.prefix.length - a.prefix.length);
1038
+ if (prefixes.length > 1 && prefixes[0]?.prefix === prefixes[1]?.prefix) return ambiguous();
1039
+ serverName = prefixes[0]?.server;
1147
1040
  }
1148
1041
  }
1149
-
1150
- if (!serverName || !toolMeta) {
1151
- const nativeTool = !serverOverride
1152
- ? getPiTools?.().find((tool) => tool.name === toolName && tool.name !== "mcp")
1153
- : undefined;
1154
- if (nativeTool) {
1155
- return {
1156
- content: [{ type: "text" as const, text: `"${toolName}" is a native Pi tool. Call ${toolName} directly instead of using mcp({ tool: "${toolName}" }).` }],
1157
- details: { mode: "call", error: "native_tool", requestedTool: toolName },
1158
- };
1159
- }
1160
-
1161
- const hintServer = serverName ?? prefixMatchedServer;
1162
- const normalizedToolName = toolName.replace(/-/g, "_");
1163
- const localOriginalName = serverOverride && state.toolMetadata.get(serverOverride)
1164
- ?.some(tool => tool.originalName.replace(/-/g, "_") === normalizedToolName);
1165
- const suggestedOwner = serverOverride && !localOriginalName
1166
- ? [...state.toolMetadata].find(([candidateServer, metadata]) =>
1167
- candidateServer !== serverOverride
1168
- && !isServerDisabled(state.config.mcpServers[candidateServer])
1169
- && findToolByName(metadata, toolName))
1170
- : undefined;
1171
- const suggestedTool = suggestedOwner ? findToolByName(suggestedOwner[1], toolName) : undefined;
1172
- if (suggestedOwner && suggestedTool) {
1173
- const suggestedServer = suggestedOwner[0];
1174
- return {
1175
- content: [{ type: "text" as const, text: `Tool ${JSON.stringify(toolName)} is on server ${JSON.stringify(suggestedServer)}, not ${JSON.stringify(serverOverride)}. Retry the same call with server: ${JSON.stringify(suggestedServer)} or omit server.` }],
1176
- details: { mode: "call", error: "tool_not_found", requestedTool: toolName, hintServer, suggestedServer, suggestions: [suggestedTool.name] },
1177
- };
1178
- }
1179
-
1180
- const suggestions = rankSuggestions(state, toolName, 5, hintServer);
1181
- let hint: string;
1182
- if (suggestions.length > 0) {
1183
- hint = ` Did you mean: ${suggestions.join(", ")}. Inspect with mcp({ describe: "${suggestions[0]}" }).`;
1184
- } else if (hintServer) {
1185
- hint = ` Search with mcp({ search: ${JSON.stringify(toolName)}, server: ${JSON.stringify(hintServer)} }).`;
1186
- } else {
1187
- hint = ` Use mcp({ search: "..." }) to search.`;
1188
- }
1189
- const suggestionOnHintServer = hintServer && suggestions.some(suggestion =>
1190
- state.toolMetadata.get(hintServer)?.some(tool => tool.name === suggestion));
1191
- if (hintServer && (suggestions.length === 0 || suggestionOnHintServer) && state.toolMetadata.has(hintServer) && state.manager.getConnection(hintServer)?.status !== "connected") {
1192
- hint += ` Refresh a stale catalog with mcp({ connect: ${JSON.stringify(hintServer)} }).`;
1193
- }
1042
+ const missing = (): ProxyToolResult => {
1043
+ const nativeTool = !serverOverride && getPiTools?.().find(tool => tool.name === toolName && tool.name !== "mcp");
1044
+ if (nativeTool) return {
1045
+ content: [{ type: "text", text: `"${toolName}" is a native Pi tool. Call ${toolName} directly instead of using mcp({ action: "call", tool: "${toolName}" }).` }],
1046
+ details: { mode: "call", error: "native_tool", requestedTool: toolName },
1047
+ };
1048
+ const normalizedName = toolName.replace(/-/g, "_");
1049
+ const localOriginal = serverOverride && state.toolMetadata.get(serverOverride)?.some(tool => tool.originalName.replace(/-/g, "_") === normalizedName);
1050
+ const owners = serverOverride && !localOriginal
1051
+ ? [...state.toolMetadata].filter(([server, metadata]) => server !== serverOverride && !isServerDisabled(state.config.mcpServers[server]) && matches(metadata).length === 1)
1052
+ : [];
1053
+ const suggestedOwner = owners.length === 1 ? owners[0] : undefined;
1054
+ if (suggestedOwner) return {
1055
+ content: [{ type: "text", text: `Tool ${JSON.stringify(toolName)} is on server ${JSON.stringify(suggestedOwner[0])}, not ${JSON.stringify(serverOverride)}. Retry the same call with server: ${JSON.stringify(suggestedOwner[0])} or omit server.` }],
1056
+ details: { mode: "call", error: "tool_not_found", requestedTool: toolName, hintServer: serverName, suggestedServer: suggestedOwner[0], suggestions: matches(suggestedOwner[1]).map(tool => tool.name) },
1057
+ };
1058
+ const suggestions = rankSuggestions(state, toolName, 5, serverName);
1059
+ let hint = suggestions.length
1060
+ ? ` Did you mean: ${suggestions.join(", ")}. Inspect with mcp({ action: "describe", tool: ${JSON.stringify(suggestions[0])} }).`
1061
+ : serverName ? ` Search with mcp({ action: "search", query: ${JSON.stringify(toolName)}, server: ${JSON.stringify(serverName)} }).` : ' Use mcp({ action: "search", query: "..." }) to search.';
1062
+ if (serverName && state.toolMetadata.has(serverName) && state.manager.getConnection(serverName)?.status !== "connected") hint += ` Refresh a stale catalog with mcp({ action: "connect", server: ${JSON.stringify(serverName)} }).`;
1194
1063
  return {
1195
- content: [{ type: "text" as const, text: `Tool "${toolName}" not found.${hint}` }],
1196
- details: { mode: "call", error: "tool_not_found", requestedTool: toolName, hintServer, suggestions },
1064
+ content: [{ type: "text", text: `Tool "${toolName}" not found.${hint}` }],
1065
+ details: { mode: "call", error: "tool_not_found", requestedTool: toolName, hintServer: serverName, suggestions },
1197
1066
  };
1067
+ };
1068
+ if (!serverName) return missing();
1069
+ const definition = state.config.mcpServers[serverName];
1070
+ if (!definition) 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 } };
1071
+ if (isServerDisabled(definition)) return disabledResult("call", serverName);
1072
+ const cached = state.toolMetadata.get(serverName);
1073
+ if (matches(cached).length > 1) return ambiguous();
1074
+ // Misspellings in a known catalog do not cause connection churn or guessed execution.
1075
+ if (cached && !matches(cached).length && !options.exactOriginalName && options.resourceUri === undefined) return missing();
1076
+
1077
+ const prepared = await prepareMcpConnection(state, serverName, ownedSignal);
1078
+ if (prepared.error) {
1079
+ const cachedTarget = matches(cached)[0];
1080
+ return { ...prepared.error, details: { ...prepared.error.details, ...(cachedTarget
1081
+ ? cachedTarget.resourceUri ? { resourceUri: cachedTarget.resourceUri } : { tool: cachedTarget.originalName }
1082
+ : { requestedTool: toolName }) } };
1198
1083
  }
1199
-
1200
- const callIdentity = toolMeta.resourceUri
1201
- ? { server: serverName, resourceUri: toolMeta.resourceUri }
1202
- : { server: serverName, tool: toolMeta.originalName };
1203
-
1204
- let connection = state.manager.getConnection(serverName);
1205
- let refreshAfterAuth = false;
1206
- if (connection?.status === "needs-auth") {
1207
- if (!autoAuthAttempted) {
1208
- autoAuthAttempted = true;
1209
- const autoAuth = await attemptAutoAuth(state, serverName, ownedSignal);
1210
- if (autoAuth.status === "failed") {
1211
- return {
1212
- content: [{ type: "text" as const, text: autoAuth.message }],
1213
- details: { mode: "call", error: "auth_required", ...callIdentity, message: autoAuth.message },
1214
- };
1215
- }
1216
- if (autoAuth.status === "success") {
1217
- clearFailure(state, serverName);
1218
- refreshAfterAuth = true;
1219
- }
1220
- }
1221
-
1222
- if (connection?.status === "needs-auth" && !refreshAfterAuth) {
1223
- const message = getAuthRequiredMessage(state, serverName);
1224
- return {
1225
- content: [{ type: "text" as const, text: message }],
1226
- details: { mode: "call", error: "auth_required", ...callIdentity, message },
1227
- };
1228
- }
1229
- }
1230
- if (!connection || connection.status !== "connected") {
1231
- const failedAgo = getFailureAgeSeconds(state, serverName);
1232
- if (failedAgo !== null) {
1233
- return {
1234
- content: [{ type: "text" as const, text: `Server "${serverName}" not available (last failed ${failedAgo}s ago)` }],
1235
- details: { mode: "call", error: "server_backoff", ...callIdentity },
1236
- };
1237
- }
1238
-
1239
- const definition = state.config.mcpServers[serverName];
1240
- if (!definition) {
1241
- return {
1242
- content: [{ type: "text" as const, text: `Server "${serverName}" not connected` }],
1243
- details: { mode: "call", error: "server_not_connected", ...callIdentity },
1244
- };
1245
- }
1246
-
1247
- try {
1248
- if (state.ui) {
1249
- state.ui.setStatus("mcp", formatMcpStatus(state.config, `connecting to ${serverName}...`));
1250
- }
1251
- connection = refreshAfterAuth && connection
1252
- ? await state.manager.reconnect(serverName, definition, connection, ownedSignal)
1253
- : await state.manager.connect(serverName, definition, ownedSignal);
1254
- if (connection.status === "needs-auth") {
1255
- if (!autoAuthAttempted) {
1256
- autoAuthAttempted = true;
1257
- const autoAuth = await attemptAutoAuth(state, serverName, ownedSignal);
1258
- if (autoAuth.status === "failed") {
1259
- return {
1260
- content: [{ type: "text" as const, text: autoAuth.message }],
1261
- details: { mode: "call", error: "auth_required", ...callIdentity, message: autoAuth.message },
1262
- };
1263
- }
1264
- if (autoAuth.status === "success") {
1265
- connection = await state.manager.reconnect(serverName, definition, connection, ownedSignal);
1266
- }
1267
- }
1268
-
1269
- if (connection.status === "needs-auth") {
1270
- const message = getAuthRequiredMessage(state, serverName);
1271
- return {
1272
- content: [{ type: "text" as const, text: message }],
1273
- details: { mode: "call", error: "auth_required", ...callIdentity, message },
1274
- };
1275
- }
1276
- }
1277
- clearFailure(state, serverName);
1278
- updateServerMetadata(state, serverName);
1279
- updateMetadataCache(state, serverName);
1280
- notifyToolMetadataUpdated(state, serverName, "proxy-call-reconnect");
1281
- markKeepAliveAfterConnect(state, serverName);
1282
- updateStatusBar(state);
1283
- toolMeta = findToolByName(state.toolMetadata.get(serverName), toolName);
1284
- if (!toolMeta) {
1285
- const suggestions = rankSuggestions(state, toolName, 5, serverName);
1286
- const hint = suggestions.length > 0
1287
- ? ` Did you mean: ${suggestions.join(", ")}. Inspect with mcp({ describe: "${suggestions[0]}" }).`
1288
- : ` Search with mcp({ search: ${JSON.stringify(toolName)}, server: ${JSON.stringify(serverName)} }).`;
1289
- return {
1290
- content: [{ type: "text" as const, text: `Tool "${toolName}" not found on "${serverName}" after reconnect.${hint}` }],
1291
- details: { mode: "call", error: "tool_not_found_after_reconnect", server: serverName, requestedTool: toolName, suggestions },
1292
- };
1293
- }
1294
- } catch (error) {
1295
- const message = error instanceof Error ? error.message : String(error);
1296
- if (!isAbortError(error, ownedSignal)) recordFailure(state, serverName, message);
1297
- updateStatusBar(state);
1298
- return {
1299
- content: [{ type: "text" as const, text: `Failed to connect to "${serverName}": ${message}` }],
1300
- details: { mode: "call", error: isAbortError(error, ownedSignal) ? "aborted" : "connect_failed", ...callIdentity, message },
1301
- };
1302
- }
1303
- }
1304
-
1305
- if (isServerDisabled(state.config.mcpServers[serverName])) {
1306
- return disabledCallResult(serverName, toolMeta);
1307
- }
1308
-
1084
+ const targets = matches(state.toolMetadata.get(serverName));
1085
+ if (targets.length > 1) return ambiguous();
1086
+ const toolMeta = targets[0];
1087
+ if (!toolMeta) return missing();
1088
+ if (isServerDisabled(state.config.mcpServers[serverName])) return disabledResult("call", serverName);
1309
1089
  const approval = await ensureToolCallApproved(state, serverName, toolMeta, args, ownedSignal);
1310
- if (approval.ok === false) {
1090
+ if (!approval.ok) {
1311
1091
  const denied = approval.reason === "denied";
1312
- const message = denied
1313
- ? `The user declined approval to run MCP tool "${toolMeta.originalName}" on server "${serverName}".`
1314
- : `MCP tool "${toolMeta.originalName}" on server "${serverName}" is approval-gated and requires an interactive session.`;
1315
1092
  return {
1316
- content: [{ type: "text" as const, text: message }],
1317
- details: {
1318
- mode: "call",
1319
- error: denied ? "approval_denied" : "approval_required",
1320
- server: serverName,
1321
- tool: toolMeta.originalName,
1322
- },
1093
+ content: [{ type: "text", text: denied
1094
+ ? `The user declined approval to run MCP tool "${toolMeta.originalName}" on server "${serverName}".`
1095
+ : `MCP tool "${toolMeta.originalName}" on server "${serverName}" is approval-gated and requires an interactive session.` }],
1096
+ details: { mode: "call", error: denied ? "approval_denied" : "approval_required", server: serverName, tool: toolMeta.originalName },
1323
1097
  };
1324
1098
  }
1325
-
1326
- const recoverAuthConnection: NonNullable<SessionRecoveryDeps["onNeedsAuth"]> = async (_serverName, recoverySignal = ownedSignal, challenge) => {
1327
- throwIfAborted(recoverySignal);
1328
- const current = state.manager.getConnection(serverName);
1329
- if (current?.status === "connected" && current !== challenge?.connection) return current;
1330
-
1331
- if (!autoAuthAttempted) {
1332
- autoAuthAttempted = true;
1333
- const autoAuth = await attemptAutoAuth(state, serverName, recoverySignal);
1334
- throwIfAborted(recoverySignal);
1335
- if (autoAuth.status === "failed") {
1336
- throw new SessionRecoveryAuthRequiredError(serverName, autoAuth.message);
1337
- }
1338
- if (autoAuth.status === "success") {
1339
- const definition = state.config.mcpServers[serverName];
1340
- if (!definition) return undefined;
1341
- if (!current) return undefined;
1342
- clearFailure(state, serverName);
1343
- connection = await state.manager.reconnect(serverName, definition, current, recoverySignal);
1344
- return connection;
1345
- }
1346
- }
1347
- return challenge ? undefined : state.manager.getConnection(serverName);
1348
- };
1349
-
1350
1099
  return runToolCall(state, serverName, toolMeta, args, {
1351
1100
  ...identity,
1352
1101
  ...(beforeDispatch ? { beforeDispatch } : {}),
1353
- detailsBase: { mode: "call", ...callIdentity },
1354
- ownedSignal,
1355
- signal,
1356
- recoverAuthConnection,
1102
+ ...(options.raw !== undefined ? { raw: options.raw } : {}),
1103
+ detailsBase: { mode: "call", server: serverName, ...(toolMeta.resourceUri ? { resourceUri: toolMeta.resourceUri } : { tool: toolMeta.originalName }) },
1104
+ ownedSignal, signal,
1105
+ recoverAuthConnection: prepared.recoverAuthConnection!,
1357
1106
  authRequiredMessage: () => getAuthRequiredMessage(state, serverName),
1358
- autoAuthAttempted: () => autoAuthAttempted,
1359
- ...(onRawResult ? { onRawResult } : {}),
1107
+ autoAuthAttempted: prepared.autoAuthAttempted!,
1360
1108
  });
1361
1109
  }