@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
@@ -1,13 +1,12 @@
1
1
  import { DEFAULT_REQUEST_TIMEOUT_MSEC, UrlElicitationRequiredError } from "@modelcontextprotocol/client";
2
2
  import { getServerPrefix, isServerDisabled, isNonInteractiveOAuth, parseUiPromptHandoff, resolveToolPrefix } from "./types.js";
3
- import { lazyConnect, markKeepAliveAfterConnect, notifyToolMetadataUpdated, updateServerMetadata, updateMetadataCache, getFailureAgeSeconds, updateStatusBar, clearFailure, recordFailure } from "./init.js";
3
+ import { lazyConnect, markKeepAliveAfterConnect, notifyToolMetadataUpdated, updateMetadataCache, getFailureAgeSeconds, updateStatusBar, clearFailure, recordFailure } from "./init.js";
4
4
  import { abortable, throwIfAborted } from "./abort.js";
5
5
  import { combineAbortSignals, isAbortError } from "./runtime-owner.js";
6
- import { buildToolMetadata, findToolByName, formatSchema } from "./tool-metadata.js";
7
- import { renderTsShape } from "./ts-shape.js";
6
+ import { buildToolMetadata, catalogCoverage, findToolByName, formatSchema, toToolDescriptor } from "./tool-metadata.js";
8
7
  import { reconstructPromptMetadata } from "./metadata-cache.js";
9
- import { resolveMcpResultContent, transformMcpContent } from "./tool-registrar.js";
10
- import { guardMcpOutput, guardedMcpDetails, resolveMcpOutputGuardOptions } from "./mcp-output-guard.js";
8
+ import { renderMcpResultContent } from "./tool-registrar.js";
9
+ import { guardMcpOutput, guardedMcpDetails, retainMcpResult, resolveMcpOutputGuardOptions, formatMcpResultReference } from "./mcp-output-guard.js";
11
10
  import { maybeStartUiSession, summarizeUiSessionResult } from "./ui-session.js";
12
11
  import { formatAuthRequiredMessage, formatMcpStatus, resolveServerUrl, truncateAtWord } from "./utils.js";
13
12
  import { authenticate, completeAuthFromInput, startAuth, supportsOAuth } from "./mcp-auth-flow.js";
@@ -66,7 +65,7 @@ function formatManualAuthInstructions(serverName, authorizationUrl) {
66
65
  "If the browser cannot reach the callback, copy the full redirected localhost URL from your address bar and send it back with:",
67
66
  `mcp({ action: "auth-complete", server: "${serverName}", args: { redirectUrl: "PASTE_REDIRECT_URL_HERE" } })`,
68
67
  "",
69
- 'You can also pass just the `code` query parameter as `args: { code: "PASTE_CODE_HERE" }`. JSON-string args remain supported.',
68
+ 'You can also pass just the `code` query parameter as `args: { code: "PASTE_CODE_HERE" }`.',
70
69
  portNote.trimEnd(),
71
70
  ].filter(Boolean).join("\n");
72
71
  }
