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/CHANGELOG.md +51 -0
- package/OAUTH.md +4 -2
- package/README.md +61 -9
- package/commands.ts +20 -2
- package/config.ts +19 -1
- package/direct-tool-surface.ts +3 -2
- package/direct-tools.ts +5 -3
- package/dist/config.d.ts +6 -0
- package/dist/config.js +15 -0
- package/dist/config.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 +45 -5
- package/dist/metadata-cache.js.map +1 -1
- package/dist/output-shape.d.ts +18 -5
- package/dist/output-shape.js +162 -18
- package/dist/output-shape.js.map +1 -1
- 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 +2 -0
- package/dist/types.d.ts +11 -1
- package/dist/types.js.map +1 -1
- package/index.ts +72 -12
- package/init.ts +20 -5
- package/mcp-auth-flow.ts +13 -2
- package/mcp-code.ts +125 -77
- 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 +55 -5
- package/output-shape.ts +161 -22
- package/package-mcp-loader.ts +8 -5
- package/package.json +5 -5
- package/proxy-modes.ts +125 -109
- package/server-manager.ts +28 -15
- package/skills/mcp-scripting/SKILL.md +24 -30
- package/skills/mcp-scripting/references/jev.md +3 -0
- package/state.ts +2 -0
- package/types.ts +8 -1
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 === "
|
|
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:
|
|
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 (
|
|
1075
|
-
|
|
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 (
|
|
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
|
|
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.
|
|
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
|
-
?
|
|
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/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
|
|
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";
|
|
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
|
|
101
|
-
|
|
102
|
-
|
|
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
|
|
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,
|
|
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
|
-
:
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
|
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
|
-
...(
|
|
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
|
-
|
|
337
|
-
|
|
338
|
-
const
|
|
339
|
-
|
|
340
|
-
|
|
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
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
|
366
|
-
|
|
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
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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,
|