pi-mcp-adapter 3.2.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.
Files changed (70) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/OAUTH.md +4 -2
  3. package/README.md +72 -10
  4. package/cli.js +1 -0
  5. package/commands.ts +20 -2
  6. package/config.ts +19 -1
  7. package/direct-tool-surface.ts +3 -2
  8. package/direct-tools.ts +12 -2
  9. package/dist/config.d.ts +6 -0
  10. package/dist/config.js +15 -0
  11. package/dist/config.js.map +1 -1
  12. package/dist/elicitation-handler.d.ts +9 -1
  13. package/dist/elicitation-handler.js +65 -2
  14. package/dist/elicitation-handler.js.map +1 -1
  15. package/dist/jev-client.js +7 -5
  16. package/dist/jev-client.js.map +1 -1
  17. package/dist/jev-key-store.d.ts +1 -0
  18. package/dist/jev-key-store.js +11 -0
  19. package/dist/jev-key-store.js.map +1 -1
  20. package/dist/mcp-auth-flow.d.ts +2 -0
  21. package/dist/mcp-auth-flow.js +12 -3
  22. package/dist/mcp-auth-flow.js.map +1 -1
  23. package/dist/metadata-cache.d.ts +12 -0
  24. package/dist/metadata-cache.js +50 -6
  25. package/dist/metadata-cache.js.map +1 -1
  26. package/dist/output-shape.d.ts +36 -0
  27. package/dist/output-shape.js +338 -0
  28. package/dist/output-shape.js.map +1 -0
  29. package/dist/package-mcp-loader.js +9 -5
  30. package/dist/package-mcp-loader.js.map +1 -1
  31. package/dist/server-manager.js +27 -16
  32. package/dist/server-manager.js.map +1 -1
  33. package/dist/state.d.ts +6 -0
  34. package/dist/ts-shape.d.ts +3 -0
  35. package/dist/ts-shape.js +153 -0
  36. package/dist/ts-shape.js.map +1 -0
  37. package/dist/types.d.ts +24 -3
  38. package/dist/types.js.map +1 -1
  39. package/dist/unix-socket-transport.js +1 -1
  40. package/dist/unix-socket-transport.js.map +1 -1
  41. package/dist/utils.d.ts +6 -1
  42. package/dist/utils.js +17 -0
  43. package/dist/utils.js.map +1 -1
  44. package/elicitation-handler.ts +78 -1
  45. package/index.ts +72 -12
  46. package/init.ts +20 -5
  47. package/jev-client.ts +7 -4
  48. package/jev-key-store.ts +7 -0
  49. package/mcp-auth-flow.ts +13 -2
  50. package/mcp-code.ts +126 -68
  51. package/mcp-output-guard.ts +24 -5
  52. package/mcp-script-worker.mjs +6 -2
  53. package/mcp-setup-panel.ts +583 -302
  54. package/metadata-cache.ts +60 -5
  55. package/output-shape.ts +348 -0
  56. package/package-mcp-loader.ts +8 -5
  57. package/package.json +6 -6
  58. package/prompts.ts +68 -0
  59. package/proxy-modes.ts +149 -106
  60. package/server-manager.ts +28 -15
  61. package/skills/mcp-scripting/SKILL.md +24 -28
  62. package/skills/mcp-scripting/references/jev.md +3 -0
  63. package/state.ts +6 -0
  64. package/tool-approval.ts +17 -4
  65. package/tool-metadata.ts +3 -1
  66. package/ts-shape.ts +1 -1
  67. package/types.ts +22 -3
  68. package/ui-server.ts +5 -2
  69. package/unix-socket-transport.ts +1 -1
  70. package/utils.ts +16 -1
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/jev-client.ts CHANGED
@@ -2,7 +2,7 @@ import { TypeSafeClient } from "@typesafe-ai/sdk";
2
2
  import type { McpExtensionState } from "./state.ts";
3
3
  import { isServerDisabled } from "./types.ts";
4
4
  import { combineAbortSignals } from "./runtime-owner.ts";
5
- import { JEV_SDK_PATH, resolveJevCredential, resolveJevEndpoint, type JevCredentialResolution, type ResolvedJevEndpoint } from "./jev-key-store.ts";
5
+ import { JEV_SDK_PATH, OPENROUTER_ORIGIN, resolveJevCredential, resolveJevEndpoint, type JevCredentialResolution, type ResolvedJevEndpoint } from "./jev-key-store.ts";
6
6
  import type { JevAnswer, JevBudget, JevEvaluateInput, JevEvaluationData, JevEvaluationEnvelope, JevJson, JevQuestion, ResolvedJevSettings } from "./jev-contracts.ts";
