pi-mcp-adapter 3.3.0 → 4.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.
package/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { withFileMutationQueue, type AgentToolUpdateCallback, type ExtensionAPI, type ExtensionContext, type ToolInfo } from "@earendil-works/pi-coding-agent";
1
+ import { withFileMutationQueue, type AgentToolResult, type AgentToolUpdateCallback, type ExtensionAPI, type ExtensionContext, type ToolInfo } from "@earendil-works/pi-coding-agent";
2
2
  import { resolve } from "node:path";
3
3
  import { existsSync } from "node:fs";
4
4
  import { fileURLToPath } from "node:url";
@@ -61,7 +61,7 @@ const INIT_WAIT_TIMED_OUT: unique symbol = Symbol("init-wait-timed-out");
61
61
  function hasBuiltInMcpCommand(pi: ExtensionAPI): boolean {
62
62
  if (typeof pi.getCommands !== "function") return false;
63
63
  return pi.getCommands().some((command) => /^mcp(?::\d+)?$/.test(command.name)
64
- && command.sourceInfo.path === "<inline:mcp>");
64
+ && command.sourceInfo.path === "builtin:mcp");
65
65
  }
66
66
 
67
67
  function hasEnabledServerWithoutValidMetadata(
@@ -114,6 +114,23 @@ export interface McpRuntimeSnapshotRequest {
114
114
  result?: McpRuntimeSnapshotResult;
115
115
  }
116
116
 
117
+ export const MCP_RUNTIME_TOOL_CALL_EVENT = "pi-mcp-adapter:runtime-tool-call:v1" as const;
118
+ export const MCP_RUNTIME_TOOL_CALL_VERSION = 1 as const;
119
+
120
+ export type McpRuntimeToolCallResult =
121
+ | { ok: true; result: AgentToolResult<Record<string, unknown>> }
122
+ | { ok: false; error: Error };
123
+
124
+ /** The adapter sets `result` to a promise during `emit()`; await it. */
125
+ export interface McpRuntimeToolCallRequest {
126
+ version: typeof MCP_RUNTIME_TOOL_CALL_VERSION;
127
+ /** Tool name as accepted by `mcp({ tool })`. */
128
+ tool: string;
129
+ args?: Record<string, unknown>;
130
+ server?: string;
131
+ result?: Promise<McpRuntimeToolCallResult>;
132
+ }
133
+
117
134
  // Fast path for callers that share the adapter's module and ExtensionAPI.
118
135
  const runtimeRegistrars = new WeakMap<ExtensionAPI, (name: string, definition: ServerEntry) => McpServerRegistration>();
119
136
  const runtimeSnapshotters = new WeakMap<ExtensionAPI, (name: string) => McpRuntimeServerSnapshot>();
@@ -179,6 +196,7 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
179
196
  const sessionConfig = options.config !== undefined ? cloneMcpConfig(options.config) : undefined;
180
197
  const programmaticConfig = sessionConfig !== undefined;
181
198
  let state: McpExtensionState | null = null;
199
+ let sessionCtx: ExtensionContext | null = null;
182
200
  let initPromise: Promise<McpExtensionState> | null = null;
183
201
  let initStartedPromise: Promise<void> | null = null;
184
202
  let currentOwner: McpRuntimeOwner | null = null;
@@ -336,6 +354,8 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
336
354
  const earlyConfig = programmaticConfig
337
355
  ? resolveConfiguredClaudePluginMcp(cloneMcpConfig(sessionConfig), process.cwd())
338
356
  : excludeProjectServersAtLoadTime(loadMcpConfig(earlyConfigPath));
357
+ // Pi registers tools and discovers skills once per load, so the script tool and every pointer to it follow this value.
358
+ const scriptTool = earlyConfig.settings?.scriptMode === true;
339
359
  const earlyCache = loadMetadataCache();
340
360
  const envRaw = process.env.MCP_DIRECT_TOOLS;
341
361
  const envDirectToolOverride = parseEnvDirectToolOverride(envRaw);
@@ -597,7 +617,7 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
597
617
  const definition = config.mcpServers[serverName];
598
618
  if (!definition || isServerDisabled(definition)) continue;
599
619
  if (entry.configHash !== computeServerHash(definition)) continue;
600
- const { ttlMs: _diskTtl, ...sessionEntry } = entry;
620
+ const { ttlMs: _sessionTtl, cacheScope: _sessionScope, ...sessionEntry } = entry;
601
621
  servers[serverName] = { ...sessionEntry, cachedAt: Date.now() };
602
622
  }
603
623
  return { version: 1, servers };
@@ -840,6 +860,38 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
840
860
  request.result = { ok: false, error: error instanceof Error ? error : new Error(String(error)) };
841
861
  }
842
862
  });