@@ -204,7 +203,7 @@ export function executeStatus(state) {
204
203
  const disabled = isServerDisabled(definition);
205
204
  const connection = disabled ? undefined : state.manager.getConnection(name);
206
205
  const metadata = disabled ? undefined : state.toolMetadata.get(name);
207
- const toolCount = metadata?.length ?? 0;
206
+ const toolCount = metadata?.filter(tool => !tool.resourceUri).length ?? 0;
208
207
  const failedAgo = disabled ? null : getFailureAgeSeconds(state, name);
209
208
  let status = disabled ? "disabled" : "not connected";
210
209
  if (!disabled && connection?.status === "connected") {
@@ -225,6 +224,7 @@ export function executeStatus(state) {
225
224
  const enabledServers = servers.filter(s => !s.disabled);
226
225
  const totalTools = enabledServers.reduce((sum, s) => sum + s.toolCount, 0);
227
226
  const connectedCount = enabledServers.filter(s => s.status === "connected").length;
227
+ const coverage = catalogCoverage(state);
228
228
  let text = `MCP: ${connectedCount}/${enabledServers.length} connected, ${totalTools} tools available (calls connect lazily)`;
229
229
  if (disabledCount > 0)
230
230
  text += ` (${disabledCount} disabled)`;
@@ -250,14 +250,16 @@ export function executeStatus(state) {
250
250
  text += `✗ ${server.name} (failed ${server.failedAgo ?? 0}s ago)\n`;
251
251
  continue;
252
252
  }
253
- text += `○ ${server.name} (not connected)\n`;
253
+ text += `○ ${server.name} (not discovered)\n`;
254
254
  }
255
255
  if (servers.length > 0) {
256
- text += `\nmcp({ server: "name" }) to browse tools, mcp({ search: "..." }) to search`;
256
+ text += `\nmcp_search({ query: "...", server: "name" }) to discover tools; mcp({ action: "resources", server: "name" }) for resources.`;
257
+ if (!coverage.complete)
258
+ text += `\nPartial catalog: ${coverage.unknownServers.length} undiscovered server(s).`;
257
259
  }
258
260
  return {
259
261
  content: [{ type: "text", text: text.trim() }],
260
- details: { mode: "status", servers, totalTools, connectedCount, disabledCount },
262
+ details: { mode: "status", servers, totalTools, connectedCount, disabledCount, coverage },
261
263
  };
262
264
  }
263
265
  export async function executeAuthStart(state, serverName, signal) {
@@ -266,7 +268,7 @@ export async function executeAuthStart(state, serverName, signal) {
266
268
  const definition = state.config.mcpServers[serverName];
267
269
  if (!definition) {
268
270
  return {
269
- content: [{ type: "text", text: `Server "${serverName}" not found. Use mcp({}) to see available servers.` }],
271
+ content: [{ type: "text", text: `Server "${serverName}" not found. Use mcp({ action: "status" }) to see available servers.` }],
270
272
  details: { mode: "auth-start", error: "not_found", server: serverName },
271
273
  };
272
274
  }
@@ -312,7 +314,7 @@ export async function executeAuthComplete(state, serverName, input, signal) {
312
314
  const definition = state.config.mcpServers[serverName];
313
315
  if (!definition) {
314
316
  return {
315
- content: [{ type: "text", text: `Server "${serverName}" not found. Use mcp({}) to see available servers.` }],
317
+ content: [{ type: "text", text: `Server "${serverName}" not found. Use mcp({ action: "status" }) to see available servers.` }],
316
318
  details: { mode: "auth-complete", error: "not_found", server: serverName },
317
319
  };
318
320
  }
@@ -338,7 +340,7 @@ export async function executeAuthComplete(state, serverName, input, signal) {
338
340
  clearFailure(state, serverName);
339
341
  updateStatusBar(state);
340
342
  return {
341
- content: [{ type: "text", text: `OAuth authentication successful for "${serverName}". Run mcp({ connect: "${serverName}" }) to connect with the new token.` }],
343
+ content: [{ type: "text", text: `OAuth authentication successful for "${serverName}". Run mcp({ action: "connect", server: "${serverName}" }) to connect with the new token.` }],
342
344
  details: { mode: "auth-complete", server: serverName, authenticated: true },
343
345
  };
344
346
  }
@@ -350,63 +352,44 @@ export async function executeAuthComplete(state, serverName, input, signal) {
350
352
  };
351
353
  }
352
354
  }
353
- export function executeDescribe(state, toolName) {
354
- let serverName;
355
- let toolMeta;
356
- let disabledMatch;
357
- for (const [server, metadata] of state.toolMetadata.entries()) {
358
- const found = findToolByName(metadata, toolName);
359
- if (!found)
360
- continue;
361
- if (isServerDisabled(state.config.mcpServers[server])) {
362
- disabledMatch ??= server;
363
- continue;
364
- }
365
- serverName = server;
366
- toolMeta = found;
367
- break;
368
- }
369
- if (!serverName || !toolMeta) {
370
- if (disabledMatch)
371
- return disabledResult("describe", disabledMatch);
372
- const suggestions = rankSuggestions(state, toolName, 5);
355
+ export function executeDescribe(state, toolName, serverFilter) {
356
+ if (serverFilter && isServerDisabled(state.config.mcpServers[serverFilter]))
357
+ return disabledResult("describe", serverFilter);
358
+ const catalog = [...state.toolMetadata].flatMap(([server, tools]) => (!serverFilter || serverFilter === server) && state.config.mcpServers[server] && !isServerDisabled(state.config.mcpServers[server])
359
+ ? tools.map(tool => ({ server, tool })) : []);
360
+ const originals = serverFilter ? catalog.filter(({ tool }) => tool.originalName === toolName) : [];
361
+ const exact = originals.length ? originals : catalog.filter(({ tool }) => tool.name === toolName);
362
+ const matches = exact.length ? exact : catalog.filter(({ tool }) => tool.name.replace(/-/g, "_") === toolName.replace(/-/g, "_"));
363
+ if (matches.length > 1)
364
+ return {
365
+ 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 })))}` }],
366
+ details: { mode: "describe", error: "ambiguous_tool", requestedTool: toolName },
367
+ };
368
+ const match = matches[0];
369
+ if (!match) {
370
+ const disabled = [...state.toolMetadata].find(([server, tools]) => isServerDisabled(state.config.mcpServers[server]) && findToolByName(tools, toolName));
371
+ if (disabled)
372
+ return disabledResult("describe", disabled[0]);
373
+ const suggestions = rankSuggestions(state, toolName, 5, serverFilter);
373
374
  const hint = suggestions.length > 0
374
- ? `Did you mean: ${suggestions.join(", ")}. Inspect with mcp({ describe: "${suggestions[0]}" }).`
375
- : `Use mcp({ search: "..." }) to search.`;
375
+ ? `Did you mean: ${suggestions.join(", ")}. Inspect with mcp({ action: "describe", tool: "${suggestions[0]}" }).`
376
+ : `Use mcp_search({ query: "..." }) to search.`;
376
377
  return {
377
378
  content: [{ type: "text", text: `Tool "${toolName}" not found. ${hint}` }],
378
379
  details: { mode: "describe", error: "tool_not_found", requestedTool: toolName, suggestions },
379
380
  };
380
381
  }
381
- const approvalMarker = isToolCallApprovalRequired(state.config, serverName, toolMeta)
382
- ? " (requires approval)"
383
- : "";
384
- let text = `${toolMeta.name}${approvalMarker}\n`;
385
- text += `Server: ${serverName}\n`;
386
- if (toolMeta.resourceUri) {
387
- text += `Type: Resource (reads from ${toolMeta.resourceUri})\n`;
388
- }
389
- text += `\n${toolMeta.description || "(no description)"}\n`;
390
- if (toolMeta.annotations)
391
- text += `\nAnnotations: ${JSON.stringify(toolMeta.annotations)}\n`;
392
- if (toolMeta.inputSchema && !toolMeta.resourceUri) {
393
- const shape = renderTsShape(toolMeta.inputSchema);
394
- text += shape === null ? `\nParameters:\n${formatSchema(toolMeta.inputSchema)}` : `\nShape:\n${shape}`;
395
- }
396
- else if (toolMeta.resourceUri) {
397
- text += `\nNo parameters required (resource tool).`;
398
- }
399
- else {
400
- text += `\nNo parameters defined.`;
401
- }
382
+ const { server: serverName, tool: toolMeta } = match;
383
+ const descriptor = toToolDescriptor(serverName, toolMeta);
384
+ const approvalRequired = isToolCallApprovalRequired(state.config, serverName, toolMeta);
402
385
  return {
403
- content: [{ type: "text", text: text.trim() }],
386
+ 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) }],
404
387
  details: { mode: "describe", tool: toolMeta, server: serverName },
405
388
  };
406
389
  }
407
390
  export function executeSearch(state, query, server, includeSchemas, limit = 12, offset = 0) {
408
391
  const showSchemas = includeSchemas !== false;
409
- const searchCall = (nextOffset) => `mcp({ search: ${JSON.stringify(query)}${server ? `, server: ${JSON.stringify(server)}` : ""}${showSchemas ? "" : ", includeSchemas: false"}, limit: ${limit}, offset: ${nextOffset} })`;
392
+ const searchCall = (nextOffset) => `mcp({ action: "search", query: ${JSON.stringify(query)}${server ? `, server: ${JSON.stringify(server)}` : ""}${showSchemas ? "" : ", includeSchemas: false"}, limit: ${limit}, offset: ${nextOffset} })`;
410
393
  if (server && isServerDisabled(state.config.mcpServers[server]))
411
394
  return disabledResult("search", server);
412
395
  let matches;
@@ -417,7 +400,7 @@ export function executeSearch(state, query, server, includeSchemas, limit = 12,
417
400
  details: { mode: "search", error: "empty_query" },
418
401
  };
419
402
  }
420
- matches = (state.toolMetadata.get(server) ?? [])
403
+ matches = (state.toolMetadata.get(server) ?? []).filter(tool => !tool.resourceUri)
421
404
  .map(tool => ({ server, tool, score: 0 }))
422
405
  .sort((a, b) => a.tool.name.localeCompare(b.tool.name));
423
406
  }
@@ -425,11 +408,13 @@ export function executeSearch(state, query, server, includeSchemas, limit = 12,
425
408
  matches = rankToolMatches(state, query, server);
426
409
  }
427
410
  const page = paginate(matches, offset, limit);
411
+ const coverage = catalogCoverage(state, server);
412
+ const coverageNotice = coverage.complete ? "" : `\nCatalog is incomplete. Undiscovered servers: ${coverage.unknownServers.join(", ")}. Search with server to discover one.`;
428
413
  if (page.total === 0) {
429
414
  const msg = server ? `No tools matching "${query}" in "${server}"` : `No tools matching "${query}"`;
430
415
  return {
431
- content: [{ type: "text", text: msg }],
432
- details: { mode: "search", matches: [], count: 0, hasMore: false, nextOffset: null, query },
416
+ content: [{ type: "text", text: msg + coverageNotice }],
417
+ details: { mode: "search", matches: [], count: 0, hasMore: false, nextOffset: null, query, coverage },
433
418
  };
434
419
  }
435
420
  if (page.items.length === 0) {
@@ -445,18 +430,7 @@ export function executeSearch(state, query, server, includeSchemas, limit = 12,
445
430
  ? " (requires approval)"
446
431
  : "";
447
432
  if (showSchemas) {
448
- text += `${match.tool.name}${approvalMarker}\n`;
449
- text += ` ${match.tool.description || "(no description)"}\n`;
450
- if (match.tool.inputSchema && !match.tool.resourceUri) {
451
- const shape = renderTsShape(match.tool.inputSchema);
452
- text += shape === null
453
- ? `\n Parameters:\n${formatSchema(match.tool.inputSchema, " ")}\n`
454
- : `\n Shape:\n${shape.split("\n").map(line => ` ${line}`).join("\n")}\n`;
455
- }
456
- else if (match.tool.resourceUri) {
457
- text += " No parameters (resource tool).\n";
458
- }
459
- text += "\n";
433
+ text += `${JSON.stringify({ ...toToolDescriptor(match.server, match.tool), ...(approvalMarker ? { approvalRequired: true } : {}) }, null, 2)}\n\n`;
460
434
  }
461
435
  else {
462
436
  text += `- ${match.tool.name}${approvalMarker}`;
@@ -471,9 +445,10 @@ export function executeSearch(state, query, server, includeSchemas, limit = 12,
471
445
  ? `\n${first}-${first + page.items.length - 1} of ${page.total} — ${nextSearch} for more\n`
472
446
  : `\n${first}-${first + page.items.length - 1} of ${page.total} — end\n`;
473
447
  return {
474
- content: [{ type: "text", text: text.trim() }],
448
+ content: [{ type: "text", text: text.trim() + coverageNotice }],
475
449
  details: {
476
450
  mode: "search",
451
+ coverage,
477
452
  matches: page.items.map(match => ({ server: match.server, tool: match.tool.name, score: match.score })),
478
453
  count: page.total,
479
454
  hasMore: page.hasMore,
@@ -486,13 +461,13 @@ export function executeList(state, server, limit = 12, offset = 0) {
486
461
  const definition = state.config.mcpServers[server];
487
462
  if (!definition) {
488
463
  return {
489
- content: [{ type: "text", text: `Server "${server}" not found. Use mcp({}) to see available servers.` }],
464
+ content: [{ type: "text", text: `Server "${server}" not found. Use mcp({ action: "status" }) to see available servers.` }],
490
465
  details: { mode: "list", server, tools: [], count: 0, error: "not_found" },
491
466
  };
492
467
  }
493
468
  if (isServerDisabled(definition))
494
469
  return disabledResult("list", server);
495
- const metadata = state.toolMetadata.get(server);
470
+ const metadata = state.toolMetadata.get(server)?.filter(tool => !tool.resourceUri);
496
471
  const connection = state.manager.getConnection(server);
497
472
  const instructions = state.serverInstructions.get(server);
498
473
  let instructionsText = "";
@@ -500,7 +475,7 @@ export function executeList(state, server, limit = 12, offset = 0) {
500
475
  const preview = truncateAtWord(instructions, INSTRUCTIONS_PREVIEW_LENGTH);
501
476
  instructionsText = `\n\nServer instructions:\n${preview}`;
502
477
  if (preview !== instructions) {
503
- instructionsText += `\nUse mcp({ instructions: "${server}" }) for the full text.`;
478
+ instructionsText += `\nUse mcp({ action: "instructions", server: "${server}" }) for the full text.`;
504
479
  }
505
480
  }
506
481
  if (!metadata?.length) {
@@ -517,14 +492,14 @@ export function executeList(state, server, limit = 12, offset = 0) {
517
492
  };
518
493
  }
519
494
  return {
520
- content: [{ type: "text", text: `Server "${server}" is configured but not connected. Use mcp({ connect: "${server}" }) or /mcp reconnect ${server} to retry.${instructionsText}` }],
495
+ content: [{ type: "text", text: `Server "${server}" is configured but not connected. Use mcp({ action: "connect", server: "${server}" }) or /mcp reconnect ${server} to retry.${instructionsText}` }],
521
496
  details: { mode: "list", server, tools: [], count: 0, error: "not_connected", hasInstructions: Boolean(instructions) },
522
497
  };
523
498
  }
524
499
  const page = paginate(metadata, offset, limit);
525
500
  if (page.items.length === 0) {
526
501
  return {
527
- content: [{ type: "text", text: `No tools at offset ${offset}; "${server}" has ${page.total} tools. Retry with mcp({ server: ${JSON.stringify(server)}, limit: ${limit}, offset: 0 }).` }],
502
+ content: [{ type: "text", text: `No tools at offset ${offset}; "${server}" has ${page.total} tools. Retry with mcp({ action: "list", server: ${JSON.stringify(server)}, limit: ${limit}, offset: 0 }).` }],
528
503
  details: { mode: "list", error: "offset_out_of_range", server, tools: [], count: page.total, hasMore: false, nextOffset: null, hasInstructions: Boolean(instructions) },
529
504
  };
530
505
  }
@@ -539,7 +514,7 @@ export function executeList(state, server, limit = 12, offset = 0) {
539
514
  }
540
515
  const first = (Number.isFinite(offset) ? Math.max(0, Math.trunc(offset)) : 0) + 1;
541
516
  text += page.hasMore
542
- ? `\n${first}-${first + page.items.length - 1} of ${page.total} — mcp({ server: ${JSON.stringify(server)}, limit: ${limit}, offset: ${page.nextOffset} }) for more`
517
+ ? `\n${first}-${first + page.items.length - 1} of ${page.total} — mcp({ action: "list", server: ${JSON.stringify(server)}, limit: ${limit}, offset: ${page.nextOffset} }) for more`
543
518
  : `\n${first}-${first + page.items.length - 1} of ${page.total} — end`;
544
519
  text += instructionsText;
545
520
  return {
@@ -559,7 +534,7 @@ export function executeInstructions(state, server) {
559
534
  const definition = state.config.mcpServers[server];
560
535
  if (!definition) {
561
536
  return {
562
- content: [{ type: "text", text: `Server "${server}" not found. Use mcp({}) to see available servers.` }],
537
+ content: [{ type: "text", text: `Server "${server}" not found. Use mcp({ action: "status" }) to see available servers.` }],
563
538
  details: { mode: "instructions", server, error: "not_found" },
564
539
  };
565
540
  }
@@ -580,7 +555,7 @@ export function executeInstructions(state, server) {
580
555
  };
581
556
  }
582
557
  return {
583
- content: [{ type: "text", text: `No instructions cached for "${server}". Use mcp({ connect: "${server}" }) to connect and refresh.` }],
558
+ content: [{ type: "text", text: `No instructions cached for "${server}". Use mcp({ action: "connect", server: "${server}" }) to connect and refresh.` }],
584
559
  details: { mode: "instructions", server, error: "not_connected" },
585
560
  };
586
561
  }
@@ -590,7 +565,7 @@ export async function executeConnect(state, serverName, signal, limit, offset) {
590
565
  const definition = state.config.mcpServers[serverName];
591
566
  if (!definition) {
592
567
  return {
593
- content: [{ type: "text", text: `Server "${serverName}" not found. Use mcp({}) to see available servers.` }],
568
+ content: [{ type: "text", text: `Server "${serverName}" not found. Use mcp({ action: "status" }) to see available servers.` }],
594
569
  details: { mode: "connect", error: "not_found", server: serverName },
595
570
  };
596
571
  }
@@ -627,6 +602,7 @@ export async function executeConnect(state, serverName, signal, limit, offset) {
627
602
  const prefix = state.config.settings?.toolPrefix ?? "server";
628
603
  const { metadata } = buildToolMetadata(connection.tools, connection.resources, definition, serverName, prefix);
629
604
  state.toolMetadata.set(serverName, metadata);
605
+ state.resourceCounts?.set(serverName, connection.resources.length);
630
606
  if (!connection.promptDiscoveryFailed) {
631
607
  state.promptMetadata?.set(serverName, reconstructPromptMetadata(serverName, connection.prompts ?? [], prefix, definition));
632
608
  state.promptMetadataLive?.add(serverName);
@@ -658,12 +634,11 @@ export async function executeConnect(state, serverName, signal, limit, offset) {
658
634
  /**
659
635
  * Executes an already-approved tool call against a connected server.
660
636
  *
661
- * Both the `mcp` proxy and direct tools reach this with a resolved target and a
662
- * live connection; everything before this point (name resolution, connect and
663
- * auth strategy, approval) differs between them and stays with the caller.
637
+ * Direct, gateway, resource and script calls share executeCall preparation.
638
+ * Raw scripts skip content rendering but retain protocol results and artifacts.
664
639
  */
665
640
  export async function runToolCall(state, serverName, target, args, options) {
666
- const { detailsBase, signal, recoverAuthConnection, authRequiredMessage, autoAuthAttempted, onRawResult } = options;
641
+ const { detailsBase, signal, recoverAuthConnection, authRequiredMessage, autoAuthAttempted } = options;
667
642
  const configuredOptions = state.manager.getRequestOptions?.(serverName, options.ownedSignal);
668
643
  const callerSignal = combineAbortSignals(configuredOptions?.signal, options.ownedSignal);
669
644
  const outputGuardOptions = resolveMcpOutputGuardOptions(state.config.settings, state.outputDirectory);
@@ -705,8 +680,7 @@ export async function runToolCall(state, serverName, target, args, options) {
705
680
  onNeedsAuth: recoverAuthConnection,
706
681
  })
707
682
  : null;
708
- // Frozen direct-tool schemas may be older than the live catalog. Never use
709
- // stale cached hints in preference to the connected server's annotations.
683
+ // UI preparation can replace the connection; use its current annotations.
710
684
  const liveTools = state.manager.getConnection(serverName)?.tools;
711
685
  const annotations = liveTools
712
686
  ? liveTools.find(tool => tool.name === target.originalName)?.annotations
@@ -744,46 +718,32 @@ export async function runToolCall(state, serverName, target, args, options) {
744
718
  await capture({ ...call, phase: "after", result });
745
719
  return result;
746
720
  };
747
- if (target.resourceUri) {
748
- const result = await dispatch(() => withSessionRecovery(recovery, serverName, (conn) => conn.client.readResource({ uri: target.resourceUri }, requestOptions)));
749
- onRawResult?.(result);
750
- const contents = (result.contents ?? []).map(c => ({
751
- type: "text",
752
- text: "text" in c ? c.text : ("blob" in c ? `[Binary data: ${c.mimeType ?? "unknown"}]` : JSON.stringify(c)),
753
- }));
754
- const guarded = await guardMcpOutput(contents.length > 0 ? contents : [{ type: "text", text: "(empty resource)" }], outputGuardOptions);
755
- return { content: guarded.content, details: { ...detailsBase, ...guardedMcpDetails(guarded) } };
756
- }
757
- const result = await dispatch(() => withSessionRecovery({ ...recovery, retryOnTransportFailure: annotations?.readOnlyHint === true || annotations?.idempotentHint === true }, serverName, (conn) => abortable(conn.client.callTool({
758
- name: target.originalName,
759
- arguments: args ?? {},
760
- _meta: uiSession?.requestMeta,
761
- }, requestOptions), ownedSignal)));
762
- uiSession?.sendToolResult(result);
763
- if (result.isError) {
764
- const content = transformMcpContent((result.content ?? []));
765
- const guarded = await guardMcpOutput(content.length > 0 ? content : [{ type: "text", text: "(empty result)" }], { ...outputGuardOptions, prefix: "Error: ", suffix: schemaSuffix(), emptyTextFallback: "Tool execution failed", rawMcpResult: result });
766
- return { content: guarded.content, details: { ...detailsBase, error: "tool_error", ...guardedMcpDetails(guarded) } };
767
- }
768
- onRawResult?.(result);
769
- const content = resolveMcpResultContent(result);
770
- const outputContent = content.length > 0 ? content : [{ type: "text", text: "(empty result)" }];
771
- if (target.uiResourceUri) {
772
- const uiSummary = summarizeUiSessionResult(uiSession);
773
- const guarded = await guardMcpOutput(outputContent, { ...outputGuardOptions, suffix: `\n\n${uiSummary.message}`, rawMcpResult: result });
774
- return {
775
- content: guarded.content,
776
- details: {
777
- ...detailsBase,
778
- ...guardedMcpDetails(guarded),
779
- uiOpen: uiSummary.uiOpen,
780
- uiViewer: uiSummary.uiViewer,
781
- uiUrl: uiSummary.uiUrl,
782
- },
783
- };
784
- }
785
- const guarded = await guardMcpOutput(outputContent, { ...outputGuardOptions, rawMcpResult: result });
786
- return { content: guarded.content, details: { ...detailsBase, ...guardedMcpDetails(guarded) } };
721
+ const result = target.resourceUri
722
+ ? await dispatch(() => withSessionRecovery(recovery, serverName, conn => abortable(conn.client.readResource({ uri: target.resourceUri }, requestOptions), ownedSignal)))
723
+ : await dispatch(() => withSessionRecovery({ ...recovery, retryOnTransportFailure: annotations?.readOnlyHint === true || annotations?.idempotentHint === true }, serverName, conn => abortable(conn.client.callTool({ name: target.originalName, arguments: args ?? {}, _meta: uiSession?.requestMeta }, requestOptions), ownedSignal)));
724
+ if (!target.resourceUri)
725
+ uiSession?.sendToolResult(result);
726
+ const record = result;
727
+ const isError = record.isError === true;
728
+ const uiSummary = target.uiResourceUri ? summarizeUiSessionResult(uiSession) : undefined;
729
+ const details = {
730
+ ...detailsBase,
731
+ ...(isError ? { error: "tool_error" } : {}),
732
+ ...(uiSummary ? { uiOpen: uiSummary.uiOpen, uiViewer: uiSummary.uiViewer, uiUrl: uiSummary.uiUrl } : {}),
733
+ };
734
+ const retained = await retainMcpResult(result, outputGuardOptions, options.raw);
735
+ if (options.raw) {
736
+ const message = isError
737
+ ? (Array.isArray(record.content) ? record.content.filter(block => block.type === "text").map(block => block.text).join("\n") : "") || "Tool execution failed" : undefined;
738
+ return { content: [], details: { ...details, ...retained, ...(message ? { message } : {}) } };
739
+ }
740
+ const content = renderMcpResultContent(record, retained.payloadFiles);
741
+ const guarded = await guardMcpOutput(content.length ? content : [{ type: "text", text: target.resourceUri ? "(empty resource)" : "(empty result)" }], {
742
+ ...outputGuardOptions, retainedMcpResult: retained,
743
+ ...(isError ? { prefix: "Error: ", suffix: schemaSuffix(), emptyTextFallback: "Tool execution failed" }
744
+ : uiSummary ? { suffix: `\n\n${uiSummary.message}` } : {}),
745
+ });
746
+ return { content: guarded.content, details: { ...details, ...guardedMcpDetails(guarded) } };
787
747
  }
788
748
  catch (error) {
789
749
  // Direct/proxy host errors throw; scripts retain their failed-call envelope.
@@ -795,9 +755,11 @@ export async function runToolCall(state, serverName, target, args, options) {
795
755
  ? "MCP call capture failed before dispatch; the tool did not run. Restore capture before continuing."
796
756
  : "MCP outcome capture failed. Use the retained outcome to continue the original work; do not repeat a completed call or rerun the script.";
797
757
  uiSession?.sendToolCancelled(message);
758
+ const retained = "result" in captureFailure && captureFailure.result !== undefined
759
+ ? await retainMcpResult(captureFailure.result, { ...outputGuardOptions, enabled: true, detailsMaxBytes: 1 }) : {};
798
760
  return {
799
- content: [{ type: "text", text: message }],
800
- details: { ...detailsBase, error: isAbortError(error, callerSignal) ? "aborted" : "call_capture_failed", message, recovery: context },
761
+ content: [{ type: "text", text: message }, ...(retained.resultRef ? [{ type: "text", text: formatMcpResultReference(retained.resultRef) }] : [])],
762
+ details: { ...detailsBase, ...retained, error: isAbortError(error, callerSignal) ? "aborted" : "call_capture_failed", message, recovery: context },
801
763
  };
802
764
  }
803
765
  if (isToolTransportFailure(error)) {
@@ -848,388 +810,211 @@ export async function runToolCall(state, serverName, target, args, options) {
848
810
  state.manager.touch(serverName);
849
811
  }
850
812
  }
851
- export async function executeCall(state, toolName, args, serverOverride, getPiTools, signal, onRawResult, identity = {}, beforeDispatch) {
813
+ /** Connect/auth once for calls and resource discovery. Recovery stays bounded by withSessionRecovery. */
814
+ export async function prepareMcpConnection(state, serverName, signal) {
852
815
  const ownedSignal = combineAbortSignals(state.owner?.signal, signal);
853
816
  throwIfAborted(ownedSignal);
854
- let serverName = serverOverride;
855
- let toolMeta;
856
817
  let autoAuthAttempted = false;
857
- const prefixMode = state.config.settings?.toolPrefix ?? "server";
858
- const disabledCallResult = (disabledServer, metadata) => {
859
- if (!metadata) {
860
- const message = `Server "${disabledServer}" is disabled. Run /mcp enable ${disabledServer} and /reload to enable it.`;
861
- return {
862
- content: [{ type: "text", text: message }],
863
- details: { mode: "call", error: "server_disabled", server: disabledServer, requestedTool: toolName, message },
864
- };
818
+ const recoverAuthConnection = async (_name, recoverySignal = ownedSignal, challenge) => {
819
+ throwIfAborted(recoverySignal);
820
+ const current = state.manager.getConnection(serverName);
821
+ if (current?.status === "connected" && current !== challenge?.connection)
822
+ return current;
823
+ if (!autoAuthAttempted) {
824
+ autoAuthAttempted = true;
825
+ const auth = await attemptAutoAuth(state, serverName, recoverySignal);
826
+ throwIfAborted(recoverySignal);
827
+ if (auth.status === "failed")
828
+ throw new SessionRecoveryAuthRequiredError(serverName, auth.message);
829
+ if (auth.status === "success" && current) {
830
+ clearFailure(state, serverName);
831
+ const definition = state.config.mcpServers[serverName];
832
+ if (!definition)
833
+ return undefined;
834
+ state.ui?.setStatus("mcp", formatMcpStatus(state.config, `connecting to ${serverName}...`));
835
+ await state.manager.reconnect(serverName, definition, current, recoverySignal);
836
+ if (await lazyConnect(state, serverName, recoverySignal)) {
837
+ updateStatusBar(state);
838
+ return state.manager.getConnection(serverName);
839
+ }
840
+ }
865
841
  }
866
- const message = `Server "${disabledServer}" is disabled. Run /mcp enable ${disabledServer} and /reload to enable it.`;
867
- const identity = metadata.resourceUri
868
- ? { server: disabledServer, resourceUri: metadata.resourceUri }
869
- : { server: disabledServer, tool: metadata.originalName };
870
- return {
871
- content: [{ type: "text", text: message }],
872
- details: { mode: "call", error: "server_disabled", ...identity, message },
873
- };
842
+ return challenge ? undefined : state.manager.getConnection(serverName);
874
843
  };
875
- if (serverName && !state.config.mcpServers[serverName]) {
876
- return {
877
- content: [{ type: "text", text: `Server "${serverName}" not found. Use mcp({}) to see available servers.` }],
878
- details: { mode: "call", error: "server_not_found", server: serverName, requestedTool: toolName },
879
- };
880
- }
881
- if (serverName) {
882
- toolMeta = findToolByName(state.toolMetadata.get(serverName), toolName);
883
- if (isServerDisabled(state.config.mcpServers[serverName])) {
884
- return disabledCallResult(serverName, toolMeta);
844
+ const failure = (error, message) => ({
845
+ content: [{ type: "text", text: message }],
846
+ details: { mode: "call", error, server: serverName, message },
847
+ });
848
+ const definition = state.config.mcpServers[serverName];
849
+ if (!definition)
850
+ return { error: failure("server_not_found", `Server "${serverName}" not found.`) };
851
+ if (isServerDisabled(definition))
852
+ return { error: disabledResult("call", serverName) };
853
+ try {
854
+ // Connected metadata is read below, so it does not require another connection attempt.
855
+ let connection = state.manager.getConnection(serverName);
856
+ if (connection?.status !== "connected") {
857
+ await lazyConnect(state, serverName, ownedSignal);
858
+ connection = state.manager.getConnection(serverName);
885
859
  }
886
- }
887
- else {
888
- let disabledMatch;
889
- for (const [server, metadata] of state.toolMetadata.entries()) {
890
- const found = findToolByName(metadata, toolName);
891
- if (!found)
892
- continue;
893
- if (isServerDisabled(state.config.mcpServers[server])) {
894
- disabledMatch ??= { serverName: server, toolMeta: found };
895
- continue;
896
- }
897
- serverName = server;
898
- toolMeta = found;
899
- break;
860
+ else {
861
+ markKeepAliveAfterConnect(state, serverName);
900
862
  }
901
- if (!toolMeta && disabledMatch)
902
- return disabledCallResult(disabledMatch.serverName, disabledMatch.toolMeta);
903
- }
904
- if (serverName && !toolMeta && !state.toolMetadata.has(serverName)) {
905
- const connected = await lazyConnect(state, serverName, ownedSignal);
906
- if (connected) {
907
- toolMeta = findToolByName(state.toolMetadata.get(serverName), toolName);
863
+ if (connection?.status === "needs-auth")
864
+ connection = await recoverAuthConnection(serverName, ownedSignal);
865
+ if (connection?.status === "needs-auth")
866
+ throw new SessionRecoveryAuthRequiredError(serverName);
867
+ if (connection?.status !== "connected") {
868
+ const failedAgo = getFailureAgeSeconds(state, serverName);
869
+ return { error: failure(failedAgo === null ? "server_unavailable" : "server_backoff", `Server "${serverName}" not available${failedAgo === null ? "" : ` (last failed ${failedAgo}s ago)`}`) };
908
870
  }
909
- else {
910
- const needsAuthConnection = state.manager.getConnection(serverName);
911
- if (needsAuthConnection?.status === "needs-auth") {
912
- if (!autoAuthAttempted) {
913
- autoAuthAttempted = true;
914
- const autoAuth = await attemptAutoAuth(state, serverName, ownedSignal);
915
- if (autoAuth.status === "failed") {
916
- return {
917
- content: [{ type: "text", text: autoAuth.message }],
918
- details: { mode: "call", error: "auth_required", server: serverName, requestedTool: toolName, message: autoAuth.message },
919
- };
920
- }
921
- if (autoAuth.status === "success") {
922
- clearFailure(state, serverName);
923
- let connectedAfterAuth = false;
924
- try {
925
- const definition = state.config.mcpServers[serverName];
926
- if (definition) {
927
- await state.manager.reconnect(serverName, definition, needsAuthConnection, ownedSignal);
928
- connectedAfterAuth = true;
929
- }
930
- }
931
- catch (error) {
932
- if (isAbortError(error, ownedSignal))
933
- throwIfAborted(ownedSignal);
934
- recordFailure(state, serverName, error instanceof Error ? error.message : String(error));
935
- updateStatusBar(state);
936
- }
937
- if (connectedAfterAuth)
938
- connectedAfterAuth = await lazyConnect(state, serverName, ownedSignal);
939
- if (connectedAfterAuth) {
940
- toolMeta = findToolByName(state.toolMetadata.get(serverName), toolName);
941
- if (!toolMeta) {
942
- const suggestions = rankSuggestions(state, toolName, 5, serverName);
943
- const suggestionText = suggestions.length > 0 ? ` Did you mean: ${suggestions.join(", ")}` : "";
944
- return {
945
- content: [{ type: "text", text: `Tool "${toolName}" not found on "${serverName}" after reconnect.${suggestionText}` }],
946
- details: { mode: "call", error: "tool_not_found_after_reconnect", server: serverName, requestedTool: toolName, suggestions },
947
- };
948
- }
949
- }
950
- }
951
- }
952
- if (!toolMeta && state.manager.getConnection(serverName)?.status === "needs-auth") {
953
- const message = getAuthRequiredMessage(state, serverName);
954
- return {
955
- content: [{ type: "text", text: message }],
956
- details: { mode: "call", error: "auth_required", server: serverName, requestedTool: toolName, message },
957
- };
958
- }
959
- }
960
- if (!toolMeta) {
961
- const failedAgo = getFailureAgeSeconds(state, serverName);
962
- if (failedAgo !== null) {
963
- return {
964
- content: [{ type: "text", text: `Server "${serverName}" not available (last failed ${failedAgo}s ago)` }],
965
- details: { mode: "call", error: "server_backoff", server: serverName, requestedTool: toolName },
966
- };
967
- }
968
- }
871
+ // Use the live descriptor for visibility, filtering, approval, schema and UI hints.
872
+ if (connection.tools) {
873
+ const { metadata } = buildToolMetadata(connection.tools, connection.resources ?? [], definition, serverName, state.config.settings?.toolPrefix ?? "server");
874
+ state.toolMetadata.set(serverName, metadata);
969
875
  }
876
+ return { connection, recoverAuthConnection, autoAuthAttempted: () => autoAuthAttempted };
970
877
  }
971
- let prefixMatchedServer;
972
- if (!serverName && !toolMeta) {
973
- const candidates = Object.keys(state.config.mcpServers)
974
- .filter(name => !isServerDisabled(state.config.mcpServers[name]))
975
- .map(name => ({
976
- name,
977
- prefix: getServerPrefix(name, resolveToolPrefix(state.config.mcpServers[name], prefixMode)),
978
- }))
979
- .filter(c => c.prefix && toolName.startsWith(c.prefix + "_"))
980
- .sort((a, b) => b.prefix.length - a.prefix.length)
981
- .slice(0, 1);
982
- for (const { name: configuredServer } of candidates) {
983
- const existingConnection = state.manager.getConnection(configuredServer);
984
- const failedAgo = getFailureAgeSeconds(state, configuredServer);
985
- if (failedAgo !== null && existingConnection?.status !== "needs-auth")
986
- continue;
987
- if (state.toolMetadata.has(configuredServer)) {
988
- prefixMatchedServer ??= configuredServer;
989
- continue;
990
- }
991
- let connected = await lazyConnect(state, configuredServer, ownedSignal);
992
- const needsAuthConnection = state.manager.getConnection(configuredServer);
993
- if (!connected && needsAuthConnection?.status === "needs-auth" && !autoAuthAttempted) {
994
- autoAuthAttempted = true;
995
- const autoAuth = await attemptAutoAuth(state, configuredServer, ownedSignal);
996
- if (autoAuth.status === "failed") {
997
- return {
998
- content: [{ type: "text", text: autoAuth.message }],
999
- details: { mode: "call", error: "auth_required", server: configuredServer, requestedTool: toolName, message: autoAuth.message },
1000
- };
1001
- }
1002
- if (autoAuth.status === "success") {
1003
- clearFailure(state, configuredServer);
1004
- try {
1005
- const definition = state.config.mcpServers[configuredServer];
1006
- if (definition) {
1007
- await state.manager.reconnect(configuredServer, definition, needsAuthConnection, ownedSignal);
1008
- connected = true;
1009
- }
1010
- }
1011
- catch (error) {
1012
- if (isAbortError(error, ownedSignal))
1013
- throwIfAborted(ownedSignal);
1014
- recordFailure(state, configuredServer, error instanceof Error ? error.message : String(error));
1015
- updateStatusBar(state);
1016
- }
1017
- if (connected)
1018
- connected = await lazyConnect(state, configuredServer, ownedSignal);
1019
- }
1020
- }
1021
- if (!connected)
1022
- continue;
1023
- if (!prefixMatchedServer)
1024
- prefixMatchedServer = configuredServer;
1025
- toolMeta = findToolByName(state.toolMetadata.get(configuredServer), toolName);
1026
- if (toolMeta) {
1027
- serverName = configuredServer;
1028
- break;
1029
- }
878
+ catch (error) {
879
+ if (isAbortError(error, ownedSignal))
880
+ throw error;
881
+ const message = error instanceof SessionRecoveryAuthRequiredError
882
+ ? error.authMessage ?? getAuthRequiredMessage(state, serverName)
883
+ : error instanceof Error ? error.message : String(error);
884
+ if (!(error instanceof SessionRecoveryAuthRequiredError)) {
885
+ recordFailure(state, serverName, message);
886
+ updateStatusBar(state);
1030
887
  }
888
+ return { error: failure(error instanceof SessionRecoveryAuthRequiredError ? "auth_required" : "connect_failed", message) };
1031
889
  }
1032
- if (!serverName || !toolMeta) {
1033
- const nativeTool = !serverOverride
1034
- ? getPiTools?.().find((tool) => tool.name === toolName && tool.name !== "mcp")
1035
- : undefined;
1036
- if (nativeTool) {
890
+ }
891
+ export async function executeCall(state, toolName, args, serverOverride, getPiTools, signal, identity = {}, beforeDispatch, options = {}) {
892
+ const ownedSignal = combineAbortSignals(state.owner?.signal, signal);
893
+ throwIfAborted(ownedSignal);
894
+ if (typeof toolName !== "string" || !toolName.trim() || (serverOverride !== undefined && (typeof serverOverride !== "string" || !serverOverride)))
895
+ return {
896
+ content: [{ type: "text", text: "Specify a non-empty tool name and, when supplied, server." }],
897
+ details: { mode: "call", error: "invalid_arguments" },
898
+ };
899
+ if (args !== undefined && (args === null || typeof args !== "object" || Array.isArray(args)))
900
+ return {
901
+ content: [{ type: "text", text: "Tool arguments must be an object." }],
902
+ details: { mode: "call", error: "invalid_arguments", requestedTool: toolName },
903
+ };
904
+ let serverName = serverOverride;
905
+ const matches = (metadata = []) => {
906
+ if (options.resourceUri !== undefined)
907
+ return metadata.filter(tool => tool.resourceUri === options.resourceUri);
908
+ if (options.exactOriginalName)
909
+ return metadata.filter(tool => tool.originalName === toolName && !tool.resourceUri);
910
+ if (serverOverride) {
911
+ const originals = metadata.filter(tool => tool.originalName === toolName);
912
+ if (originals.length)
913
+ return originals;
914
+ }
915
+ const exact = metadata.filter(tool => tool.name === toolName);
916
+ if (exact.length)
917
+ return exact;
918
+ return metadata.filter(tool => tool.name.replace(/-/g, "_") === toolName.replace(/-/g, "_"));
919
+ };
920
+ const ambiguous = () => ({
921
+ content: [{ type: "text", text: `Tool "${toolName}" is ambiguous. Specify its exact server and tool name; no call was dispatched.` }],
922
+ details: { mode: "call", error: "ambiguous_tool", requestedTool: toolName, ...(serverName ? { server: serverName } : {}) },
923
+ });
924
+ if (!serverName) {
925
+ const candidates = [...state.toolMetadata].flatMap(([server, metadata]) => isServerDisabled(state.config.mcpServers[server]) ? [] : matches(metadata).map(tool => ({ server, tool })));
926
+ if (candidates.length > 1)
927
+ return ambiguous();
928
+ serverName = candidates[0]?.server;
929
+ if (!serverName) {
930
+ const disabled = [...state.toolMetadata].find(([server, metadata]) => isServerDisabled(state.config.mcpServers[server]) && matches(metadata).length > 0);
931
+ if (disabled)
932
+ return disabledResult("call", disabled[0]);
933
+ const prefixes = Object.entries(state.config.mcpServers)
934
+ .filter(([, definition]) => !isServerDisabled(definition))
935
+ .map(([server, definition]) => ({ server, prefix: getServerPrefix(server, resolveToolPrefix(definition, state.config.settings?.toolPrefix ?? "server")) }))
936
+ .filter(({ prefix }) => prefix && toolName.startsWith(`${prefix}_`))
937
+ .sort((a, b) => b.prefix.length - a.prefix.length);
938
+ if (prefixes.length > 1 && prefixes[0]?.prefix === prefixes[1]?.prefix)
939
+ return ambiguous();
940
+ serverName = prefixes[0]?.server;
941
+ }
942
+ }
943
+ const missing = () => {
944
+ const nativeTool = !serverOverride && getPiTools?.().find(tool => tool.name === toolName && tool.name !== "mcp");
945
+ if (nativeTool)
1037
946
  return {
1038
- content: [{ type: "text", text: `"${toolName}" is a native Pi tool. Call ${toolName} directly instead of using mcp({ tool: "${toolName}" }).` }],
947
+ content: [{ type: "text", text: `"${toolName}" is a native Pi tool. Call ${toolName} directly instead of using mcp({ action: "call", tool: "${toolName}" }).` }],
1039
948
  details: { mode: "call", error: "native_tool", requestedTool: toolName },
1040
949
  };
1041
- }
1042
- const hintServer = serverName ?? prefixMatchedServer;
1043
- const normalizedToolName = toolName.replace(/-/g, "_");
1044
- const localOriginalName = serverOverride && state.toolMetadata.get(serverOverride)
1045
- ?.some(tool => tool.originalName.replace(/-/g, "_") === normalizedToolName);
1046
- const suggestedOwner = serverOverride && !localOriginalName
1047
- ? [...state.toolMetadata].find(([candidateServer, metadata]) => candidateServer !== serverOverride
1048
- && !isServerDisabled(state.config.mcpServers[candidateServer])
1049
- && findToolByName(metadata, toolName))
1050
- : undefined;
1051
- const suggestedTool = suggestedOwner ? findToolByName(suggestedOwner[1], toolName) : undefined;
1052
- if (suggestedOwner && suggestedTool) {
1053
- const suggestedServer = suggestedOwner[0];
950
+ const normalizedName = toolName.replace(/-/g, "_");
951
+ const localOriginal = serverOverride && state.toolMetadata.get(serverOverride)?.some(tool => tool.originalName.replace(/-/g, "_") === normalizedName);
952
+ const owners = serverOverride && !localOriginal
953
+ ? [...state.toolMetadata].filter(([server, metadata]) => server !== serverOverride && !isServerDisabled(state.config.mcpServers[server]) && matches(metadata).length === 1)
954
+ : [];
955
+ const suggestedOwner = owners.length === 1 ? owners[0] : undefined;
956
+ if (suggestedOwner)
1054
957
  return {
1055
- content: [{ type: "text", 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.` }],
1056
- details: { mode: "call", error: "tool_not_found", requestedTool: toolName, hintServer, suggestedServer, suggestions: [suggestedTool.name] },
958
+ 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.` }],
959
+ details: { mode: "call", error: "tool_not_found", requestedTool: toolName, hintServer: serverName, suggestedServer: suggestedOwner[0], suggestions: matches(suggestedOwner[1]).map(tool => tool.name) },
1057
960
  };
1058
- }
1059
- const suggestions = rankSuggestions(state, toolName, 5, hintServer);
1060
- let hint;
1061
- if (suggestions.length > 0) {
1062
- hint = ` Did you mean: ${suggestions.join(", ")}. Inspect with mcp({ describe: "${suggestions[0]}" }).`;
1063
- }
1064
- else if (hintServer) {
1065
- hint = ` Search with mcp({ search: ${JSON.stringify(toolName)}, server: ${JSON.stringify(hintServer)} }).`;
1066
- }
1067
- else {
1068
- hint = ` Use mcp({ search: "..." }) to search.`;
1069
- }
1070
- const suggestionOnHintServer = hintServer && suggestions.some(suggestion => state.toolMetadata.get(hintServer)?.some(tool => tool.name === suggestion));
1071
- if (hintServer && (suggestions.length === 0 || suggestionOnHintServer) && state.toolMetadata.has(hintServer) && state.manager.getConnection(hintServer)?.status !== "connected") {
1072
- hint += ` Refresh a stale catalog with mcp({ connect: ${JSON.stringify(hintServer)} }).`;
1073
- }
961
+ const suggestions = rankSuggestions(state, toolName, 5, serverName);
962
+ let hint = suggestions.length
963
+ ? ` Did you mean: ${suggestions.join(", ")}. Inspect with mcp({ action: "describe", tool: ${JSON.stringify(suggestions[0])} }).`
964
+ : serverName ? ` Search with mcp({ action: "search", query: ${JSON.stringify(toolName)}, server: ${JSON.stringify(serverName)} }).` : ' Use mcp({ action: "search", query: "..." }) to search.';
965
+ if (serverName && state.toolMetadata.has(serverName) && state.manager.getConnection(serverName)?.status !== "connected")
966
+ hint += ` Refresh a stale catalog with mcp({ action: "connect", server: ${JSON.stringify(serverName)} }).`;
1074
967
  return {
1075
968
  content: [{ type: "text", text: `Tool "${toolName}" not found.${hint}` }],
1076
- details: { mode: "call", error: "tool_not_found", requestedTool: toolName, hintServer, suggestions },
969
+ details: { mode: "call", error: "tool_not_found", requestedTool: toolName, hintServer: serverName, suggestions },
1077
970
  };
1078
- }
1079
- const callIdentity = toolMeta.resourceUri
1080
- ? { server: serverName, resourceUri: toolMeta.resourceUri }
1081
- : { server: serverName, tool: toolMeta.originalName };
1082
- let connection = state.manager.getConnection(serverName);
1083
- let refreshAfterAuth = false;
1084
- if (connection?.status === "needs-auth") {
1085
- if (!autoAuthAttempted) {
1086
- autoAuthAttempted = true;
1087
- const autoAuth = await attemptAutoAuth(state, serverName, ownedSignal);
1088
- if (autoAuth.status === "failed") {
1089
- return {
1090
- content: [{ type: "text", text: autoAuth.message }],
1091
- details: { mode: "call", error: "auth_required", ...callIdentity, message: autoAuth.message },
1092
- };
1093
- }
1094
- if (autoAuth.status === "success") {
1095
- clearFailure(state, serverName);
1096
- refreshAfterAuth = true;
1097
- }
1098
- }
1099
- if (connection?.status === "needs-auth" && !refreshAfterAuth) {
1100
- const message = getAuthRequiredMessage(state, serverName);
1101
- return {
1102
- content: [{ type: "text", text: message }],
1103
- details: { mode: "call", error: "auth_required", ...callIdentity, message },
1104
- };
1105
- }
1106
- }
1107
- if (!connection || connection.status !== "connected") {
1108
- const failedAgo = getFailureAgeSeconds(state, serverName);
1109
- if (failedAgo !== null) {
1110
- return {
1111
- content: [{ type: "text", text: `Server "${serverName}" not available (last failed ${failedAgo}s ago)` }],
1112
- details: { mode: "call", error: "server_backoff", ...callIdentity },
1113
- };
1114
- }
1115
- const definition = state.config.mcpServers[serverName];
1116
- if (!definition) {
1117
- return {
1118
- content: [{ type: "text", text: `Server "${serverName}" not connected` }],
1119
- details: { mode: "call", error: "server_not_connected", ...callIdentity },
1120
- };
1121
- }
1122
- try {
1123
- if (state.ui) {
1124
- state.ui.setStatus("mcp", formatMcpStatus(state.config, `connecting to ${serverName}...`));
1125
- }
1126
- connection = refreshAfterAuth && connection
1127
- ? await state.manager.reconnect(serverName, definition, connection, ownedSignal)
1128
- : await state.manager.connect(serverName, definition, ownedSignal);
1129
- if (connection.status === "needs-auth") {
1130
- if (!autoAuthAttempted) {
1131
- autoAuthAttempted = true;
1132
- const autoAuth = await attemptAutoAuth(state, serverName, ownedSignal);
1133
- if (autoAuth.status === "failed") {
1134
- return {
1135
- content: [{ type: "text", text: autoAuth.message }],
1136
- details: { mode: "call", error: "auth_required", ...callIdentity, message: autoAuth.message },
1137
- };
1138
- }
1139
- if (autoAuth.status === "success") {
1140
- connection = await state.manager.reconnect(serverName, definition, connection, ownedSignal);
1141
- }
1142
- }
1143
- if (connection.status === "needs-auth") {
1144
- const message = getAuthRequiredMessage(state, serverName);
1145
- return {
1146
- content: [{ type: "text", text: message }],
1147
- details: { mode: "call", error: "auth_required", ...callIdentity, message },
1148
- };
1149
- }
1150
- }
1151
- clearFailure(state, serverName);
1152
- updateServerMetadata(state, serverName);
1153
- updateMetadataCache(state, serverName);
1154
- notifyToolMetadataUpdated(state, serverName, "proxy-call-reconnect");
1155
- markKeepAliveAfterConnect(state, serverName);
1156
- updateStatusBar(state);
1157
- toolMeta = findToolByName(state.toolMetadata.get(serverName), toolName);
1158
- if (!toolMeta) {
1159
- const suggestions = rankSuggestions(state, toolName, 5, serverName);
1160
- const hint = suggestions.length > 0
1161
- ? ` Did you mean: ${suggestions.join(", ")}. Inspect with mcp({ describe: "${suggestions[0]}" }).`
1162
- : ` Search with mcp({ search: ${JSON.stringify(toolName)}, server: ${JSON.stringify(serverName)} }).`;
1163
- return {
1164
- content: [{ type: "text", text: `Tool "${toolName}" not found on "${serverName}" after reconnect.${hint}` }],
1165
- details: { mode: "call", error: "tool_not_found_after_reconnect", server: serverName, requestedTool: toolName, suggestions },
1166
- };
1167
- }
1168
- }
1169
- catch (error) {
1170
- const message = error instanceof Error ? error.message : String(error);
1171
- if (!isAbortError(error, ownedSignal))
1172
- recordFailure(state, serverName, message);
1173
- updateStatusBar(state);
1174
- return {
1175
- content: [{ type: "text", text: `Failed to connect to "${serverName}": ${message}` }],
1176
- details: { mode: "call", error: isAbortError(error, ownedSignal) ? "aborted" : "connect_failed", ...callIdentity, message },
1177
- };
1178
- }
1179
- }
1180
- if (isServerDisabled(state.config.mcpServers[serverName])) {
1181
- return disabledCallResult(serverName, toolMeta);
1182
- }
971
+ };
972
+ if (!serverName)
973
+ return missing();
974
+ const definition = state.config.mcpServers[serverName];
975
+ if (!definition)
976
+ return { content: [{ type: "text", text: `Server "${serverName}" not found. Use mcp({ action: "status" }) to see available servers.` }], details: { mode: "call", error: "server_not_found", server: serverName, requestedTool: toolName } };
977
+ if (isServerDisabled(definition))
978
+ return disabledResult("call", serverName);
979
+ const cached = state.toolMetadata.get(serverName);
980
+ if (matches(cached).length > 1)
981
+ return ambiguous();
982
+ // Misspellings in a known catalog do not cause connection churn or guessed execution.
983
+ if (cached && !matches(cached).length && !options.exactOriginalName && options.resourceUri === undefined)
984
+ return missing();
985
+ const prepared = await prepareMcpConnection(state, serverName, ownedSignal);
986
+ if (prepared.error) {
987
+ const cachedTarget = matches(cached)[0];
988
+ return { ...prepared.error, details: { ...prepared.error.details, ...(cachedTarget
989
+ ? cachedTarget.resourceUri ? { resourceUri: cachedTarget.resourceUri } : { tool: cachedTarget.originalName }
990
+ : { requestedTool: toolName }) } };
991
+ }
992
+ const targets = matches(state.toolMetadata.get(serverName));
993
+ if (targets.length > 1)
994
+ return ambiguous();
995
+ const toolMeta = targets[0];
996
+ if (!toolMeta)
997
+ return missing();
998
+ if (isServerDisabled(state.config.mcpServers[serverName]))
999
+ return disabledResult("call", serverName);
1183
1000
  const approval = await ensureToolCallApproved(state, serverName, toolMeta, args, ownedSignal);
1184
- if (approval.ok === false) {
1001
+ if (!approval.ok) {
1185
1002
  const denied = approval.reason === "denied";
1186
- const message = denied
1187
- ? `The user declined approval to run MCP tool "${toolMeta.originalName}" on server "${serverName}".`
1188
- : `MCP tool "${toolMeta.originalName}" on server "${serverName}" is approval-gated and requires an interactive session.`;
1189
1003
  return {
1190
- content: [{ type: "text", text: message }],
1191
- details: {
1192
- mode: "call",
1193
- error: denied ? "approval_denied" : "approval_required",
1194
- server: serverName,
1195
- tool: toolMeta.originalName,
1196
- },
1004
+ content: [{ type: "text", text: denied
1005
+ ? `The user declined approval to run MCP tool "${toolMeta.originalName}" on server "${serverName}".`
1006
+ : `MCP tool "${toolMeta.originalName}" on server "${serverName}" is approval-gated and requires an interactive session.` }],
1007
+ details: { mode: "call", error: denied ? "approval_denied" : "approval_required", server: serverName, tool: toolMeta.originalName },
1197
1008
  };
1198
1009
  }
1199
- const recoverAuthConnection = async (_serverName, recoverySignal = ownedSignal, challenge) => {
1200
- throwIfAborted(recoverySignal);
1201
- const current = state.manager.getConnection(serverName);
1202
- if (current?.status === "connected" && current !== challenge?.connection)
1203
- return current;
1204
- if (!autoAuthAttempted) {
1205
- autoAuthAttempted = true;
1206
- const autoAuth = await attemptAutoAuth(state, serverName, recoverySignal);
1207
- throwIfAborted(recoverySignal);
1208
- if (autoAuth.status === "failed") {
1209
- throw new SessionRecoveryAuthRequiredError(serverName, autoAuth.message);
1210
- }
1211
- if (autoAuth.status === "success") {
1212
- const definition = state.config.mcpServers[serverName];
1213
- if (!definition)
1214
- return undefined;
1215
- if (!current)
1216
- return undefined;
1217
- clearFailure(state, serverName);
1218
- connection = await state.manager.reconnect(serverName, definition, current, recoverySignal);
1219
- return connection;
1220
- }
1221
- }
1222
- return challenge ? undefined : state.manager.getConnection(serverName);
1223
- };
1224
1010
  return runToolCall(state, serverName, toolMeta, args, {
1225
1011
  ...identity,
1226
1012
  ...(beforeDispatch ? { beforeDispatch } : {}),
1227
- detailsBase: { mode: "call", ...callIdentity },
1228
- ownedSignal,
1229
- signal,
1230
- recoverAuthConnection,
1013
+ ...(options.raw !== undefined ? { raw: options.raw } : {}),
1014
+ detailsBase: { mode: "call", server: serverName, ...(toolMeta.resourceUri ? { resourceUri: toolMeta.resourceUri } : { tool: toolMeta.originalName }) },
1015
+ ownedSignal, signal,
1016
+ recoverAuthConnection: prepared.recoverAuthConnection,
1231
1017
  authRequiredMessage: () => getAuthRequiredMessage(state, serverName),
1232
- autoAuthAttempted: () => autoAuthAttempted,
1233
- ...(onRawResult ? { onRawResult } : {}),
1018
+ autoAuthAttempted: prepared.autoAuthAttempted,
1234
1019
  });
1235
1020
  }