7
7
 
8
8
  export type { ResolvedJevSettings } from "./jev-contracts.ts";
@@ -15,6 +15,8 @@ const DEFAULTS: ResolvedJevSettings = {
15
15
  semanticMinProbability: 0.2,
16
16
  };
17
17
  const DANGEROUS_KEYS = new Set(["__proto__", "constructor", "prototype"]);
18
+ /** OpenRouter's name for Jev, used there when `settings.jev.model` is not configured. */
19
+ const OPENROUTER_DEFAULT_MODEL = "typesafe/jev-1.13";
18
20
 
19
21
  type JevSettingsInput = Partial<ResolvedJevSettings>;
20
22
  type Host = { endpoint: ResolvedJevEndpoint; client: TypeSafeClient };
@@ -192,8 +194,8 @@ function sameKeys(actual: Record<string, unknown>, expected: string[]): boolean
192
194
  return keys.length === expected.length && expected.every(key => Object.hasOwn(actual, key));
193
195
  }
194
196
  function validateResponse(value: unknown, input: JevEvaluateInput): JevEvaluationData {
197
+ // Providers may add metadata such as `id`, `provider`, or `usage.cost`; only the fields validated here are kept.
195
198
  const response = record(value, "response");
196
- exactKeys(response, ["model", "answers", "usage"], "response");
197
199
  const answers = record(response.answers, "response answers");
198
200
  const questionNames = Object.keys(input.questions);
199
201
  if (!sameKeys(answers, questionNames)) throw new Error("Response answer keys do not match questions");
@@ -224,7 +226,6 @@ function validateResponse(value: unknown, input: JevEvaluateInput): JevEvaluatio
224
226
  }
225
227
  if (typeof response.model !== "string" || response.model.length === 0 || response.model.length > 128) throw new Error("Invalid response model");
226
228
  const usage = record(response.usage, "response usage");
227
- if (!sameKeys(usage, ["input_tokens", "output_tokens"])) throw new Error("Invalid response usage");
228
229
  return { answers: validated, model: response.model, usage: { inputTokens: safeNumber(usage.input_tokens, "input tokens", true), outputTokens: safeNumber(usage.output_tokens, "output tokens", true) } };
229
230
  }
230
231
 