863
+ pi.events.on(MCP_RUNTIME_TOOL_CALL_EVENT, (rawRequest: unknown) => {
864
+ if (typeof rawRequest !== "object" || rawRequest === null || Array.isArray(rawRequest)) return;
865
+ const request = rawRequest as McpRuntimeToolCallRequest;
866
+ if (request.result !== undefined) return;
867
+ const ctx = sessionCtx;
868
+ request.result = (async (): Promise<McpRuntimeToolCallResult> => {
869
+ try {
870
+ if (request.version !== MCP_RUNTIME_TOOL_CALL_VERSION) {
871
+ throw new Error(`Unsupported MCP runtime tool-call version: ${String(request.version)}`);
872
+ }
873
+ if (typeof request.tool !== "string" || request.tool.trim() === "") {
874
+ throw new Error("MCP runtime tool-call requires a non-empty `tool` name");
875
+ }
876
+ if (!ctx) throw new Error("MCP runtime tool calls require an active Pi session");
877
+ const callState = await awaitWithTimeout(ensureSessionRuntime(ctx), INIT_WAIT_TIMEOUT_MS);
878
+ if (callState === INIT_WAIT_TIMED_OUT) {
879
+ throw new Error(`MCP initialization is still in progress after ${INIT_WAIT_TIMEOUT_MS}ms`);
880
+ }
881
+ if (!callState) throw new Error("MCP is not initialized");
882
+ const guard = captureRuntimeGuard(callState);
883
+ const { executeCall } = await loadForRuntime(loadProxyModes, guard);
884
+ const result = await executeCall(callState, request.tool, request.args, request.server, getPiTools, undefined, "script");
885
+ assertRuntimeGuard(guard);
886
+ // Denials and tool errors resolve with details.error rather than rejecting.
887
+ const failure = result.details?.error;
888
+ if (failure !== undefined) return { ok: false, error: new Error(`MCP tool call failed: ${String(failure)}`) };
889
+ return { ok: true, result };
890
+ } catch (error) {
891
+ return { ok: false, error: error instanceof Error ? error : new Error(String(error)) };
892
+ }
893
+ })();
894
+ });
843
895
 
844
896
  const getPiTools = (): ToolInfo[] => pi.getAllTools();
845
897
 
@@ -923,6 +975,7 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
923
975
  };
924
976
  const registeredDuringFinalization = new Set<string>();
925
977
  let statusPublicationAttempted = false;
978
+ nextState.scriptTool = scriptTool;
926
979
  state = nextState;
927
980
  nextState.migrationNotices = [...sessionMigrationNotices];
928
981
  finalizationGuard = guard;
@@ -1066,21 +1119,20 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
1066
1119
  });
1067
1120
  }
1068
1121
 
