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.
- package/CHANGELOG.md +80 -0
- package/OAUTH.md +4 -2
- package/README.md +72 -10
- package/cli.js +1 -0
- package/commands.ts +20 -2
- package/config.ts +19 -1
- package/direct-tool-surface.ts +3 -2
- package/direct-tools.ts +12 -2
- package/dist/config.d.ts +6 -0
- package/dist/config.js +15 -0
- package/dist/config.js.map +1 -1
- package/dist/elicitation-handler.d.ts +9 -1
- package/dist/elicitation-handler.js +65 -2
- package/dist/elicitation-handler.js.map +1 -1
- package/dist/jev-client.js +7 -5
- package/dist/jev-client.js.map +1 -1
- package/dist/jev-key-store.d.ts +1 -0
- package/dist/jev-key-store.js +11 -0
- package/dist/jev-key-store.js.map +1 -1
- package/dist/mcp-auth-flow.d.ts +2 -0
- package/dist/mcp-auth-flow.js +12 -3
- package/dist/mcp-auth-flow.js.map +1 -1
- package/dist/metadata-cache.d.ts +12 -0
- package/dist/metadata-cache.js +50 -6
- package/dist/metadata-cache.js.map +1 -1
- package/dist/output-shape.d.ts +36 -0
- package/dist/output-shape.js +338 -0
- package/dist/output-shape.js.map +1 -0
- package/dist/package-mcp-loader.js +9 -5
- package/dist/package-mcp-loader.js.map +1 -1
- package/dist/server-manager.js +27 -16
- package/dist/server-manager.js.map +1 -1
- package/dist/state.d.ts +6 -0
- package/dist/ts-shape.d.ts +3 -0
- package/dist/ts-shape.js +153 -0
- package/dist/ts-shape.js.map +1 -0
- package/dist/types.d.ts +24 -3
- package/dist/types.js.map +1 -1
- package/dist/unix-socket-transport.js +1 -1
- package/dist/unix-socket-transport.js.map +1 -1
- package/dist/utils.d.ts +6 -1
- package/dist/utils.js +17 -0
- package/dist/utils.js.map +1 -1
- package/elicitation-handler.ts +78 -1
- package/index.ts +72 -12
- package/init.ts +20 -5
- package/jev-client.ts +7 -4
- package/jev-key-store.ts +7 -0
- package/mcp-auth-flow.ts +13 -2
- package/mcp-code.ts +126 -68
- package/mcp-output-guard.ts +24 -5
- package/mcp-script-worker.mjs +6 -2
- package/mcp-setup-panel.ts +583 -302
- package/metadata-cache.ts +60 -5
- package/output-shape.ts +348 -0
- package/package-mcp-loader.ts +8 -5
- package/package.json +6 -6
- package/prompts.ts +68 -0
- package/proxy-modes.ts +149 -106
- package/server-manager.ts +28 -15
- package/skills/mcp-scripting/SKILL.md +24 -28
- package/skills/mcp-scripting/references/jev.md +3 -0
- package/state.ts +6 -0
- package/tool-approval.ts +17 -4
- package/tool-metadata.ts +3 -1
- package/ts-shape.ts +1 -1
- package/types.ts +22 -3
- package/ui-server.ts +5 -2
- package/unix-socket-transport.ts +1 -1
- 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
|
-
?
|
|
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
|
-
|
|
612
|
-
|
|
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
|
-
...(
|
|
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
|
|
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
|
|
12
|
-
import {
|
|
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 {
|
|
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
|
|
100
|
-
|
|
101
|
-
|
|
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
|
|
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,
|
|
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
|
-
:
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
|
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
|
-
...(
|
|
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
|
-
|
|
336
|
-
|
|
337
|
-
const
|
|
338
|
-
|
|
339
|
-
|
|
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
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
|
356
|
-
|
|
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
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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,
|
package/mcp-output-guard.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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];
|
package/mcp-script-worker.mjs
CHANGED
|
@@ -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
|
-
|
|
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 });
|