@fitchmultz/pi-mcp-adapter 5.0.0 → 6.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -1
- package/OAUTH.md +3 -3
- package/README.md +153 -85
- package/app-bridge.bundle.js +75 -33
- package/commands.ts +9 -6
- package/direct-tools.ts +25 -358
- package/dist/app-bridge.bundle.js +75 -33
- package/dist/commands.js +9 -6
- package/dist/direct-tools.d.ts +2 -4
- package/dist/direct-tools.js +21 -319
- package/dist/gateway-arguments.d.ts +33 -0
- package/dist/gateway-arguments.js +63 -0
- package/dist/index.js +181 -276
- package/dist/init.js +6 -20
- package/dist/mcp-code.d.ts +2 -1
- package/dist/mcp-code.js +87 -41
- package/dist/mcp-output-guard.d.ts +57 -2
- package/dist/mcp-output-guard.js +129 -24
- package/dist/mcp-panel.d.ts +1 -1
- package/dist/mcp-panel.js +34 -97
- package/dist/mcp-script-worker.mjs +5 -2
- package/dist/mcp-status.js +2 -1
- package/dist/metadata-cache.d.ts +1 -1
- package/dist/metadata-cache.js +25 -88
- package/dist/prompts.d.ts +1 -1
- package/dist/prompts.js +1 -1
- package/dist/proxy-modes.d.ts +40 -6
- package/dist/proxy-modes.js +270 -485
- package/dist/resource-tools.d.ts +15 -0
- package/dist/resource-tools.js +26 -1
- package/dist/sampling-handler.js +4 -1
- package/dist/search-ranking.d.ts +0 -2
- package/dist/search-ranking.js +66 -137
- package/dist/state.d.ts +1 -1
- package/dist/tool-loader.d.ts +32 -0
- package/dist/tool-loader.js +152 -0
- package/dist/tool-metadata.d.ts +67 -0
- package/dist/tool-metadata.js +23 -17
- package/dist/tool-registrar.d.ts +4 -4
- package/dist/tool-registrar.js +18 -13
- package/dist/tool-result-renderer.d.ts +3 -0
- package/dist/tool-result-renderer.js +8 -3
- package/dist/types.d.ts +11 -36
- package/dist/utils.js +2 -4
- package/gateway-arguments.ts +73 -0
- package/index.ts +166 -299
- package/init.ts +6 -20
- package/mcp-code.ts +71 -45
- package/mcp-output-guard.ts +145 -27
- package/mcp-panel.ts +38 -109
- package/mcp-script-worker.mjs +5 -2
- package/mcp-status.ts +2 -1
- package/metadata-cache.ts +31 -93
- package/package.json +14 -9
- package/prompts.ts +1 -1
- package/proxy-modes.ts +265 -517
- package/resource-tools.ts +36 -1
- package/sampling-handler.ts +4 -1
- package/scripts/build-app-bridge.mjs +15 -0
- package/scripts/build.mjs +3 -2
- package/scripts/prepare.mjs +1 -0
- package/search-ranking.ts +71 -135
- package/skills/mcp-scripting/SKILL.md +67 -20
- package/state.ts +1 -1
- package/tool-loader.ts +153 -0
- package/tool-metadata.ts +22 -17
- package/tool-registrar.ts +18 -13
- package/tool-result-renderer.ts +10 -2
- package/types.ts +15 -40
- package/utils.ts +2 -4
- package/dist/ts-shape.d.ts +0 -2
- package/dist/ts-shape.js +0 -147
- package/ts-shape.ts +0 -145
package/init.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { McpExtensionState } from "./state.ts";
|
|
3
3
|
import { isServerDisabled, type McpAdapterOptions, type McpConfig, type PromptMetadata, type ToolMetadata } from "./types.ts";
|
|
4
|
-
import { existsSync } from "node:fs";
|
|
5
4
|
import { cloneMcpConfig, isPathInsideProject, loadMcpConfig } from "./config.ts";
|
|
6
5
|
import { ConsentManager } from "./consent-manager.ts";
|
|
7
6
|
import { McpLifecycleManager } from "./lifecycle.ts";
|
|
@@ -214,18 +213,7 @@ export async function initializeMcp(
|
|
|
214
213
|
const idleSetting = typeof config.settings?.idleTimeout === "number" ? config.settings.idleTimeout : 10;
|
|
215
214
|
lifecycle.setGlobalIdleTimeout(idleSetting);
|
|
216
215
|
|
|
217
|
-
const
|
|
218
|
-
const cacheFileExists = metadataCacheEnabled && existsSync(cachePath);
|
|
219
|
-
let cache = loadMetadataCache(metadataCacheEnabled);
|
|
220
|
-
let bootstrapAll = false;
|
|
221
|
-
|
|
222
|
-
if (!cacheFileExists) {
|
|
223
|
-
bootstrapAll = true;
|
|
224
|
-
saveMetadataCache({ version: 1, servers: {} }, metadataCacheEnabled);
|
|
225
|
-
} else if (!cache) {
|
|
226
|
-
cache = { version: 1, servers: {} };
|
|
227
|
-
saveMetadataCache(cache, metadataCacheEnabled);
|
|
228
|
-
}
|
|
216
|
+
const cache = loadMetadataCache(metadataCacheEnabled);
|
|
229
217
|
|
|
230
218
|
const prefix = config.settings?.toolPrefix ?? "server";
|
|
231
219
|
|
|
@@ -258,12 +246,10 @@ export async function initializeMcp(
|
|
|
258
246
|
}
|
|
259
247
|
}
|
|
260
248
|
|
|
261
|
-
const startupServers =
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
return mode === "keep-alive" || mode === "eager";
|
|
266
|
-
});
|
|
249
|
+
const startupServers = serverEntries.filter(([, definition]) => {
|
|
250
|
+
const mode = definition.lifecycle ?? "lazy";
|
|
251
|
+
return mode === "keep-alive" || mode === "eager";
|
|
252
|
+
});
|
|
267
253
|
|
|
268
254
|
if (ui && startupServers.length > 0) {
|
|
269
255
|
const status = formatMcpStatus(state.config, `connecting to ${startupServers.length} servers...`);
|
|
@@ -376,7 +362,7 @@ export async function initializeMcp(
|
|
|
376
362
|
const bootstrapped = bootstrapResults.filter(r => r.ok).map(r => r.name);
|
|
377
363
|
owner.throwIfInactive();
|
|
378
364
|
if (bootstrapped.length > 0 && ui) {
|
|
379
|
-
ui.notify(`MCP:
|
|
365
|
+
ui.notify(`MCP: tools discovered for ${bootstrapped.join(", ")}`, "info");
|
|
380
366
|
}
|
|
381
367
|
}
|
|
382
368
|
}
|
package/mcp-code.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import type { ToolInfo } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { formatWithOptions } from "node:util";
|
|
3
3
|
import { Worker } from "node:worker_threads";
|
|
4
|
-
import { guardMcpOutput, guardedMcpDetails, resolveMcpOutputGuardOptions } from "./mcp-output-guard.ts";
|
|
5
|
-
import { executeCall } from "./proxy-modes.ts";
|
|
4
|
+
import { guardMcpOutput, guardedMcpDetails, resolveMcpOutputGuardOptions, readMcpResult, formatMcpResultReference, formatMcpPayloadFile, type McpPayloadFile, type ReadMcpResultInput } from "./mcp-output-guard.ts";
|
|
5
|
+
import { executeCall, executeConnect } from "./proxy-modes.ts";
|
|
6
|
+
import { executeResourceList, executeResourceRead } from "./resource-tools.ts";
|
|
6
7
|
import { combineAbortSignals } from "./runtime-owner.ts";
|
|
7
8
|
import { paginate, rankSuggestions, rankToolMatches } from "./search-ranking.ts";
|
|
8
9
|
import type { McpExtensionState } from "./state.ts";
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import type { ContentBlock, McpOperationContext } from "./types.ts";
|
|
10
|
+
import { catalogCoverage, toToolDescriptor } from "./tool-metadata.ts";
|
|
11
|
+
import { isServerDisabled, type ContentBlock, type McpOperationContext } from "./types.ts";
|
|
12
12
|
|
|
13
13
|
export const DEFAULT_MCP_SCRIPT_TIMEOUT_MS = 30_000;
|
|
14
14
|
|
|
@@ -20,12 +20,12 @@ class McpScriptTimeoutError extends Error {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
type SearchInput = { query?: unknown; server?: unknown; limit?: unknown; offset?: unknown };
|
|
23
|
-
type DescribeInput = { path?: unknown };
|
|
23
|
+
type DescribeInput = { path?: unknown; server?: unknown };
|
|
24
24
|
type WorkerMessage =
|
|
25
25
|
| { type: "emit"; block: unknown }
|
|
26
|
-
| { type: "call"; id: number; path: string; args?: unknown }
|
|
26
|
+
| { type: "call"; id: number; path: string; args?: unknown; server?: string }
|
|
27
27
|
| { type: "search"; id: number; input?: unknown }
|
|
28
|
-
| { type: "describe"; id: number; input?: unknown }
|
|
28
|
+
| { type: "describe" | "resources" | "readResource" | "readResult"; id: number; input?: unknown }
|
|
29
29
|
| { type: "done"; returnBlock?: unknown }
|
|
30
30
|
| { type: "error"; message: string };
|
|
31
31
|
|
|
@@ -86,11 +86,9 @@ function parseWorkerMessage(value: unknown): WorkerMessage | null {
|
|
|
86
86
|
const message = value as Record<string, unknown>;
|
|
87
87
|
if (message.type === "emit" && "block" in message) return { type: "emit", block: message.block };
|
|
88
88
|
if (message.type === "call" && typeof message.id === "number" && typeof message.path === "string") {
|
|
89
|
-
return "args"
|
|
90
|
-
? { type: "call", id: message.id, path: message.path, args: message.args }
|
|
91
|
-
: { type: "call", id: message.id, path: message.path };
|
|
89
|
+
return { type: "call", id: message.id, path: message.path, ...(message.args !== undefined ? { args: message.args } : {}), ...(typeof message.server === "string" ? { server: message.server } : {}) };
|
|
92
90
|
}
|
|
93
|
-
if ((message.type === "search" || message.type === "describe") && typeof message.id === "number") {
|
|
91
|
+
if ((message.type === "search" || message.type === "describe" || message.type === "resources" || message.type === "readResource" || message.type === "readResult") && typeof message.id === "number") {
|
|
94
92
|
return "input" in message
|
|
95
93
|
? { type: message.type, id: message.id, input: message.input }
|
|
96
94
|
: { type: message.type, id: message.id };
|
|
@@ -151,17 +149,17 @@ async function runMcpScriptOperation(
|
|
|
151
149
|
: operation.durationMs,
|
|
152
150
|
}));
|
|
153
151
|
let callsSnapshot: ScriptOperation[] | undefined;
|
|
154
|
-
const
|
|
152
|
+
const resultRefs = new Set<string>();
|
|
153
|
+
const payloadNotices = new Set<string>();
|
|
154
|
+
const recordCall = async (path: string, dispatch: () => ReturnType<typeof executeCall>) => {
|
|
155
155
|
// Record before dispatch so calls still in flight at timeout/abort appear in the trace.
|
|
156
156
|
const startedAt = Date.now();
|
|
157
157
|
const index = calls.push({ operation: "call", path, ok: false, error: "incomplete", durationMs: 0, startedAt }) - 1;
|
|
158
|
-
|
|
159
|
-
let hasRawResult = false;
|
|
160
|
-
const result = await executeCall(state, path, args, undefined, getPiTools, callSignal, (raw) => {
|
|
161
|
-
rawResult = raw;
|
|
162
|
-
hasRawResult = true;
|
|
163
|
-
}, { ...(toolCallId !== undefined ? { toolCallId } : {}), innerCallId }, beforeDispatch);
|
|
158
|
+
const result = await dispatch();
|
|
164
159
|
const details = result.details;
|
|
160
|
+
if (typeof details.resultRef === "string") resultRefs.add(details.resultRef);
|
|
161
|
+
if (typeof details.resultWriteError === "string") payloadNotices.add(`[MCP result could not be saved: ${details.resultWriteError}]`);
|
|
162
|
+
for (const file of (details.payloadFiles ?? []) as McpPayloadFile[]) payloadNotices.add(formatMcpPayloadFile(file));
|
|
165
163
|
if (details.error !== undefined) {
|
|
166
164
|
const errorCode = String(details.error);
|
|
167
165
|
const suggestions = Array.isArray(details.suggestions)
|
|
@@ -180,30 +178,45 @@ async function runMcpScriptOperation(
|
|
|
180
178
|
if (errorCode === "ambiguous_outcome") output.push({ type: "text", text: message });
|
|
181
179
|
return {
|
|
182
180
|
ok: false as const,
|
|
181
|
+
...(details.mcpResult !== undefined ? { data: details.mcpResult } : {}),
|
|
182
|
+
...(details.resultRef ? { resultRef: details.resultRef } : {}),
|
|
183
183
|
error: { code: errorCode, message, ...(details.recovery ? { recovery: details.recovery } : {}) },
|
|
184
184
|
};
|
|
185
185
|
}
|
|
186
186
|
calls[index] = { operation: "call", path, ok: true, durationMs: Date.now() - startedAt, startedAt };
|
|
187
187
|
return {
|
|
188
188
|
ok: true as const,
|
|
189
|
-
data:
|
|
189
|
+
data: details.mcpResult !== undefined ? details.mcpResult : textFromContent(result.content),
|
|
190
|
+
...(details.resultRef ? { resultRef: details.resultRef } : {}),
|
|
190
191
|
};
|
|
191
192
|
};
|
|
192
193
|
|
|
193
|
-
const
|
|
194
|
+
const callIdentity = (innerCallId: number) => ({ ...(toolCallId !== undefined ? { toolCallId } : {}), innerCallId });
|
|
195
|
+
const callTool = (innerCallId: number, path: string, args?: Record<string, unknown>, server?: string) => recordCall(path,
|
|
196
|
+
() => executeCall(state, path, args, server, getPiTools, callSignal, callIdentity(innerCallId), beforeDispatch, { raw: true }));
|
|
197
|
+
|
|
198
|
+
const discoverServer = async (server?: string) => {
|
|
199
|
+
if (!server) return undefined;
|
|
200
|
+
if (isServerDisabled(state.config.mcpServers[server])) return { code: "server_disabled", message: `Server "${server}" is disabled.` };
|
|
201
|
+
if (state.config.mcpServers[server] && state.toolMetadata.has(server)) return undefined;
|
|
202
|
+
const result = await executeConnect(state, server, callSignal);
|
|
203
|
+
return result.details.error ? { code: result.details.error, message: textFromContent(result.content) } : undefined;
|
|
204
|
+
};
|
|
205
|
+
const searchTools = async (input?: SearchInput) => {
|
|
194
206
|
const startedAt = Date.now();
|
|
195
207
|
const query = typeof input?.query === "string" ? input.query : "";
|
|
196
208
|
let error: unknown;
|
|
197
209
|
try {
|
|
198
|
-
if (query.trim() === "") {
|
|
199
|
-
return { items: [], total: 0, hasMore: false, nextOffset: null };
|
|
200
|
-
}
|
|
201
210
|
const server = typeof input?.server === "string" ? input.server : undefined;
|
|
211
|
+
const discoveryError = await discoverServer(server);
|
|
212
|
+
const coverage = catalogCoverage(state, server);
|
|
213
|
+
if (discoveryError) { error = discoveryError.code; return { error: discoveryError, coverage }; }
|
|
214
|
+
if (query.trim() === "") return { items: [], total: 0, hasMore: false, nextOffset: null, coverage };
|
|
202
215
|
const limit = typeof input?.limit === "number" ? input.limit : 12;
|
|
203
216
|
const offset = typeof input?.offset === "number" ? input.offset : 0;
|
|
204
|
-
const page = paginate(rankToolMatches(state, query, server), offset, limit);
|
|
217
|
+
const page = paginate(rankToolMatches(state, query, server).filter(match => !match.tool.resourceUri), offset, limit);
|
|
205
218
|
return {
|
|
206
|
-
...page,
|
|
219
|
+
...page, coverage,
|
|
207
220
|
items: page.items.map(({ server: matchServer, tool, score }) => ({
|
|
208
221
|
path: tool.name,
|
|
209
222
|
name: tool.originalName,
|
|
@@ -222,25 +235,23 @@ async function runMcpScriptOperation(
|
|
|
222
235
|
}
|
|
223
236
|
};
|
|
224
237
|
|
|
225
|
-
const describeTool = (input?: DescribeInput) => {
|
|
238
|
+
const describeTool = async (input?: DescribeInput) => {
|
|
226
239
|
const startedAt = Date.now();
|
|
227
240
|
const path = typeof input?.path === "string" ? input.path : "";
|
|
228
241
|
let error: unknown;
|
|
229
242
|
try {
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
return
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
}
|
|
243
|
-
const suggestions = path ? rankSuggestions(state, path, 5) : [];
|
|
243
|
+
const selectedServer = typeof input?.server === "string" ? input.server : undefined;
|
|
244
|
+
const discoveryError = await discoverServer(selectedServer);
|
|
245
|
+
if (discoveryError) { error = discoveryError.code; return { path, error: discoveryError }; }
|
|
246
|
+
const matches = [...state.toolMetadata].flatMap(([server, metadata]) => {
|
|
247
|
+
if ((selectedServer && server !== selectedServer) || !state.config.mcpServers[server] || isServerDisabled(state.config.mcpServers[server])) return [];
|
|
248
|
+
const tools = metadata.filter(tool => !tool.resourceUri);
|
|
249
|
+
const originals = selectedServer ? tools.filter(tool => tool.originalName === path) : [];
|
|
250
|
+
return (originals.length ? originals : tools.filter(tool => tool.name === path)).map(tool => toToolDescriptor(server, tool));
|
|
251
|
+
});
|
|
252
|
+
if (matches.length === 1) return matches[0];
|
|
253
|
+
if (matches.length > 1) { error = "ambiguous_tool"; return { path, error: { code: "ambiguous_tool", message: "Specify server and exact tool name." } }; }
|
|
254
|
+
const suggestions = path ? rankSuggestions(state, path, 5, selectedServer) : [];
|
|
244
255
|
error = "tool_not_found";
|
|
245
256
|
return {
|
|
246
257
|
path,
|
|
@@ -301,11 +312,25 @@ async function runMcpScriptOperation(
|
|
|
301
312
|
void (async () => {
|
|
302
313
|
let envelope: unknown;
|
|
303
314
|
if (message.type === "call") {
|
|
304
|
-
envelope = await callTool(message.id, message.path, message.args as Record<string, unknown> | undefined);
|
|
315
|
+
envelope = await callTool(message.id, message.path, message.args as Record<string, unknown> | undefined, message.server);
|
|
305
316
|
} else if (message.type === "search") {
|
|
306
|
-
envelope = searchTools(message.input as SearchInput | undefined);
|
|
317
|
+
envelope = await searchTools(message.input as SearchInput | undefined);
|
|
318
|
+
} else if (message.type === "describe") {
|
|
319
|
+
envelope = await describeTool(message.input as DescribeInput | undefined);
|
|
320
|
+
} else if (message.type === "readResult") {
|
|
321
|
+
envelope = await readMcpResult(message.input as ReadMcpResultInput, resolveMcpOutputGuardOptions(state.config.settings, state.outputDirectory));
|
|
307
322
|
} else {
|
|
308
|
-
|
|
323
|
+
const input = message.input as { server?: unknown; uri?: unknown; limit?: unknown; offset?: unknown } | undefined;
|
|
324
|
+
if (typeof input?.server !== "string" || !input.server || (message.type === "readResource" && (typeof input.uri !== "string" || !input.uri))) {
|
|
325
|
+
envelope = { error: { code: "invalid_arguments", message: "Specify server and, for reads, resource uri." } };
|
|
326
|
+
} else if (message.type === "resources") {
|
|
327
|
+
const result = await executeResourceList(state, input.server, typeof input.limit === "number" ? input.limit : 12, typeof input.offset === "number" ? input.offset : 0, callSignal);
|
|
328
|
+
envelope = result.details.error ? { error: { code: result.details.error, message: textFromContent(result.content) } } : result.details;
|
|
329
|
+
} else {
|
|
330
|
+
const server = input.server;
|
|
331
|
+
const uri = input.uri as string;
|
|
332
|
+
envelope = await recordCall(`${server}:${uri}`, () => executeResourceRead(state, server, uri, callSignal, callIdentity(message.id), beforeDispatch, true));
|
|
333
|
+
}
|
|
309
334
|
}
|
|
310
335
|
const response: WorkerResultMessage = { type: "result", id: message.id, envelope };
|
|
311
336
|
activeWorker.postMessage(response);
|
|
@@ -369,13 +394,14 @@ async function runMcpScriptOperation(
|
|
|
369
394
|
|
|
370
395
|
// Snapshot before the asynchronous output guard; the terminated worker can no longer emit.
|
|
371
396
|
const guarded = await guardMcpOutput(
|
|
372
|
-
output.length > 0 ?
|
|
397
|
+
[...(output.length > 0 ? output : [{ type: "text" as const, text: "(no output)" }]), ...[...payloadNotices].map(text => ({ type: "text" as const, text })), ...[...resultRefs].map(ref => ({ type: "text" as const, text: formatMcpResultReference(ref) }))],
|
|
373
398
|
resolveMcpOutputGuardOptions(state.config.settings, state.outputDirectory),
|
|
374
399
|
);
|
|
375
400
|
return {
|
|
376
401
|
content: guarded.content,
|
|
377
402
|
details: {
|
|
378
403
|
mode: "script",
|
|
404
|
+
...(resultRefs.size ? { resultRefs: [...resultRefs] } : {}),
|
|
379
405
|
...(errorCode ? { error: errorCode, message: errorMessage } : {}),
|
|
380
406
|
timeoutMs: resolvedTimeoutMs,
|
|
381
407
|
...(recovery !== undefined ? { recovery } : {}),
|
package/mcp-output-guard.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
|
-
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
|
|
2
|
+
import { mkdir, mkdtemp, readFile, realpath, writeFile } from "node:fs/promises";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
|
-
import { join, resolve } from "node:path";
|
|
4
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
5
5
|
import type { ContentBlock, McpSettings } from "./types.ts";
|
|
6
6
|
|
|
7
7
|
export const DEFAULT_MCP_OUTPUT_MAX_BYTES = 50 * 1024;
|
|
@@ -52,17 +52,31 @@ export interface McpOutputGuardOptions {
|
|
|
52
52
|
/**
|
|
53
53
|
* Raw MCP result to expose as details.mcpResult. Kept raw when its JSON
|
|
54
54
|
* fits detailsMaxBytes (or when the guard is disabled); otherwise replaced
|
|
55
|
-
* with a compact summary and spilled to a
|
|
56
|
-
*
|
|
55
|
+
* with a compact summary and spilled to a file. Payloads that are not fully
|
|
56
|
+
* visible as plain text also get a model-visible reference for local readback.
|
|
57
57
|
*/
|
|
58
58
|
rawMcpResult?: unknown;
|
|
59
|
+
retainedMcpResult?: RetainedMcpResult;
|
|
59
60
|
}
|
|
60
61
|
|
|
61
62
|
export interface GuardedMcpOutput {
|
|
62
63
|
content: ContentBlock[];
|
|
63
64
|
outputGuard?: McpOutputGuardDetails;
|
|
64
65
|
mcpResult?: unknown;
|
|
66
|
+
resultRef?: string;
|
|
67
|
+
resultWriteError?: string;
|
|
68
|
+
payloadFiles?: McpPayloadFile[];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface McpPayloadFile {
|
|
72
|
+
index: number;
|
|
73
|
+
kind: "audio" | "resource";
|
|
74
|
+
uri?: string;
|
|
75
|
+
mimeType?: string;
|
|
76
|
+
path?: string;
|
|
77
|
+
error?: string;
|
|
65
78
|
}
|
|
79
|
+
export type RetainedMcpResult = Pick<GuardedMcpOutput, "mcpResult" | "resultRef" | "resultWriteError" | "payloadFiles">;
|
|
66
80
|
|
|
67
81
|
export function resolveMcpOutputGuardOptions(settings?: McpSettings, outputDirectory?: string): Pick<McpOutputGuardOptions, "enabled" | "maxBytes" | "maxLines" | "detailsMaxBytes" | "outputDirectory"> {
|
|
68
82
|
const configured = settings?.outputGuard;
|
|
@@ -81,6 +95,9 @@ export function guardedMcpDetails(guarded: GuardedMcpOutput): Record<string, unk
|
|
|
81
95
|
return {
|
|
82
96
|
...(guarded.mcpResult !== undefined ? { mcpResult: guarded.mcpResult } : {}),
|
|
83
97
|
...(guarded.outputGuard ? { outputGuard: guarded.outputGuard } : {}),
|
|
98
|
+
...(guarded.resultRef ? { resultRef: guarded.resultRef } : {}),
|
|
99
|
+
...(guarded.resultWriteError ? { resultWriteError: guarded.resultWriteError } : {}),
|
|
100
|
+
...(guarded.payloadFiles?.length ? { payloadFiles: guarded.payloadFiles } : {}),
|
|
84
101
|
};
|
|
85
102
|
}
|
|
86
103
|
|
|
@@ -95,7 +112,6 @@ export async function guardMcpOutput(
|
|
|
95
112
|
): Promise<GuardedMcpOutput> {
|
|
96
113
|
const maxBytes = options.maxBytes ?? DEFAULT_MCP_OUTPUT_MAX_BYTES;
|
|
97
114
|
const maxLines = options.maxLines ?? DEFAULT_MCP_OUTPUT_MAX_LINES;
|
|
98
|
-
const detailsMaxBytes = options.detailsMaxBytes ?? DEFAULT_MCP_DETAILS_MAX_BYTES;
|
|
99
115
|
const prefix = options.prefix ?? "";
|
|
100
116
|
const suffix = options.suffix ?? "";
|
|
101
117
|
|
|
@@ -106,12 +122,11 @@ export async function guardMcpOutput(
|
|
|
106
122
|
options.emptyTextFallback,
|
|
107
123
|
);
|
|
108
124
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
}
|
|
125
|
+
const retained = options.retainedMcpResult ?? (options.rawMcpResult === undefined ? {} : await retainMcpResult(options.rawMcpResult, options));
|
|
126
|
+
const reference = retained.resultRef ? formatMcpResultReference(retained.resultRef)
|
|
127
|
+
: retained.resultWriteError ? `[MCP result could not be saved: ${retained.resultWriteError}]` : undefined;
|
|
128
|
+
const withReference = (blocks: ContentBlock[]) => reference ? [...blocks, { type: "text" as const, text: reference }] : blocks;
|
|
129
|
+
if (options.enabled === false) return { content: withReference(addAffixes(normalizedContent, prefix, suffix)), ...retained };
|
|
115
130
|
|
|
116
131
|
const imageBlocks = normalizedContent.filter((block) => block.type === "image");
|
|
117
132
|
const textOutput = normalizedContent
|
|
@@ -120,17 +135,19 @@ export async function guardMcpOutput(
|
|
|
120
135
|
.join("\n");
|
|
121
136
|
const composedOutput = `${prefix}${textOutput}${suffix}`;
|
|
122
137
|
const stats = textStats(composedOutput);
|
|
138
|
+
const referenceSuffix = reference ? `\n${reference}` : "";
|
|
139
|
+
const totalStats = textStats(composedOutput ? composedOutput + referenceSuffix : reference ?? "");
|
|
123
140
|
|
|
124
141
|
let guardedContent: ContentBlock[] = addAffixes(normalizedContent, prefix, suffix);
|
|
125
142
|
let outputGuard: McpOutputGuardDetails | undefined;
|
|
126
143
|
|
|
127
|
-
if (
|
|
144
|
+
if (totalStats.bytes > maxBytes || totalStats.lines > maxLines) {
|
|
128
145
|
const { path: fullOutputPath, error: writeError } = await saveArtifact("output", composedOutput, options.outputDirectory);
|
|
129
146
|
const notice = formatTruncationNotice(stats, fullOutputPath, writeError);
|
|
130
|
-
const previewBudget = reserveBudget(maxBytes, maxLines, notice);
|
|
147
|
+
const previewBudget = reserveBudget(maxBytes, maxLines, notice + referenceSuffix);
|
|
131
148
|
const preview = truncateHead(composedOutput, previewBudget.maxBytes, previewBudget.maxLines);
|
|
132
149
|
const finalText = `${preview.content}\n\n${notice}`;
|
|
133
|
-
const finalStats = textStats(finalText);
|
|
150
|
+
const finalStats = textStats(finalText + referenceSuffix);
|
|
134
151
|
|
|
135
152
|
guardedContent = [{ type: "text" as const, text: finalText }, ...imageBlocks];
|
|
136
153
|
outputGuard = {
|
|
@@ -145,14 +162,10 @@ export async function guardMcpOutput(
|
|
|
145
162
|
};
|
|
146
163
|
}
|
|
147
164
|
|
|
148
|
-
const mcpResult = options.rawMcpResult === undefined
|
|
149
|
-
? undefined
|
|
150
|
-
: await boundMcpResult(options.rawMcpResult, detailsMaxBytes, options.outputDirectory);
|
|
151
|
-
|
|
152
165
|
return {
|
|
153
|
-
content: guardedContent,
|
|
166
|
+
content: withReference(guardedContent),
|
|
154
167
|
...(outputGuard ? { outputGuard } : {}),
|
|
155
|
-
...
|
|
168
|
+
...retained,
|
|
156
169
|
};
|
|
157
170
|
}
|
|
158
171
|
|
|
@@ -267,16 +280,46 @@ function formatTruncationNotice(
|
|
|
267
280
|
* detailsMaxBytes; otherwise replace it with a compact summary and spill the
|
|
268
281
|
* raw JSON to a temp file.
|
|
269
282
|
*/
|
|
270
|
-
async function
|
|
283
|
+
export async function retainMcpResult(result: unknown, options: McpOutputGuardOptions = {}, rawResult = false): Promise<RetainedMcpResult> {
|
|
271
284
|
const raw = safeStringify(result);
|
|
272
285
|
const rawBytes = byteLength(raw);
|
|
273
|
-
|
|
274
|
-
|
|
286
|
+
const record = asRecord(result);
|
|
287
|
+
const content = Array.isArray(record?.content) ? record.content
|
|
288
|
+
: Array.isArray(record?.contents) ? record.contents.map(resource => ({ type: "resource", resource })) : [];
|
|
289
|
+
const payloadFiles = (await Promise.all(content.map(async (block, index): Promise<McpPayloadFile | undefined> => {
|
|
290
|
+
const item = asRecord(block);
|
|
291
|
+
const resource = asRecord(item?.resource);
|
|
292
|
+
const data = item?.type === "audio" ? item.data : item?.type === "resource" ? resource?.blob : undefined;
|
|
293
|
+
if (typeof data !== "string") return undefined;
|
|
294
|
+
const mimeType = item?.mimeType ?? resource?.mimeType;
|
|
295
|
+
return { index, kind: item!.type as "audio" | "resource", ...(typeof resource?.uri === "string" ? { uri: resource.uri } : {}), ...(typeof mimeType === "string" ? { mimeType } : {}), ...await saveArtifact("payload", Buffer.from(data, "base64"), options.outputDirectory) };
|
|
296
|
+
}))).filter((file): file is McpPayloadFile => file !== undefined);
|
|
297
|
+
const oversized = options.enabled !== false && rawBytes > (options.detailsMaxBytes ?? DEFAULT_MCP_DETAILS_MAX_BYTES);
|
|
298
|
+
const shouldSave = oversized || !record || Object.keys(record).some(key => key !== "content" && key !== "isError")
|
|
299
|
+
|| content.some(block => {
|
|
300
|
+
const item = asRecord(block);
|
|
301
|
+
return item?.type !== "text" || Object.keys(item).some(key => key !== "type" && key !== "text");
|
|
302
|
+
});
|
|
303
|
+
if (!shouldSave) return { mcpResult: result };
|
|
304
|
+
const artifact = await saveArtifact("mcp-result", raw, options.outputDirectory);
|
|
305
|
+
return {
|
|
306
|
+
mcpResult: oversized && !rawResult ? summarizeMcpResult(result, rawBytes, artifact) : result,
|
|
307
|
+
...(artifact.path ? { resultRef: artifact.path } : {}),
|
|
308
|
+
...(artifact.error ? { resultWriteError: artifact.error } : {}),
|
|
309
|
+
...(payloadFiles.length ? { payloadFiles } : {}),
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export function formatMcpPayloadFile(file: McpPayloadFile): string {
|
|
314
|
+
return `[MCP ${file.kind === "audio" ? "audio" : "binary resource"}${file.uri ? ` (${file.uri})` : ""}: ${file.mimeType ?? "unknown MIME type"}; not rendered. ${file.path ? `Saved file: ${file.path}` : `Could not save: ${file.error}`}]`;
|
|
275
315
|
}
|
|
276
316
|
|
|
277
|
-
|
|
278
|
-
|
|
317
|
+
export function formatMcpResultReference(ref: string): string {
|
|
318
|
+
return `[MCP result saved: ${JSON.stringify(ref)}. Inspect without repeating the call using mcp action "read-result" or tools.readResult with this ref. Optionally select a JSON Pointer with path.]`;
|
|
319
|
+
}
|
|
279
320
|
|
|
321
|
+
function summarizeMcpResult(result: unknown, rawBytes: number, artifact: { path?: string; error?: string }): McpResultSummary {
|
|
322
|
+
const { path: fullResultPath, error: resultWriteError } = artifact;
|
|
280
323
|
const record = asRecord(result);
|
|
281
324
|
const content = Array.isArray(record?.content) ? record.content : [];
|
|
282
325
|
const summary: McpResultSummary = {
|
|
@@ -357,12 +400,12 @@ function truncateKey(key: string): string {
|
|
|
357
400
|
return key.length <= KEY_MAX_CHARS ? key : `${key.slice(0, KEY_MAX_CHARS - 1)}…`;
|
|
358
401
|
}
|
|
359
402
|
|
|
360
|
-
async function saveArtifact(kind: string, text: string, outputDirectory?: string): Promise<{ path?: string; error?: string }> {
|
|
403
|
+
async function saveArtifact(kind: string, text: string | Uint8Array, outputDirectory?: string): Promise<{ path?: string; error?: string }> {
|
|
361
404
|
try {
|
|
362
405
|
const parent = outputDirectory === undefined ? tmpdir() : resolve(outputDirectory);
|
|
363
406
|
if (outputDirectory !== undefined) await mkdir(parent, { recursive: true, mode: 0o700 });
|
|
364
407
|
const dir = await mkdtemp(join(parent, "pi-mcp-output-"));
|
|
365
|
-
const path = join(dir, `${kind}-${randomBytes(4).toString("hex")}
|
|
408
|
+
const path = join(dir, `${kind}-${randomBytes(4).toString("hex")}.${typeof text === "string" ? "txt" : "bin"}`);
|
|
366
409
|
await writeFile(path, text, { encoding: "utf8", mode: 0o600 });
|
|
367
410
|
return { path };
|
|
368
411
|
} catch (error) {
|
|
@@ -370,6 +413,81 @@ async function saveArtifact(kind: string, text: string, outputDirectory?: string
|
|
|
370
413
|
}
|
|
371
414
|
}
|
|
372
415
|
|
|
416
|
+
export interface ReadMcpResultInput {
|
|
417
|
+
ref: string;
|
|
418
|
+
/** RFC 6901 JSON Pointer. Omit to read the entire retained result. */
|
|
419
|
+
path?: string;
|
|
420
|
+
fields?: string[];
|
|
421
|
+
/** Zero-based character offset in the selected, pretty-printed value. */
|
|
422
|
+
offset?: number;
|
|
423
|
+
/** Maximum characters, additionally bounded by the configured output limits. */
|
|
424
|
+
limit?: number;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
export async function readMcpResult(input: ReadMcpResultInput, options: McpOutputGuardOptions = {}) {
|
|
428
|
+
try {
|
|
429
|
+
if (!input || typeof input.ref !== "string") throw new Error("ref must be a retained MCP result path");
|
|
430
|
+
// Script-only hosts deliberately have no general filesystem capability.
|
|
431
|
+
const root = await realpath(options.outputDirectory === undefined ? tmpdir() : resolve(options.outputDirectory));
|
|
432
|
+
const ref = await realpath(resolve(input.ref));
|
|
433
|
+
if (dirname(dirname(ref)) !== root || !/^pi-mcp-output-/.test(basename(dirname(ref)))
|
|
434
|
+
|| !/^(mcp-result|output)-[a-f0-9]+\.txt$/.test(basename(ref))) throw new Error("ref is not an MCP output artifact in this host's output directory");
|
|
435
|
+
let rendered = await readFile(ref, "utf8");
|
|
436
|
+
if (input.path !== undefined || input.fields !== undefined || basename(ref).startsWith("mcp-result-")) {
|
|
437
|
+
let value: unknown = JSON.parse(rendered);
|
|
438
|
+
if (input.path !== undefined && input.path !== "") {
|
|
439
|
+
if (typeof input.path !== "string" || !input.path.startsWith("/") || /~(?:[^01]|$)/.test(input.path)) throw new Error("path must be an RFC 6901 JSON Pointer");
|
|
440
|
+
for (const token of input.path.slice(1).split("/")) {
|
|
441
|
+
const key = token.replace(/~1/g, "/").replace(/~0/g, "~");
|
|
442
|
+
if (value === null || typeof value !== "object" || (Array.isArray(value) && !/^(0|[1-9]\d*)$/.test(key)) || !Object.hasOwn(value, key)) throw new Error(`JSON Pointer does not exist: ${input.path}`);
|
|
443
|
+
value = (value as Record<string, unknown>)[key];
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
if (input.fields !== undefined) {
|
|
447
|
+
if (!Array.isArray(input.fields) || input.fields.some(field => typeof field !== "string")) throw new Error("fields must be an array of keys");
|
|
448
|
+
const select = (item: unknown) => {
|
|
449
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) throw new Error("fields requires an object or array of objects");
|
|
450
|
+
return Object.fromEntries(input.fields!.filter(key => Object.hasOwn(item, key)).map(key => [key, (item as Record<string, unknown>)[key]]));
|
|
451
|
+
};
|
|
452
|
+
value = Array.isArray(value) ? value.map(select) : select(value);
|
|
453
|
+
}
|
|
454
|
+
rendered = JSON.stringify(value, null, 2);
|
|
455
|
+
}
|
|
456
|
+
const offset = input.offset ?? 0;
|
|
457
|
+
const limit = input.limit ?? 12_000;
|
|
458
|
+
if (!Number.isSafeInteger(offset) || offset < 0 || !Number.isSafeInteger(limit) || limit < 1) throw new Error("offset must be a non-negative integer and limit a positive integer");
|
|
459
|
+
if (offset > 0 && rendered.codePointAt(offset - 1)! > 0xffff) throw new Error("offset must not split a Unicode surrogate pair");
|
|
460
|
+
const maxBytes = options.maxBytes ?? DEFAULT_MCP_OUTPUT_MAX_BYTES;
|
|
461
|
+
const maxLines = options.maxLines ?? DEFAULT_MCP_OUTPUT_MAX_LINES;
|
|
462
|
+
const notice = (end: number) => `[MCP result page: ${offset}–${end} of ${rendered.length} characters. Continue with offset: ${end}.]`;
|
|
463
|
+
const pageEnd = (byteCap: number, lineCap: number) => {
|
|
464
|
+
let end = offset;
|
|
465
|
+
let bytes = 0;
|
|
466
|
+
let lines = 1;
|
|
467
|
+
while (end < rendered.length && end - offset < limit) {
|
|
468
|
+
const width = rendered.codePointAt(end)! > 0xffff ? 2 : 1;
|
|
469
|
+
const char = rendered.slice(end, end + width);
|
|
470
|
+
bytes += byteLength(char);
|
|
471
|
+
if (char === "\n") lines++;
|
|
472
|
+
// Tiny caps still return one complete character plus recovery guidance.
|
|
473
|
+
if (end > offset && (bytes > byteCap || lines > lineCap || end + width - offset > limit)) break;
|
|
474
|
+
end += width;
|
|
475
|
+
}
|
|
476
|
+
return end;
|
|
477
|
+
};
|
|
478
|
+
let end = pageEnd(maxBytes, maxLines);
|
|
479
|
+
if (end < rendered.length) {
|
|
480
|
+
// The final offset cannot need more digits than the total length. Include the block separator.
|
|
481
|
+
end = pageEnd(maxBytes - byteLength(notice(rendered.length)) - 1, maxLines - 1);
|
|
482
|
+
}
|
|
483
|
+
const nextOffset = end < rendered.length ? end : null;
|
|
484
|
+
return { content: [{ type: "text" as const, text: rendered.slice(offset, end) }, ...(nextOffset !== null ? [{ type: "text" as const, text: notice(end) }] : [])], details: { ref, offset, nextOffset, totalCharacters: rendered.length, ...(input.path !== undefined ? { path: input.path } : {}) } };
|
|
485
|
+
} catch (error) {
|
|
486
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
487
|
+
return { content: [{ type: "text" as const, text: message }], details: { error: "result_read_failed", message } };
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
373
491
|
function asRecord(value: unknown): Recordish | undefined {
|
|
374
492
|
return typeof value === "object" && value !== null ? value as Recordish : undefined;
|
|
375
493
|
}
|