@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.
Files changed (73) hide show
  1. package/CHANGELOG.md +25 -1
  2. package/OAUTH.md +3 -3
  3. package/README.md +153 -85
  4. package/app-bridge.bundle.js +75 -33
  5. package/commands.ts +9 -6
  6. package/direct-tools.ts +25 -358
  7. package/dist/app-bridge.bundle.js +75 -33
  8. package/dist/commands.js +9 -6
  9. package/dist/direct-tools.d.ts +2 -4
  10. package/dist/direct-tools.js +21 -319
  11. package/dist/gateway-arguments.d.ts +33 -0
  12. package/dist/gateway-arguments.js +63 -0
  13. package/dist/index.js +181 -276
  14. package/dist/init.js +6 -20
  15. package/dist/mcp-code.d.ts +2 -1
  16. package/dist/mcp-code.js +87 -41
  17. package/dist/mcp-output-guard.d.ts +57 -2
  18. package/dist/mcp-output-guard.js +129 -24
  19. package/dist/mcp-panel.d.ts +1 -1
  20. package/dist/mcp-panel.js +34 -97
  21. package/dist/mcp-script-worker.mjs +5 -2
  22. package/dist/mcp-status.js +2 -1
  23. package/dist/metadata-cache.d.ts +1 -1
  24. package/dist/metadata-cache.js +25 -88
  25. package/dist/prompts.d.ts +1 -1
  26. package/dist/prompts.js +1 -1
  27. package/dist/proxy-modes.d.ts +40 -6
  28. package/dist/proxy-modes.js +270 -485
  29. package/dist/resource-tools.d.ts +15 -0
  30. package/dist/resource-tools.js +26 -1
  31. package/dist/sampling-handler.js +4 -1
  32. package/dist/search-ranking.d.ts +0 -2
  33. package/dist/search-ranking.js +66 -137
  34. package/dist/state.d.ts +1 -1
  35. package/dist/tool-loader.d.ts +32 -0
  36. package/dist/tool-loader.js +152 -0
  37. package/dist/tool-metadata.d.ts +67 -0
  38. package/dist/tool-metadata.js +23 -17
  39. package/dist/tool-registrar.d.ts +4 -4
  40. package/dist/tool-registrar.js +18 -13
  41. package/dist/tool-result-renderer.d.ts +3 -0
  42. package/dist/tool-result-renderer.js +8 -3
  43. package/dist/types.d.ts +11 -36
  44. package/dist/utils.js +2 -4
  45. package/gateway-arguments.ts +73 -0
  46. package/index.ts +166 -299
  47. package/init.ts +6 -20
  48. package/mcp-code.ts +71 -45
  49. package/mcp-output-guard.ts +145 -27
  50. package/mcp-panel.ts +38 -109
  51. package/mcp-script-worker.mjs +5 -2
  52. package/mcp-status.ts +2 -1
  53. package/metadata-cache.ts +31 -93
  54. package/package.json +14 -9
  55. package/prompts.ts +1 -1
  56. package/proxy-modes.ts +265 -517
  57. package/resource-tools.ts +36 -1
  58. package/sampling-handler.ts +4 -1
  59. package/scripts/build-app-bridge.mjs +15 -0
  60. package/scripts/build.mjs +3 -2
  61. package/scripts/prepare.mjs +1 -0
  62. package/search-ranking.ts +71 -135
  63. package/skills/mcp-scripting/SKILL.md +67 -20
  64. package/state.ts +1 -1
  65. package/tool-loader.ts +153 -0
  66. package/tool-metadata.ts +22 -17
  67. package/tool-registrar.ts +18 -13
  68. package/tool-result-renderer.ts +10 -2
  69. package/types.ts +15 -40
  70. package/utils.ts +2 -4
  71. package/dist/ts-shape.d.ts +0 -2
  72. package/dist/ts-shape.js +0 -147
  73. 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 cachePath = getMetadataCachePath();
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 = bootstrapAll
262
- ? serverEntries
263
- : serverEntries.filter(([, definition]) => {
264
- const mode = definition.lifecycle ?? "lazy";
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: direct tools for ${bootstrapped.join(", ")} will be available after restart`, "info");
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 { findToolByName } from "./tool-metadata.ts";
10
- import { renderTsShape } from "./ts-shape.ts";
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" in message
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 callTool = async (innerCallId: number, path: string, args?: Record<string, unknown>) => {
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
- let rawResult: unknown;
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: hasRawResult ? rawResult : details.mcpResult !== undefined ? details.mcpResult : textFromContent(result.content),
189
+ data: details.mcpResult !== undefined ? details.mcpResult : textFromContent(result.content),
190
+ ...(details.resultRef ? { resultRef: details.resultRef } : {}),
190
191
  };
191
192
  };
192
193
 
193
- const searchTools = (input?: SearchInput) => {
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
- for (const [server, metadata] of state.toolMetadata) {
231
- const tool = findToolByName(metadata, path);
232
- if (!tool) continue;
233
- const inputTypeScript = tool.inputSchema ? renderTsShape(tool.inputSchema) : null;
234
- return {
235
- path: tool.name,
236
- name: tool.originalName,
237
- server,
238
- ...(tool.description ? { description: tool.description } : {}),
239
- ...(inputTypeScript ? { inputTypeScript } : tool.inputSchema !== undefined ? { inputSchema: tool.inputSchema } : {}),
240
- ...(tool.annotations !== undefined ? { annotations: tool.annotations } : {}),
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
- envelope = describeTool(message.input as DescribeInput | undefined);
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 ? [...output] : [{ type: "text", text: "(no output)" }],
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 } : {}),
@@ -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 temp file. Omit for call sites
56
- * whose details never carried the raw result (e.g. resource reads).
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
- if (options.enabled === false) {
110
- return {
111
- content: addAffixes(normalizedContent, prefix, suffix),
112
- ...(options.rawMcpResult !== undefined ? { mcpResult: options.rawMcpResult } : {}),
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 (stats.bytes > maxBytes || stats.lines > maxLines) {
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
- ...(mcpResult !== undefined ? { mcpResult } : {}),
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 boundMcpResult(result: unknown, detailsMaxBytes: number, outputDirectory?: string): Promise<unknown> {
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
- if (rawBytes <= detailsMaxBytes) return result;
274
- return summarizeMcpResult(result, raw, rawBytes, outputDirectory);
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
- async function summarizeMcpResult(result: unknown, raw: string, rawBytes: number, outputDirectory?: string): Promise<McpResultSummary> {
278
- const { path: fullResultPath, error: resultWriteError } = await saveArtifact("mcp-result", raw, outputDirectory);
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")}.txt`);
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
  }