1122
+ const scriptingSkillPath = fileURLToPath(new URL("./skills/mcp-scripting/SKILL.md", import.meta.url));
1069
1123
  pi.on("resources_discover", (event) => {
1070
1124
  const resourceConfig = programmaticConfig
1071
1125
  ? cloneMcpConfig(sessionConfig)
1072
1126
  : loadMcpConfig(earlyConfigPath, event.cwd);
1073
1127
  const skillPaths = discoverConfiguredClaudePluginSkills(resourceConfig, event.cwd);
1074
- if (earlyConfig.settings?.scriptMode !== false) {
1075
- const scriptingSkillPath = fileURLToPath(new URL("./skills/mcp-scripting/SKILL.md", import.meta.url));
1076
- if (existsSync(scriptingSkillPath) && !skillPaths.includes(scriptingSkillPath)) {
1077
- skillPaths.push(scriptingSkillPath);
1078
- }
1128
+ if (scriptTool && existsSync(scriptingSkillPath) && !skillPaths.includes(scriptingSkillPath)) {
1129
+ skillPaths.push(scriptingSkillPath);
1079
1130
  }
1080
1131
  return skillPaths.length > 0 ? { skillPaths } : undefined;
1081
1132
  });
1082
1133
 
1083
1134
  pi.on("session_start", async (_event, ctx) => {
1135
+ sessionCtx = null;
1084
1136
  const builtInMcpDetected = hasBuiltInMcpCommand(pi);
1085
1137
  if (!builtInMcpDetected && !mcpAliasRegistered) {
1086
1138
  registerMcpCommand("mcp");
@@ -1122,6 +1174,9 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
1122
1174
  }
1123
1175
 
1124
1176
  if (generation !== lifecycleGeneration || !owner.isActive()) return;
1177
+ // Recorded only after previous-session cleanup, so a runtime tool call cannot
1178
+ // start initialization before session_start decides whether to defer it.
1179
+ sessionCtx = ctx;
1125
1180
  if (state) return;
1126
1181
 
1127
1182
  if (!initPromise) {
@@ -1214,6 +1269,7 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
1214
1269
  });
1215
1270
 
1216
1271
  pi.on("session_shutdown", async () => {
1272
+ sessionCtx = null;
1217
1273
  ++lifecycleGeneration;
1218
1274
  const currentState = state;
1219
1275
  const owner = currentOwner;
@@ -1526,14 +1582,18 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
1526
1582
  },
1527
1583
  });
1528
1584
 
1529
- if (earlyConfig.settings?.scriptMode !== false) {
1585
+ if (scriptTool) {
1586
+ // The skill file is manual-only; Pi reads disable-model-invocation from the file and extensions cannot override it.
1587
+ const skillPointer = earlyConfig.settings?.scriptSkill === "model"
1588
+ ? ` Before writing a script, read ${scriptingSkillPath} for result shapes and limits.`
1589
+ : "";
1530
1590
  (pi.registerTool as (tool: unknown) => unknown)({
1531
1591
  name: "mcpScript",
1532
1592
  label: "MCP Script",
1533
- description: "Run sandboxed JavaScript that makes multiple MCP tool calls in one request — loop, filter, chain, or fan out between calls. For a single MCP call, search, describe, status check, or auth action, use the mcp tool instead. Discover with await tools.search({ query }) — resolves to { items: [{ path, name, server, description? }], total, hasMore, nextOffset }, not an { ok, data } envelope. Inspect with await tools.describe({ path }) — resolves to the tool descriptor with inputTypeScript, or { path, error: { code, message, suggestions } }. Then call tools.call(path, args) — resolves to { ok: true, data } or { ok: false, error: { code, message } } — or use direct flat calls when the name is already known; use emit(value) for user-visible output.",
1593
+ description: "Run sandboxed JavaScript to loop, filter, chain, or fan out across multiple MCP calls in one request. Use mcp for a single call, search, describe, status, or auth. await tools.search({ query }) returns { items: [{ path, name, server, description? }], total, hasMore, nextOffset }, not { ok, data }. await tools.describe({ path }) returns a descriptor with inputTypeScript or { path, error: { code, message, suggestions } }. tools.call(path, args) and tools.<path>(args) return { ok: true, data } or { ok: false, error: { code, message } }. data is the raw MCP result { content, structuredContent? }: use data.structuredContent when present; otherwise JSON usually needs JSON.parse(data.content[0].text). Use emit(value) for user-visible output." + skillPointer,
1534
1594
  promptSnippet: "Batch multiple MCP tool calls in one JavaScript request (loop, filter, chain)",
1535
1595
  parameters: Type.Object({
1536
- code: Type.String({ description: "Sandboxed JavaScript MCP script. Use tools.<prefixedToolName>(args) and emit(value)." }),
1596
+ code: Type.String({ description: "Sandboxed JavaScript MCP script. Pass tool names exactly as mcp lists them, e.g. tools.call(\"github_search_issues\", args), and use emit(value)." }),
1537
1597
  timeoutMs: optionalNumber({ minimum: 1, description: "Execution timeout in milliseconds (default: 30000)" }),
1538
1598
  }),
1539
1599
  renderCall: createMcpScriptToolCallRenderer(toolRenderOptions),
@@ -2055,7 +2115,7 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
2055
2115
  || hasEnabledServerWithoutValidMetadata(config, cache, directSpecs);
2056
2116
 
2057
2117
  if (shouldRegisterProxyTool) {
2058
- const description = buildProxyDescription(config);
2118
+ const description = buildProxyDescription(config, scriptTool);
2059
2119
  if (!proxyToolRegistered || proxyToolDescription !== description) {
2060
2120
  finalizationRegistrations?.add("mcp");
2061
2121
  registerProxyTool(description);
package/init.ts CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  getMetadataCachePath,
13
13
  getMissingConfiguredDirectToolServers,
14
14
  isServerCacheValid,
15
+ keepOutputShapes,
15
16
  loadMetadataCache,
16
17
  reconstructPromptMetadata,
17
18
  reconstructToolMetadata,
@@ -43,6 +44,7 @@ import {
43
44
  createSessionApprovalWriter,
44
45
  restoreSessionApprovalState,
45
46
  } from "./session-approvals.ts";
47
+ import { seedObservedOutputs } from "./output-shape.ts";
46
48
  export { getFailureAgeSeconds, getFailureMessage } from "./failure-backoff.ts";
47
49
 
48
50
  const MAX_FAILURE_MESSAGE_CHARS = 8 * 1024;
@@ -339,6 +341,7 @@ export async function initializeMcp(
339
341
  if (cachedEntry.instructions) {
340
342
  serverInstructions.set(name, cachedEntry.instructions);
341
343
  }
344
+ seedObservedOutputs(state, name, cachedEntry);
342
345
  }
343
346
  }
344
347
 
@@ -603,19 +606,29 @@ export function updateMetadataCache(
603
606
 
604
607
  const tools = serializeTools(connection.tools);
605
608
  let resources = definition.exposeResources === false ? [] : serializeResources(connection.resources);
609
+ const sessionEntry = state.sessionMetadata?.get(serverName);
610
+ const cacheScope = connection.toolListHints?.cacheScope;
611
+ // Metadata from a private listing is tied to that authorization context; it may only fill an entry that is private too.
612
+ const sessionFallback = sessionEntry?.configHash === configHash && (sessionEntry.cacheScope !== "private" || cacheScope === "private")
613
+ ? sessionEntry
614
+ : undefined;
606
615
  const prompts = connection.promptDiscoveryFailed
607
- ? existingEntry?.configHash === configHash ? existingEntry.prompts : undefined
616
+ ? sessionFallback
617
+ ? sessionFallback.prompts
618
+ : existingEntry?.configHash === configHash && isServerCacheValid(existingEntry, definition)
619
+ ? existingEntry.prompts
620
+ : undefined
608
621
  : serializePrompts(connection.prompts ?? []);
609
622
 
610
623
  if (definition.exposeResources !== false && connection.resourceDiscoveryFailed === true) {
611
- const sessionEntry = state.sessionMetadata?.get(serverName);
612
- if (sessionEntry?.configHash === configHash) {
613
- resources = sessionEntry.resources ?? [];
624
+ if (sessionFallback) {
625
+ resources = sessionFallback.resources ?? [];
614
626
  } else if (existingEntry?.resources?.length && isServerCacheValid(existingEntry, definition)) {
615
627
  resources = existingEntry.resources;
616
628
  }
617
629
  }
618
630
 
631
+ const outputShapes = keepOutputShapes(existingEntry, configHash, tools);
619
632
  const entry: ServerCacheEntry = {
620
633
  configHash,
621
634
  tools,
@@ -623,12 +636,14 @@ export function updateMetadataCache(
623
636
  ...(prompts !== undefined ? { prompts } : {}),
624
637
  ...(connection.instructions !== undefined ? { instructions: connection.instructions } : {}),
625
638
  ...(connection.toolListHints?.ttlMs !== undefined ? { ttlMs: connection.toolListHints.ttlMs } : {}),
626
- ...(connection.toolListHints?.cacheScope !== undefined ? { cacheScope: connection.toolListHints.cacheScope } : {}),
639
+ ...(cacheScope !== undefined ? { cacheScope } : {}),
640
+ ...(outputShapes !== undefined ? { outputShapes } : {}),
627
641
  cachedAt: Date.now(),
628
642
  };
629
643
 
630
644
  (state.sessionMetadata ??= new Map()).set(serverName, entry);
631
645
  saveMetadataCache({ version: 1, servers: { [serverName]: entry } });
646
+ seedObservedOutputs(state, serverName, entry);
632
647
  }
633
648
 
634
649
  export function notifyToolMetadataUpdated(state: McpExtensionState, serverName: string, reason: string): void {
package/mcp-auth-flow.ts CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  auth as runSdkAuth,
9
9
  extractWWWAuthenticateParams,
10
10
  LATEST_PROTOCOL_VERSION,
11
+ RegistrationRejectedError,
11
12
  UnauthorizedError,
12
13
  validateClientMetadataUrl,
13
14
  type AuthOptions,
@@ -443,6 +444,13 @@ function parseOAuthRedirectUri(redirectUri: string): OAuthRedirectTarget {
443
444
  }
444
445
  }
445
446
 
447
+ export function explainRegistrationRejection(error: RegistrationRejectedError, serverUrl: string): Error {
448
+ const hint = new URL(serverUrl).hostname === "mcp.figma.com"
449
+ ? "Figma's remote MCP server only accepts approved clients, and Pi isn't approved yet. Use the Figma desktop app's local server instead: run /mcp-adapter setup."
450
+ : "This server only accepts pre-registered OAuth clients. If the provider gave you a client ID, set oauth.clientId (and oauth.clientSecret if required) for this server."
451
+ return new Error(`${error.message}. ${hint}`, { cause: error })
452
+ }
453
+
446
454
  /**
447
455
  * Start OAuth authentication flow for a server.
448
456
  * Returns the authorization URL when browser authorization is required.
@@ -464,6 +472,9 @@ export async function startAuth(
464
472
  const signal = combineAbortSignals(runtime.signal, options.signal)
465
473
  const generation = runtimeState.generation
466
474
  throwIfAborted(signal)
475
+ const explainRejection = (error: unknown): never => {
476
+ throw error instanceof RegistrationRejectedError ? explainRegistrationRejection(error, serverUrl) : error
477
+ }
467
478
 
468
479
  if (config.grantType === "client_credentials") {
469
480
  const storedAuth = await getAuthForUrl(serverName, serverUrl, authStorageOptions)
@@ -486,7 +497,7 @@ export async function startAuth(
486
497
  const discovery = applyOAuthConfig(await probeAuthDiscovery(serverUrl, definition, signal), config)
487
498
  authority()
488
499
  throwIfAborted(signal)
489
- const result = await abortable(runSdkAuth(authProvider, { serverUrl, ...discovery, fetchFn }), signal)
500
+ const result = await abortable(runSdkAuth(authProvider, { serverUrl, ...discovery, fetchFn }).catch(explainRejection), signal)
490
501
  authority()
491
502
  throwIfAborted(signal)
492
503
  if (result !== "AUTHORIZED") {
@@ -580,7 +591,7 @@ export async function startAuth(
580
591
  const discovery = applyOAuthConfig(await probeAuthDiscovery(serverUrl, definition, signal), config)
581
592
  authority()
582
593
  throwIfAborted(signal)
583
- const result = await abortable(runSdkAuth(authProvider, { serverUrl, ...discovery, fetchFn }), signal)
594
+ const result = await abortable(runSdkAuth(authProvider, { serverUrl, ...discovery, fetchFn }).catch(explainRejection), signal)
584
595
  authority()
585
596
  throwIfAborted(signal)
586
597
  if (result === "AUTHORIZED") {
package/mcp-code.ts CHANGED
@@ -6,20 +6,30 @@ import { guardMcpOutput, guardedMcpDetails, resolveMcpOutputGuardOptions } from
6
6
  import { loadMcpScriptWasm, resolveMcpScriptQuickJsUrl } from "./mcp-script-wasm.ts";
7
7
  import { evaluateJev, validateJevSettings } from "./jev-client.ts";
8
8
  import type { JevErrorCode, JevEvaluateInput, JevEvaluationEnvelope } from "./jev-contracts.ts";
9
- import { executeCall } from "./proxy-modes.ts";
9
+ import { executeCall, findTools, resolveDescribeTarget, unscopedCallReachesServer } from "./proxy-modes.ts";
10
10
  import { combineAbortSignals } from "./runtime-owner.ts";
11
- import { paginate, rankSuggestions, rankToolMatches } from "./search-ranking.ts";
12
- import { semanticSearch, type SemanticSearchEvaluator } from "./semantic-search.ts";
13
- import { isServerInActiveFailureBackoff } from "./failure-backoff.ts";
11
+ import { paginate } from "./search-ranking.ts";
12
+ import type { SemanticSearchEvaluator } from "./semantic-search.ts";
14
13
  import type { McpExtensionState } from "./state.ts";
15
- import { findToolByName, formatSchema, hasSchemaDescriptions } from "./tool-metadata.ts";
14
+ import { formatSchema, hasSchemaDescriptions } from "./tool-metadata.ts";
16
15
  import { renderTsShape } from "./ts-shape.ts";
17
16
  import { getObservedOutput, renderOutputShape } from "./output-shape.ts";
17
+ import type { ObservedOutput } from "./output-shape.ts";
18
18
  import type { ContentBlock } from "./types.ts";
19
19
 
20
20
  export const DEFAULT_MCP_SCRIPT_TIMEOUT_MS = 30_000;
21
21
  const MCP_SCRIPT_INTERMEDIATE_MAX_BYTES = 16 * 1024 * 1024;
22
22
  const MCP_SCRIPT_OUTPUT_MAX_BYTES = 16 * 1024 * 1024;
23
+ const SEEN_FIELDS_MAX_CHARS = 8 * 1024;
24
+ const EMPTY_RETURNS = new Set(["[]", "{}", "null", ""]);
25
+
26
+ // Spelled out from the call because models otherwise read `content` off the { ok, data } envelope.
27
+ // Names the server only when the path alone would not reach this server's tool, e.g. a name two servers share.
28
+ const observedTarget = (state: McpExtensionState, observed: ObservedOutput, path: string, server: string) => {
29
+ const scope = unscopedCallReachesServer(state, path, server) ? "" : `, { server: ${JSON.stringify(server)} }`;
30
+ const call = `(await tools.call(${JSON.stringify(path)}, args${scope}))`;
31
+ return observed.source === "structuredContent" ? `${call}.data.structuredContent` : `JSON.parse(${call}.data.content[0].text)`;
32
+ };
23
33
 
24
34
  class McpScriptTimeoutError extends Error {
25
35
  constructor(timeoutMs: number) {
@@ -29,10 +39,10 @@ class McpScriptTimeoutError extends Error {
29
39
  }
30
40
 
31
41
  type SearchInput = { query?: unknown; server?: unknown; limit?: unknown; offset?: unknown; searchMode?: unknown; regex?: unknown };
32
- type DescribeInput = { path?: unknown };
42
+ type DescribeInput = { path?: unknown; server?: unknown };
33
43
  type WorkerMessage =
34
44
  | { type: "emit"; block: unknown }
35
- | { type: "call"; id: number; path: string; args?: unknown }
45
+ | { type: "call"; id: number; path: string; args?: unknown; server?: string }
36
46
  | { type: "evaluate"; id: number; input: unknown }
37
47
  | { type: "search"; id: number; input?: unknown }
38
48
  | { type: "describe"; id: number; input?: unknown }
@@ -88,6 +98,19 @@ function textFromContent(content: ContentBlock[]): string {
88
98
  .join("\n");
89
99
  }
90
100
 
101
+ function resultErrorMessage(result: { content: ContentBlock[]; details: Record<string, unknown> }): string {
102
+ return typeof result.details.message === "string" ? result.details.message : textFromContent(result.content);
103
+ }
104
+
105
+ /** Script-usable guidance for scoping errors whose shared text points at mcp(), which scripts cannot call. */
106
+ function scriptScopeMessage(code: string, path: string, server: unknown, retry: string): string | undefined {
107
+ if (code === "server_not_found") return `Server "${String(server)}" not found. Use the server from a tools.search hit.`;
108
+ if (code !== "ambiguous_tool") return undefined;
109
+ return typeof server === "string"
110
+ ? `Tool "${path}" matches multiple tools on server "${server}". Use an exact path from tools.search({ query: "", server: ${JSON.stringify(server)} }).`
111
+ : `Tool "${path}" matches multiple servers. Pass the server from tools.search: ${retry}.`;
112
+ }
113
+
91
114
  function abortReasonError(reason: unknown): Error {
92
115
  return reason instanceof Error ? reason : new Error(String(reason ?? "MCP request aborted"));
93
116
  }
@@ -97,9 +120,13 @@ function parseWorkerMessage(value: unknown): WorkerMessage | null {
97
120
  const message = value as Record<string, unknown>;
98
121
  if (message.type === "emit" && "block" in message) return { type: "emit", block: message.block };
99
122
  if (message.type === "call" && typeof message.id === "number" && typeof message.path === "string") {
100
- return "args" in message
101
- ? { type: "call", id: message.id, path: message.path, args: message.args }
102
- : { type: "call", id: message.id, path: message.path };
123
+ return {
124
+ type: "call",
125
+ id: message.id,
126
+ path: message.path,
127
+ ...("args" in message ? { args: message.args } : {}),
128
+ ...(typeof message.server === "string" ? { server: message.server } : {}),
129
+ };
103
130
  }
104
131
  if (message.type === "evaluate" && typeof message.id === "number" && "input" in message) {
105
132
  return { type: "evaluate", id: message.id, input: message.input };
@@ -162,18 +189,21 @@ export async function runMcpScript(
162
189
  }));
163
190
  let callsSnapshot: ScriptOperation[] | undefined;
164
191
  let intermediateBytes = 0;
192
+ // Successful calls by server and path, for listing the result fields they returned when the script fails or finds nothing.
193
+ const calledTools = new Map<string, { path: string; server: string; tool: string }>();
194
+ let returnedEmpty = false;
165
195
  const reserveIntermediateBytes = (dataJson: string): boolean => {
166
196
  const bytes = Buffer.byteLength(dataJson, "utf8");
167
197
  if (bytes > MCP_SCRIPT_INTERMEDIATE_MAX_BYTES - intermediateBytes) return false;
168
198
  intermediateBytes += bytes;
169
199
  return true;
170
200
  };
171
- const callTool = async (path: string, args?: Record<string, unknown>): Promise<WorkerResultPayload> => {
201
+ const callTool = async (path: string, args?: Record<string, unknown>, server?: string): Promise<WorkerResultPayload> => {
172
202
  // Record before dispatch so calls still in flight at timeout/abort appear in the trace.
173
203
  const startedAt = Date.now();
174
204
  const index = calls.push({ operation: "call", path, ok: false, error: "incomplete", durationMs: 0, startedAt }) - 1;
175
205
  let dataJson: string | undefined;
176
- const result = await executeCall(state, path, args, undefined, getPiTools, callSignal, "script", {
206
+ const result = await executeCall(state, path, args, server, getPiTools, callSignal, "script", {
177
207
  onSuccess(data) {
178
208
  // Serialize once for both byte accounting and worker transfer, never for display.
179
209
  dataJson = JSON.stringify(data);
@@ -188,9 +218,7 @@ export async function runMcpScript(
188
218
  : [];
189
219
  const message = errorCode === "tool_not_found"
190
220
  ? `Tool "${path}" not found. Use await tools.search({ query: "..." }) inside mcpScript.${suggestions.length > 0 ? ` Did you mean: ${suggestions.join(", ")}` : ""}`
191
- : typeof details.message === "string"
192
- ? details.message
193
- : textFromContent(result.content);
221
+ : scriptScopeMessage(errorCode, path, details.server, "tools.call(path, args, { server })") ?? resultErrorMessage(result);
194
222
  calls[index] = { operation: "call", path, ok: false, error: errorCode, durationMs: Date.now() - startedAt, startedAt };
195
223
  return {
196
224
  envelope: { ok: false, error: { code: errorCode, message } },
@@ -210,6 +238,10 @@ export async function runMcpScript(
210
238
  }
211
239
  // Rejected responses do not consume budget. This bounds transfer, not upstream allocation.
212
240
  calls[index] = { operation: "call", path, ok: true, durationMs: Date.now() - startedAt, startedAt };
241
+ if (typeof details.server === "string" && typeof details.tool === "string") {
242
+ const calledPath = typeof details.canonicalTool === "string" ? details.canonicalTool : path;
243
+ calledTools.set(JSON.stringify([details.server, calledPath]), { path: calledPath, server: details.server, tool: details.tool });
244
+ }
213
245
  return { dataJson };
214
246
  };
215
247
 
@@ -279,34 +311,27 @@ export async function runMcpScript(
279
311
  const index = calls.push({ operation: "search", query, ok: false, error: "incomplete", durationMs: 0, startedAt }) - 1;
280
312
  let error: unknown;
281
313
  try {
282
- if (input?.searchMode !== undefined && input.searchMode !== "lexical" && input.searchMode !== "semantic") {
283
- error = "invalid_search_mode";
284
- return { items: [], total: 0, hasMore: false, nextOffset: null, error: { code: "invalid_search_mode", message: "Search mode must be lexical or semantic." } };
285
- }
286
- if (query.trim() === "" && input?.searchMode !== "semantic") {
287
- return { items: [], total: 0, hasMore: false, nextOffset: null };
314
+ const outcome = await findTools(state, {
315
+ query,
316
+ regex: input?.regex === true,
317
+ server: typeof input?.server === "string" ? input.server : undefined,
318
+ searchMode: input?.searchMode,
319
+ signal: callSignal,
320
+ semanticEvaluator: async (semanticState, semanticInput, options) => {
321
+ const rejected = admitEvaluation(semanticInput);
322
+ if (rejected) return rejected;
323
+ const envelope = await (semanticEvaluator ?? evaluateJev)(semanticState, semanticInput, options);
324
+ return chargeEvaluationTokens(envelope);
325
+ },
326
+ observedSources: [...observedSources],
327
+ });
328
+ if ("error" in outcome) {
329
+ error = String(outcome.error.details.error);
330
+ return { items: [], total: 0, hasMore: false, nextOffset: null, error: { code: error, message: resultErrorMessage(outcome.error) } };
288
331
  }
289
- const server = typeof input?.server === "string" ? input.server : undefined;
290
332
  const limit = typeof input?.limit === "number" ? input.limit : 12;
291
333
  const offset = typeof input?.offset === "number" ? input.offset : 0;
292
- const searchMode = input?.searchMode === "semantic" ? "semantic" : "lexical";
293
- if (searchMode === "semantic" && input?.regex === true) {
294
- error = "invalid_search_mode";
295
- return { items: [], total: 0, hasMore: false, nextOffset: null, error: { code: "invalid_search_mode", message: "Semantic search cannot be combined with regex search." } };
296
- }
297
- const semantic = searchMode === "semantic"
298
- ? await semanticSearch(state, query, server, callSignal, async (semanticState, semanticInput, options) => {
299
- const rejected = admitEvaluation(semanticInput);
300
- if (rejected) return rejected;
301
- const envelope = await (semanticEvaluator ?? evaluateJev)(semanticState, semanticInput, options);
302
- return chargeEvaluationTokens(envelope);
303
- }, [...observedSources])
304
- : undefined;
305
- if (semantic && !semantic.ok) {
306
- error = semantic.error.code;
307
- return { items: [], total: 0, hasMore: false, nextOffset: null, error: semantic.error };
308
- }
309
- const page = paginate(semantic?.matches ?? rankToolMatches(state, query, server), offset, limit);
334
+ const page = paginate(outcome.matches, offset, limit);
310
335
  return {
311
336
  ...page,
312
337
  items: page.items.map(({ server: matchServer, tool, score }) => ({
@@ -316,7 +341,7 @@ export async function runMcpScript(
316
341
  ...(tool.description ? { description: tool.description } : {}),
317
342
  score,
318
343
  })),
319
- ...(semantic ? { backend: semantic.backend } : {}),
344
+ ...(outcome.backend ? { backend: outcome.backend } : {}),
320
345
  };
321
346
  } catch (caught) {
322
347
  error = caught;
@@ -333,44 +358,45 @@ export async function runMcpScript(
333
358
  const path = typeof input?.path === "string" ? input.path : "";
334
359
  let error: unknown;
335
360
  try {
336
- for (const [server, metadata] of state.toolMetadata) {
337
- if (isServerInActiveFailureBackoff(state, server)) continue;
338
- const tool = findToolByName(metadata, path);
339
- if (!tool) continue;
340
- const inputShape = tool.inputSchema ? renderTsShape(tool.inputSchema) : null;
341
- const inputTypeScript = inputShape ?? (tool.inputSchema ? formatSchema(tool.inputSchema) : null);
342
- const observed = tool.resourceUri ? undefined : getObservedOutput(state, server, tool);
361
+ const target = resolveDescribeTarget(state, path, typeof input?.server === "string" ? input.server : undefined);
362
+ if ("error" in target) {
363
+ const details = target.error.details;
364
+ const code = String(details.error);
365
+ error = code;
343
366
  return {
344
- path: tool.name,
345
- name: tool.originalName,
346
- server,
347
- ...(tool.description ? { description: tool.description } : {}),
348
- ...(inputTypeScript ? { inputTypeScript } : {}),
349
- ...(inputShape && hasSchemaDescriptions(tool.inputSchema)
350
- ? { inputGuidance: formatSchema(tool.inputSchema) } : {}),
351
- ...(tool.outputSchema !== undefined ? {
352
- outputSchemaTarget: "data.structuredContent",
353
- outputSchema: tool.outputSchema,
354
- } : {}),
355
- ...(tool.annotations ? { annotations: tool.annotations } : {}),
356
- ...(observed ? {
357
- observedOutput: {
358
- target: observed.source === "structuredContent" ? "data.structuredContent" : "JSON.parse(data.content[0].text)",
359
- typeScript: renderOutputShape(observed.shape),
360
- calls: observed.calls,
361
- },
362
- } : {}),
367
+ path,
368
+ error: {
369
+ code,
370
+ message: code === "tool_not_found"
371
+ ? `Tool not found: ${path}`
372
+ : scriptScopeMessage(code, path, details.server, "tools.describe({ path, server })") ?? resultErrorMessage(target.error),
373
+ suggestions: Array.isArray(details.suggestions) ? details.suggestions : [],
374
+ },
363
375
  };
364
376
  }
365
- const suggestions = path ? rankSuggestions(state, path, 5) : [];
366
- error = "tool_not_found";
377
+ const { server, tool } = target;
378
+ const inputShape = tool.inputSchema ? renderTsShape(tool.inputSchema) : null;
379
+ const inputTypeScript = inputShape ?? (tool.inputSchema ? formatSchema(tool.inputSchema) : null);
380
+ const observed = tool.resourceUri ? undefined : getObservedOutput(state, server, tool);
367
381
  return {
368
- path,
369
- error: {
370
- code: "tool_not_found",
371
- message: `Tool not found: ${path}`,
372
- suggestions,
373
- },
382
+ path: tool.name,
383
+ name: tool.originalName,
384
+ server,
385
+ ...(tool.description ? { description: tool.description } : {}),
386
+ ...(inputTypeScript ? { inputTypeScript } : {}),
387
+ ...(inputShape && hasSchemaDescriptions(tool.inputSchema)
388
+ ? { inputGuidance: formatSchema(tool.inputSchema) } : {}),
389
+ ...(tool.outputSchema !== undefined ? {
390
+ outputSchemaTarget: "data.structuredContent",
391
+ outputSchema: tool.outputSchema,
392
+ } : {}),
393
+ ...(tool.annotations ? { annotations: tool.annotations } : {}),
394
+ ...(observed ? {
395
+ observedOutput: {
396
+ target: observedTarget(state, observed, tool.name, server),
397
+ typeScript: renderOutputShape(observed.shape),
398
+ },
399
+ } : {}),
374
400
  };
375
401
  } catch (caught) {
376
402
  error = caught;
@@ -457,6 +483,8 @@ export async function runMcpScript(
457
483
  rejectOutputBudget();
458
484
  return;
459
485
  }
486
+ const returned = message.returnBlock as { type?: unknown; text?: unknown } | undefined;
487
+ returnedEmpty = returned?.type === "text" && typeof returned.text === "string" && EMPTY_RETURNS.has(returned.text.trim());
460
488
  completed = true;
461
489
  resolve();
462
490
  return;
@@ -470,7 +498,7 @@ export async function runMcpScript(
470
498
  void (async () => {
471
499
  let payload: WorkerResultPayload;
472
500
  if (message.type === "call") {
473
- payload = await callTool(message.path, message.args as Record<string, unknown> | undefined);
501
+ payload = await callTool(message.path, message.args as Record<string, unknown> | undefined, message.server);
474
502
  } else if (message.type === "evaluate") {
475
503
  payload = await evaluate(message.input);
476
504
  } else if (message.type === "search") {
@@ -514,10 +542,30 @@ export async function runMcpScript(
514
542
  await worker?.terminate();
515
543
  }
516
544
 
545
+ // A script that fails or finds nothing after calling a tool without an output schema usually guessed
546
+ // the result fields wrong. Listing the fields seen saves a separate turn spent looking at the data.
547
+ let seenFields: string | undefined;
548
+ if (errorCode === "timeout" || errorCode === "script_error" || returnedEmpty || output.length === 0) {
549
+ const sections: string[] = [];
550
+ let chars = 0;
551
+ for (const { path, server, tool } of calledTools.values()) {
552
+ const meta = state.toolMetadata.get(server)?.find(entry => entry.originalName === tool && !entry.resourceUri);
553
+ const observed = meta && getObservedOutput(state, server, meta);
554
+ if (!observed) continue;
555
+ const section = `${observedTarget(state, observed, path, server)} is:\n${renderOutputShape(observed.shape)}`;
556
+ if (chars + section.length > SEEN_FIELDS_MAX_CHARS) break;
557
+ chars += section.length;
558
+ sections.push(section);
559
+ }
560
+ if (sections.length > 0) {
561
+ seenFields = `\n\n[Result fields seen from the tools this script called (names and types only, not a contract):\n${sections.join("\n\n")}]`;
562
+ }
563
+ }
564
+
517
565
  // Snapshot before the asynchronous output guard; the terminated worker can no longer emit.
518
566
  const guarded = await guardMcpOutput(
519
567
  output.length > 0 ? [...output] : [{ type: "text", text: "(no output)" }],
520
- resolveMcpOutputGuardOptions(state.config.settings),
568
+ { ...resolveMcpOutputGuardOptions(state.config.settings), ...(seenFields ? { footer: seenFields } : {}) },
521
569
  );
522
570
  return {
523
571
  content: guarded.content,