@@ -291,7 +292,9 @@ export async function evaluateJev(state: McpExtensionState, value: JevEvaluateIn
291
292
  const timer = setTimeout(() => { timedOut = true; deadline.abort(new Error("Jev evaluation deadline exceeded")); }, settings.requestTimeoutMs);
292
293
  const signal = combineAbortSignals(options.signal, state.owner.signal, deadline.signal);
293
294
  try {
294
- const request = { state: input.state, questions: input.questions, model: settings.model };
295
+ const configured = state.config.settings?.jev;
296
+ const model = resolved.endpoint.origin === OPENROUTER_ORIGIN && (!configured || configured.model === undefined) ? OPENROUTER_DEFAULT_MODEL : settings.model;
297
+ const request = { state: input.state, questions: input.questions, model };
295
298
  const raw = await resolved.client.systemOne(request as Parameters<TypeSafeClient["systemOne"]>[0], { ...(signal ? { signal } : {}), timeout: settings.requestTimeoutMs, retry: { maxRetries: settings.maxRetries } });
296
299
  try { return { ok: true, data: validateResponse(raw, input) }; }
297
300
  catch { return { ok: false, error: { code: "invalid_response", message: "Jev returned an invalid response." } }; }
package/jev-key-store.ts CHANGED
@@ -6,6 +6,9 @@ const SYSTEMONE_ENDPOINT_ENV = "SYSTEMONE_ENDPOINT";
6
6
  const SYSTEMONE_API_KEY_ENV = "SYSTEMONE_API_KEY";
7
7
  /** TypeSafe-issued key: honored only for the default endpoint and never sent anywhere else. */
8
8
  const LEGACY_TYPESAFE_API_KEY_ENV = "TYPESAFE_API_KEY";
9
+ /** OpenRouter's general-purpose key: honored only for an OpenRouter endpoint and never sent anywhere else. */
10
+ const OPENROUTER_API_KEY_ENV = "OPENROUTER_API_KEY";
11
+ export const OPENROUTER_ORIGIN = "https://openrouter.ai";
9
12
  /** Origin recorded in version 1 credential records. */
10
13
  export const TYPESAFE_API_ORIGIN = "https://api.typesafe.ai";
11
14
  /** Version 1 credential account, still read for the default endpoint. */
@@ -158,6 +161,10 @@ export function resolveJevCredential(
158
161
  try { return { status: "present", source: "environment", apiKey: validateApiKey(env[LEGACY_TYPESAFE_API_KEY_ENV]) }; }
159
162
  catch { return { status: "unavailable", message: `${LEGACY_TYPESAFE_API_KEY_ENV} is present but invalid.` }; }
160
163
  }
164
+ if (target.origin === OPENROUTER_ORIGIN && Object.hasOwn(env, OPENROUTER_API_KEY_ENV)) {
165
+ try { return { status: "present", source: "environment", apiKey: validateApiKey(env[OPENROUTER_API_KEY_ENV]) }; }
166
+ catch { return { status: "unavailable", message: `${OPENROUTER_API_KEY_ENV} is present but invalid.` }; }
167
+ }
161
168
  let stored: string | undefined;
162
169
  try { stored = readStoredKey(secretStore, target); }
163
170
  catch (error) {
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,19 +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";
16
+ import { getObservedOutput, renderOutputShape } from "./output-shape.ts";
17
+ import type { ObservedOutput } from "./output-shape.ts";
17
18
  import type { ContentBlock } from "./types.ts";
18
19
 
19
20
  export const DEFAULT_MCP_SCRIPT_TIMEOUT_MS = 30_000;
20
21
  const MCP_SCRIPT_INTERMEDIATE_MAX_BYTES = 16 * 1024 * 1024;
21
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
+ };
22
33
 
23
34
  class McpScriptTimeoutError extends Error {
24
35
  constructor(timeoutMs: number) {
@@ -28,10 +39,10 @@ class McpScriptTimeoutError extends Error {
28
39
  }
29
40
 
30
41
  type SearchInput = { query?: unknown; server?: unknown; limit?: unknown; offset?: unknown; searchMode?: unknown; regex?: unknown };
31
- type DescribeInput = { path?: unknown };
42
+ type DescribeInput = { path?: unknown; server?: unknown };
32
43
  type WorkerMessage =
33
44
  | { type: "emit"; block: unknown }
34
- | { type: "call"; id: number; path: string; args?: unknown }
45
+ | { type: "call"; id: number; path: string; args?: unknown; server?: string }
35
46
  | { type: "evaluate"; id: number; input: unknown }
36
47
  | { type: "search"; id: number; input?: unknown }
37
48
  | { type: "describe"; id: number; input?: unknown }
@@ -87,6 +98,19 @@ function textFromContent(content: ContentBlock[]): string {
87
98
  .join("\n");
88
99
  }
89
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
+
90
114
  function abortReasonError(reason: unknown): Error {
91
115
  return reason instanceof Error ? reason : new Error(String(reason ?? "MCP request aborted"));
92
116
  }
@@ -96,9 +120,13 @@ function parseWorkerMessage(value: unknown): WorkerMessage | null {
96
120
  const message = value as Record<string, unknown>;
97
121
  if (message.type === "emit" && "block" in message) return { type: "emit", block: message.block };
98
122
  if (message.type === "call" && typeof message.id === "number" && typeof message.path === "string") {
99
- return "args" in message
100
- ? { type: "call", id: message.id, path: message.path, args: message.args }
101
- : { 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
+ };
102
130
  }
103
131
  if (message.type === "evaluate" && typeof message.id === "number" && "input" in message) {
104
132
  return { type: "evaluate", id: message.id, input: message.input };
@@ -161,18 +189,21 @@ export async function runMcpScript(
161
189
  }));
162
190
  let callsSnapshot: ScriptOperation[] | undefined;
163
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;
164
195
  const reserveIntermediateBytes = (dataJson: string): boolean => {
165
196
  const bytes = Buffer.byteLength(dataJson, "utf8");
166
197
  if (bytes > MCP_SCRIPT_INTERMEDIATE_MAX_BYTES - intermediateBytes) return false;
167
198
  intermediateBytes += bytes;
168
199
  return true;
169
200
  };
170
- 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> => {
171
202
  // Record before dispatch so calls still in flight at timeout/abort appear in the trace.
172
203
  const startedAt = Date.now();
173
204
  const index = calls.push({ operation: "call", path, ok: false, error: "incomplete", durationMs: 0, startedAt }) - 1;
174
205
  let dataJson: string | undefined;
175
- const result = await executeCall(state, path, args, undefined, getPiTools, callSignal, "script", {
206
+ const result = await executeCall(state, path, args, server, getPiTools, callSignal, "script", {
176
207
  onSuccess(data) {
177
208
  // Serialize once for both byte accounting and worker transfer, never for display.
178
209
  dataJson = JSON.stringify(data);
@@ -187,9 +218,7 @@ export async function runMcpScript(
187
218
  : [];
188
219
  const message = errorCode === "tool_not_found"
189
220
  ? `Tool "${path}" not found. Use await tools.search({ query: "..." }) inside mcpScript.${suggestions.length > 0 ? ` Did you mean: ${suggestions.join(", ")}` : ""}`
190
- : typeof details.message === "string"
191
- ? details.message
192
- : textFromContent(result.content);
221
+ : scriptScopeMessage(errorCode, path, details.server, "tools.call(path, args, { server })") ?? resultErrorMessage(result);
193
222
  calls[index] = { operation: "call", path, ok: false, error: errorCode, durationMs: Date.now() - startedAt, startedAt };
194
223
  return {
195
224
  envelope: { ok: false, error: { code: errorCode, message } },
@@ -209,6 +238,10 @@ export async function runMcpScript(
209
238
  }
210
239
  // Rejected responses do not consume budget. This bounds transfer, not upstream allocation.
211
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
+ }
212
245
  return { dataJson };
213
246
  };
214
247
 
@@ -278,34 +311,27 @@ export async function runMcpScript(
278
311
  const index = calls.push({ operation: "search", query, ok: false, error: "incomplete", durationMs: 0, startedAt }) - 1;
279
312
  let error: unknown;
280
313
  try {
281
- if (input?.searchMode !== undefined && input.searchMode !== "lexical" && input.searchMode !== "semantic") {
282
- error = "invalid_search_mode";
283
- return { items: [], total: 0, hasMore: false, nextOffset: null, error: { code: "invalid_search_mode", message: "Search mode must be lexical or semantic." } };
284
- }
285
- if (query.trim() === "" && input?.searchMode !== "semantic") {
286
- 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) } };
287
331
  }
288
- const server = typeof input?.server === "string" ? input.server : undefined;
289
332
  const limit = typeof input?.limit === "number" ? input.limit : 12;
290
333
  const offset = typeof input?.offset === "number" ? input.offset : 0;
291
- const searchMode = input?.searchMode === "semantic" ? "semantic" : "lexical";
292
- if (searchMode === "semantic" && input?.regex === true) {
293
- error = "invalid_search_mode";
294
- return { items: [], total: 0, hasMore: false, nextOffset: null, error: { code: "invalid_search_mode", message: "Semantic search cannot be combined with regex search." } };
295
- }
296
- const semantic = searchMode === "semantic"
297
- ? await semanticSearch(state, query, server, callSignal, async (semanticState, semanticInput, options) => {
298
- const rejected = admitEvaluation(semanticInput);
299
- if (rejected) return rejected;
300
- const envelope = await (semanticEvaluator ?? evaluateJev)(semanticState, semanticInput, options);
301
- return chargeEvaluationTokens(envelope);
302
- }, [...observedSources])
303
- : undefined;
304
- if (semantic && !semantic.ok) {
305
- error = semantic.error.code;
306
- return { items: [], total: 0, hasMore: false, nextOffset: null, error: semantic.error };
307
- }
308
- const page = paginate(semantic?.matches ?? rankToolMatches(state, query, server), offset, limit);
334
+ const page = paginate(outcome.matches, offset, limit);
309
335
  return {
310
336
  ...page,
311
337
  items: page.items.map(({ server: matchServer, tool, score }) => ({
@@ -315,7 +341,7 @@ export async function runMcpScript(
315
341
  ...(tool.description ? { description: tool.description } : {}),
316
342
  score,
317
343
  })),
318
- ...(semantic ? { backend: semantic.backend } : {}),
344
+ ...(outcome.backend ? { backend: outcome.backend } : {}),
319
345
  };
320
346
  } catch (caught) {
321
347
  error = caught;
@@ -332,35 +358,45 @@ export async function runMcpScript(
332
358
  const path = typeof input?.path === "string" ? input.path : "";
333
359
  let error: unknown;
334
360
  try {
335
- for (const [server, metadata] of state.toolMetadata) {
336
- if (isServerInActiveFailureBackoff(state, server)) continue;
337
- const tool = findToolByName(metadata, path);
338
- if (!tool) continue;
339
- const inputShape = tool.inputSchema ? renderTsShape(tool.inputSchema) : null;
340
- const inputTypeScript = inputShape ?? (tool.inputSchema ? formatSchema(tool.inputSchema) : null);
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;
341
366
  return {
342
- path: tool.name,
343
- name: tool.originalName,
344
- server,
345
- ...(tool.description ? { description: tool.description } : {}),
346
- ...(inputTypeScript ? { inputTypeScript } : {}),
347
- ...(inputShape && hasSchemaDescriptions(tool.inputSchema)
348
- ? { inputGuidance: formatSchema(tool.inputSchema) } : {}),
349
- ...(tool.outputSchema !== undefined ? {
350
- outputSchemaTarget: "data.structuredContent",
351
- outputSchema: tool.outputSchema,
352
- } : {}),
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
+ },
353
375
  };
354
376
  }
355
- const suggestions = path ? rankSuggestions(state, path, 5) : [];
356
- 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);
357
381
  return {
358
- path,
359
- error: {
360
- code: "tool_not_found",
361
- message: `Tool not found: ${path}`,
362
- suggestions,
363
- },
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
+ } : {}),
364
400
  };
365
401
  } catch (caught) {
366
402
  error = caught;
@@ -447,6 +483,8 @@ export async function runMcpScript(
447
483
  rejectOutputBudget();
448
484
  return;
449
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());
450
488
  completed = true;
451
489
  resolve();
452
490
  return;
@@ -460,7 +498,7 @@ export async function runMcpScript(
460
498
  void (async () => {
461
499
  let payload: WorkerResultPayload;
462
500
  if (message.type === "call") {
463
- 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);
464
502
  } else if (message.type === "evaluate") {
465
503
  payload = await evaluate(message.input);
466
504
  } else if (message.type === "search") {
@@ -504,10 +542,30 @@ export async function runMcpScript(
504
542
  await worker?.terminate();
505
543
  }
506
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
+
507
565
  // Snapshot before the asynchronous output guard; the terminated worker can no longer emit.
508
566
  const guarded = await guardMcpOutput(
509
567
  output.length > 0 ? [...output] : [{ type: "text", text: "(no output)" }],
510
- resolveMcpOutputGuardOptions(state.config.settings),
568
+ { ...resolveMcpOutputGuardOptions(state.config.settings), ...(seenFields ? { footer: seenFields } : {}) },
511
569
  );
512
570
  return {
513
571
  content: guarded.content,
@@ -14,6 +14,7 @@ import type { ContentBlock, McpSettings } from "./types.ts";
14
14
  export const DEFAULT_MCP_OUTPUT_MAX_BYTES = DEFAULT_MAX_BYTES;
15
15
  export const DEFAULT_MCP_OUTPUT_MAX_LINES = DEFAULT_MAX_LINES;
16
16
  export const DEFAULT_MCP_DETAILS_MAX_BYTES = 16 * 1024;
17
+ const SCRIPT_PIPE_HINT_MIN_BYTES = 8 * 1024;
17
18
 
18
19
  const CONTENT_SUMMARY_LIMIT = 20;
19
20
  const KEY_PREVIEW_LIMIT = 20;
@@ -63,6 +64,8 @@ export interface McpOutputGuardOptions {
63
64
  enabled?: boolean;
64
65
  prefix?: string;
65
66
  suffix?: string;
67
+ /** Optional guidance appended when it fits the output limits. */
68
+ footer?: string;
66
69
  emptyTextFallback?: string;
67
70
  maxBytes?: number;
68
71
  maxLines?: number;
@@ -93,6 +96,18 @@ export function resolveMcpOutputGuardOptions(settings?: McpSettings): Pick<McpOu
93
96
  };
94
97
  }
95
98
 
99
+ /**
100
+ * Models retype large results into the next call's arguments unless told otherwise at the moment
101
+ * they see the result; the same wording in tool descriptions did not change that.
102
+ */
103
+ export function scriptPipeHint(scriptTool: boolean | undefined, content: ContentBlock[]): { footer?: string } {
104
+ if (scriptTool !== true) return {};
105
+ const bytes = content.reduce((total, block) => total + (block.type === "text" ? byteLength(block.text) : 0), 0);
106
+ return bytes >= SCRIPT_PIPE_HINT_MIN_BYTES
107
+ ? { footer: "\n\n[Use mcpScript to pass this result to another MCP call without copying it through the conversation.]" }
108
+ : {};
109
+ }
110
+
96
111
  /** Spread helper for tool-result details: includes mcpResult/outputGuard only when present. */
97
112
  export function guardedMcpDetails(guarded: GuardedMcpOutput): Record<string, unknown> {
98
113
  return {
@@ -115,6 +130,7 @@ export async function guardMcpOutput(
115
130
  const detailsMaxBytes = options.detailsMaxBytes ?? DEFAULT_MCP_DETAILS_MAX_BYTES;
116
131
  const prefix = options.prefix ?? "";
117
132
  const suffix = options.suffix ?? "";
133
+ const footer = options.footer ?? "";
118
134
 
119
135
  const normalizedContent = withEmptyTextFallback(
120
136
  content.length > 0
@@ -125,7 +141,7 @@ export async function guardMcpOutput(
125
141
 
126
142
  if (options.enabled === false) {
127
143
  return {
128
- content: addAffixes(normalizedContent, prefix, suffix),
144
+ content: addAffixes(normalizedContent, prefix, `${suffix}${footer}`),
129
145
  ...(options.rawMcpResult !== undefined ? { mcpResult: options.rawMcpResult } : {}),
130
146
  };
131
147
  }
@@ -136,15 +152,18 @@ export async function guardMcpOutput(
136
152
  .map((block) => (block as { text: string }).text)
137
153
  .join("\n");
138
154
  const composedOutput = `${prefix}${textOutput}${suffix}`;
139
- const truncation = truncateHead(composedOutput, { maxBytes, maxLines });
155
+ const truncation = truncateHead(`${composedOutput}${footer}`, { maxBytes, maxLines });
140
156
 
141
- let guardedContent: ContentBlock[] = addAffixes(normalizedContent, prefix, suffix);
157
+ let guardedContent: ContentBlock[] = addAffixes(normalizedContent, prefix, `${suffix}${footer}`);
142
158
  let outputGuard: McpOutputGuardDetails | undefined;
143
159
 
144
160
  if (truncation.truncated) {
145
161
  const { path: fullOutputPath, error: writeError } = await saveArtifact("output", composedOutput);
146
162
  const initialNotice = formatTruncationNotice(truncation, fullOutputPath, writeError);
147
- const previewBudget = reserveBudget(maxBytes, maxLines, initialNotice);
163
+ // Footers are optional guidance: drop one that would not fit beside the notice rather than exceed the limits.
164
+ const noticeWithFooter = textStats(`\n\n${initialNotice}${footer}`);
165
+ const keptFooter = noticeWithFooter.bytes < maxBytes && noticeWithFooter.lines < maxLines ? footer : "";
166
+ const previewBudget = reserveBudget(maxBytes, maxLines, `${initialNotice}${keptFooter}`);
148
167
  const preview = truncateHead(composedOutput, {
149
168
  maxBytes: previewBudget.maxBytes,
150
169
  maxLines: previewBudget.maxLines,
@@ -154,7 +173,7 @@ export async function guardMcpOutput(
154
173
  fullOutputPath,
155
174
  writeError,
156
175
  );
157
- const finalText = `${preview.content}\n\n${notice}`;
176
+ const finalText = `${preview.content}\n\n${notice}${keptFooter}`;
158
177
  const finalStats = textStats(finalText);
159
178
 
160
179
  guardedContent = [{ type: "text" as const, text: finalText }, ...imageBlocks];
@@ -129,11 +129,15 @@ const PRELUDE_SOURCE = `(function (bridge) {
129
129
  get(_target, property) {
130
130
  if (property === "search") return (input) => request("search", { input });
131
131
  if (property === "describe") return (input) => request("describe", { input });
132
- if (property === "call") return (path, args) => {
132
+ if (property === "call") return (path, args, options) => {
133
133
  if (typeof path !== "string" || path.trim() === "") {
134
134
  return Promise.resolve({ ok: false, error: { code: "invalid_tool_path", message: "tools.call(path, args) requires a non-empty tool path." } });
135
135
  }
136
- return request("call", { path, args });
136
+ const server = options?.server;
137
+ if (server !== undefined && (typeof server !== "string" || server.trim() === "")) {
138
+ return Promise.resolve({ ok: false, error: { code: "invalid_tool_server", message: "tools.call(path, args, { server }) requires a non-empty server name." } });
139
+ }
140
+ return request("call", server === undefined ? { path, args } : { path, args, server });
137
141
  };
138
142
  if (typeof property !== "string" || reserved.has(property)) return undefined;
139
143
  return (args) => request("call", { path: property, args });