@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
@@ -1,6 +1,6 @@
1
1
  import type { ToolInfo } from "@earendil-works/pi-coding-agent";
2
2
  import type { McpExtensionState } from "./state.ts";
3
- import type { ContentBlock, McpOperationContext } from "./types.ts";
3
+ import { type ContentBlock, type McpOperationContext } from "./types.ts";
4
4
  export declare const DEFAULT_MCP_SCRIPT_TIMEOUT_MS = 30000;
5
5
  export declare const runMcpScript: typeof runMcpScriptOperation;
6
6
  declare function runMcpScriptOperation(state: McpExtensionState, code: string, timeoutMs?: number | null, getPiTools?: () => ToolInfo[], signal?: AbortSignal, toolCallId?: string, beforeDispatch?: (signal: AbortSignal | undefined, operation: McpOperationContext) => Promise<void>): Promise<{
@@ -45,6 +45,7 @@ declare function runMcpScriptOperation(state: McpExtensionState, code: string, t
45
45
  timeoutMs: number | null;
46
46
  error?: "aborted" | "timeout" | "call_capture_failed" | "script_error";
47
47
  message?: string | undefined;
48
+ resultRefs?: string[];
48
49
  mode: string;
49
50
  };
50
51
  }>;
package/dist/mcp-code.js CHANGED
@@ -1,11 +1,12 @@
1
1
  import { formatWithOptions } from "node:util";
2
2
  import { Worker } from "node:worker_threads";
3
- import { guardMcpOutput, guardedMcpDetails, resolveMcpOutputGuardOptions } from "./mcp-output-guard.js";
4
- import { executeCall } from "./proxy-modes.js";
3
+ import { guardMcpOutput, guardedMcpDetails, resolveMcpOutputGuardOptions, readMcpResult, formatMcpResultReference, formatMcpPayloadFile } from "./mcp-output-guard.js";
4
+ import { executeCall, executeConnect } from "./proxy-modes.js";
5
+ import { executeResourceList, executeResourceRead } from "./resource-tools.js";
5
6
  import { combineAbortSignals } from "./runtime-owner.js";
6
7
  import { paginate, rankSuggestions, rankToolMatches } from "./search-ranking.js";
7
- import { findToolByName } from "./tool-metadata.js";
8
- import { renderTsShape } from "./ts-shape.js";
8
+ import { catalogCoverage, toToolDescriptor } from "./tool-metadata.js";
9
+ import { isServerDisabled } from "./types.js";
9
10
  export const DEFAULT_MCP_SCRIPT_TIMEOUT_MS = 30_000;
10
11
  class McpScriptTimeoutError extends Error {
11
12
  constructor(timeoutMs) {
@@ -73,11 +74,9 @@ function parseWorkerMessage(value) {
73
74
  if (message.type === "emit" && "block" in message)
74
75
  return { type: "emit", block: message.block };
75
76
  if (message.type === "call" && typeof message.id === "number" && typeof message.path === "string") {
76
- return "args" in message
77
- ? { type: "call", id: message.id, path: message.path, args: message.args }
78
- : { type: "call", id: message.id, path: message.path };
77
+ return { type: "call", id: message.id, path: message.path, ...(message.args !== undefined ? { args: message.args } : {}), ...(typeof message.server === "string" ? { server: message.server } : {}) };
79
78
  }
80
- if ((message.type === "search" || message.type === "describe") && typeof message.id === "number") {
79
+ if ((message.type === "search" || message.type === "describe" || message.type === "resources" || message.type === "readResource" || message.type === "readResult") && typeof message.id === "number") {
81
80
  return "input" in message
82
81
  ? { type: message.type, id: message.id, input: message.input }
83
82
  : { type: message.type, id: message.id };
@@ -124,17 +123,20 @@ async function runMcpScriptOperation(state, code, timeoutMs = DEFAULT_MCP_SCRIPT
124
123
  : operation.durationMs,
125
124
  }));
126
125
  let callsSnapshot;
127
- const callTool = async (innerCallId, path, args) => {
126
+ const resultRefs = new Set();
127
+ const payloadNotices = new Set();
128
+ const recordCall = async (path, dispatch) => {
128
129
  // Record before dispatch so calls still in flight at timeout/abort appear in the trace.
129
130
  const startedAt = Date.now();
130
131
  const index = calls.push({ operation: "call", path, ok: false, error: "incomplete", durationMs: 0, startedAt }) - 1;
131
- let rawResult;
132
- let hasRawResult = false;
133
- const result = await executeCall(state, path, args, undefined, getPiTools, callSignal, (raw) => {
134
- rawResult = raw;
135
- hasRawResult = true;
136
- }, { ...(toolCallId !== undefined ? { toolCallId } : {}), innerCallId }, beforeDispatch);
132
+ const result = await dispatch();
137
133
  const details = result.details;
134
+ if (typeof details.resultRef === "string")
135
+ resultRefs.add(details.resultRef);
136
+ if (typeof details.resultWriteError === "string")
137
+ payloadNotices.add(`[MCP result could not be saved: ${details.resultWriteError}]`);
138
+ for (const file of (details.payloadFiles ?? []))
139
+ payloadNotices.add(formatMcpPayloadFile(file));
138
140
  if (details.error !== undefined) {
139
141
  const errorCode = String(details.error);
140
142
  const suggestions = Array.isArray(details.suggestions)
@@ -155,29 +157,49 @@ async function runMcpScriptOperation(state, code, timeoutMs = DEFAULT_MCP_SCRIPT
155
157
  output.push({ type: "text", text: message });
156
158
  return {
157
159
  ok: false,
160
+ ...(details.mcpResult !== undefined ? { data: details.mcpResult } : {}),
161
+ ...(details.resultRef ? { resultRef: details.resultRef } : {}),
158
162
  error: { code: errorCode, message, ...(details.recovery ? { recovery: details.recovery } : {}) },
159
163
  };
160
164
  }
161
165
  calls[index] = { operation: "call", path, ok: true, durationMs: Date.now() - startedAt, startedAt };
162
166
  return {
163
167
  ok: true,
164
- data: hasRawResult ? rawResult : details.mcpResult !== undefined ? details.mcpResult : textFromContent(result.content),
168
+ data: details.mcpResult !== undefined ? details.mcpResult : textFromContent(result.content),
169
+ ...(details.resultRef ? { resultRef: details.resultRef } : {}),
165
170
  };
166
171
  };
167
- const searchTools = (input) => {
172
+ const callIdentity = (innerCallId) => ({ ...(toolCallId !== undefined ? { toolCallId } : {}), innerCallId });
173
+ const callTool = (innerCallId, path, args, server) => recordCall(path, () => executeCall(state, path, args, server, getPiTools, callSignal, callIdentity(innerCallId), beforeDispatch, { raw: true }));
174
+ const discoverServer = async (server) => {
175
+ if (!server)
176
+ return undefined;
177
+ if (isServerDisabled(state.config.mcpServers[server]))
178
+ return { code: "server_disabled", message: `Server "${server}" is disabled.` };
179
+ if (state.config.mcpServers[server] && state.toolMetadata.has(server))
180
+ return undefined;
181
+ const result = await executeConnect(state, server, callSignal);
182
+ return result.details.error ? { code: result.details.error, message: textFromContent(result.content) } : undefined;
183
+ };
184
+ const searchTools = async (input) => {
168
185
  const startedAt = Date.now();
169
186
  const query = typeof input?.query === "string" ? input.query : "";
170
187
  let error;
171
188
  try {
172
- if (query.trim() === "") {
173
- return { items: [], total: 0, hasMore: false, nextOffset: null };
174
- }
175
189
  const server = typeof input?.server === "string" ? input.server : undefined;
190
+ const discoveryError = await discoverServer(server);
191
+ const coverage = catalogCoverage(state, server);
192
+ if (discoveryError) {
193
+ error = discoveryError.code;
194
+ return { error: discoveryError, coverage };
195
+ }
196
+ if (query.trim() === "")
197
+ return { items: [], total: 0, hasMore: false, nextOffset: null, coverage };
176
198
  const limit = typeof input?.limit === "number" ? input.limit : 12;
177
199
  const offset = typeof input?.offset === "number" ? input.offset : 0;
178
- const page = paginate(rankToolMatches(state, query, server), offset, limit);
200
+ const page = paginate(rankToolMatches(state, query, server).filter(match => !match.tool.resourceUri), offset, limit);
179
201
  return {
180
- ...page,
202
+ ...page, coverage,
181
203
  items: page.items.map(({ server: matchServer, tool, score }) => ({
182
204
  path: tool.name,
183
205
  name: tool.originalName,
@@ -197,26 +219,31 @@ async function runMcpScriptOperation(state, code, timeoutMs = DEFAULT_MCP_SCRIPT
197
219
  : { operation: "search", query, ok: false, error: error instanceof Error ? error.message : String(error), durationMs: Date.now() - startedAt, startedAt });
198
220
  }
199
221
  };
200
- const describeTool = (input) => {
222
+ const describeTool = async (input) => {
201
223
  const startedAt = Date.now();
202
224
  const path = typeof input?.path === "string" ? input.path : "";
203
225
  let error;
204
226
  try {
205
- for (const [server, metadata] of state.toolMetadata) {
206
- const tool = findToolByName(metadata, path);
207
- if (!tool)
208
- continue;
209
- const inputTypeScript = tool.inputSchema ? renderTsShape(tool.inputSchema) : null;
210
- return {
211
- path: tool.name,
212
- name: tool.originalName,
213
- server,
214
- ...(tool.description ? { description: tool.description } : {}),
215
- ...(inputTypeScript ? { inputTypeScript } : tool.inputSchema !== undefined ? { inputSchema: tool.inputSchema } : {}),
216
- ...(tool.annotations !== undefined ? { annotations: tool.annotations } : {}),
217
- };
227
+ const selectedServer = typeof input?.server === "string" ? input.server : undefined;
228
+ const discoveryError = await discoverServer(selectedServer);
229
+ if (discoveryError) {
230
+ error = discoveryError.code;
231
+ return { path, error: discoveryError };
232
+ }
233
+ const matches = [...state.toolMetadata].flatMap(([server, metadata]) => {
234
+ if ((selectedServer && server !== selectedServer) || !state.config.mcpServers[server] || isServerDisabled(state.config.mcpServers[server]))
235
+ return [];
236
+ const tools = metadata.filter(tool => !tool.resourceUri);
237
+ const originals = selectedServer ? tools.filter(tool => tool.originalName === path) : [];
238
+ return (originals.length ? originals : tools.filter(tool => tool.name === path)).map(tool => toToolDescriptor(server, tool));
239
+ });
240
+ if (matches.length === 1)
241
+ return matches[0];
242
+ if (matches.length > 1) {
243
+ error = "ambiguous_tool";
244
+ return { path, error: { code: "ambiguous_tool", message: "Specify server and exact tool name." } };
218
245
  }
219
- const suggestions = path ? rankSuggestions(state, path, 5) : [];
246
+ const suggestions = path ? rankSuggestions(state, path, 5, selectedServer) : [];
220
247
  error = "tool_not_found";
221
248
  return {
222
249
  path,
@@ -277,13 +304,31 @@ async function runMcpScriptOperation(state, code, timeoutMs = DEFAULT_MCP_SCRIPT
277
304
  void (async () => {
278
305
  let envelope;
279
306
  if (message.type === "call") {
280
- envelope = await callTool(message.id, message.path, message.args);
307
+ envelope = await callTool(message.id, message.path, message.args, message.server);
281
308
  }
282
309
  else if (message.type === "search") {
283
- envelope = searchTools(message.input);
310
+ envelope = await searchTools(message.input);
311
+ }
312
+ else if (message.type === "describe") {
313
+ envelope = await describeTool(message.input);
314
+ }
315
+ else if (message.type === "readResult") {
316
+ envelope = await readMcpResult(message.input, resolveMcpOutputGuardOptions(state.config.settings, state.outputDirectory));
284
317
  }
285
318
  else {
286
- envelope = describeTool(message.input);
319
+ const input = message.input;
320
+ if (typeof input?.server !== "string" || !input.server || (message.type === "readResource" && (typeof input.uri !== "string" || !input.uri))) {
321
+ envelope = { error: { code: "invalid_arguments", message: "Specify server and, for reads, resource uri." } };
322
+ }
323
+ else if (message.type === "resources") {
324
+ const result = await executeResourceList(state, input.server, typeof input.limit === "number" ? input.limit : 12, typeof input.offset === "number" ? input.offset : 0, callSignal);
325
+ envelope = result.details.error ? { error: { code: result.details.error, message: textFromContent(result.content) } } : result.details;
326
+ }
327
+ else {
328
+ const server = input.server;
329
+ const uri = input.uri;
330
+ envelope = await recordCall(`${server}:${uri}`, () => executeResourceRead(state, server, uri, callSignal, callIdentity(message.id), beforeDispatch, true));
331
+ }
287
332
  }
288
333
  const response = { type: "result", id: message.id, envelope };
289
334
  activeWorker.postMessage(response);
@@ -351,11 +396,12 @@ async function runMcpScriptOperation(state, code, timeoutMs = DEFAULT_MCP_SCRIPT
351
396
  await worker?.terminate();
352
397
  }
353
398
  // Snapshot before the asynchronous output guard; the terminated worker can no longer emit.
354
- const guarded = await guardMcpOutput(output.length > 0 ? [...output] : [{ type: "text", text: "(no output)" }], resolveMcpOutputGuardOptions(state.config.settings, state.outputDirectory));
399
+ const guarded = await guardMcpOutput([...(output.length > 0 ? output : [{ type: "text", text: "(no output)" }]), ...[...payloadNotices].map(text => ({ type: "text", text })), ...[...resultRefs].map(ref => ({ type: "text", text: formatMcpResultReference(ref) }))], resolveMcpOutputGuardOptions(state.config.settings, state.outputDirectory));
355
400
  return {
356
401
  content: guarded.content,
357
402
  details: {
358
403
  mode: "script",
404
+ ...(resultRefs.size ? { resultRefs: [...resultRefs] } : {}),
359
405
  ...(errorCode ? { error: errorCode, message: errorMessage } : {}),
360
406
  timeoutMs: resolvedTimeoutMs,
361
407
  ...(recovery !== undefined ? { recovery } : {}),
@@ -38,16 +38,29 @@ export interface McpOutputGuardOptions {
38
38
  /**
39
39
  * Raw MCP result to expose as details.mcpResult. Kept raw when its JSON
40
40
  * fits detailsMaxBytes (or when the guard is disabled); otherwise replaced
41
- * with a compact summary and spilled to a temp file. Omit for call sites
42
- * whose details never carried the raw result (e.g. resource reads).
41
+ * with a compact summary and spilled to a file. Payloads that are not fully
42
+ * visible as plain text also get a model-visible reference for local readback.
43
43
  */
44
44
  rawMcpResult?: unknown;
45
+ retainedMcpResult?: RetainedMcpResult;
45
46
  }
46
47
  export interface GuardedMcpOutput {
47
48
  content: ContentBlock[];
48
49
  outputGuard?: McpOutputGuardDetails;
49
50
  mcpResult?: unknown;
51
+ resultRef?: string;
52
+ resultWriteError?: string;
53
+ payloadFiles?: McpPayloadFile[];
54
+ }
55
+ export interface McpPayloadFile {
56
+ index: number;
57
+ kind: "audio" | "resource";
58
+ uri?: string;
59
+ mimeType?: string;
60
+ path?: string;
61
+ error?: string;
50
62
  }
63
+ export type RetainedMcpResult = Pick<GuardedMcpOutput, "mcpResult" | "resultRef" | "resultWriteError" | "payloadFiles">;
51
64
  export declare function resolveMcpOutputGuardOptions(settings?: McpSettings, outputDirectory?: string): Pick<McpOutputGuardOptions, "enabled" | "maxBytes" | "maxLines" | "detailsMaxBytes" | "outputDirectory">;
52
65
  /** Spread helper for tool-result details: includes mcpResult/outputGuard only when present. */
53
66
  export declare function guardedMcpDetails(guarded: GuardedMcpOutput): Record<string, unknown>;
@@ -57,3 +70,45 @@ export declare function guardedMcpDetails(guarded: GuardedMcpOutput): Record<str
57
70
  * they are delivered to the provider as native image content, not text context.
58
71
  */
59
72
  export declare function guardMcpOutput(content: ContentBlock[], options?: McpOutputGuardOptions): Promise<GuardedMcpOutput>;
73
+ /**
74
+ * Bound details.mcpResult: keep the raw result when its JSON fits within
75
+ * detailsMaxBytes; otherwise replace it with a compact summary and spill the
76
+ * raw JSON to a temp file.
77
+ */
78
+ export declare function retainMcpResult(result: unknown, options?: McpOutputGuardOptions, rawResult?: boolean): Promise<RetainedMcpResult>;
79
+ export declare function formatMcpPayloadFile(file: McpPayloadFile): string;
80
+ export declare function formatMcpResultReference(ref: string): string;
81
+ export interface ReadMcpResultInput {
82
+ ref: string;
83
+ /** RFC 6901 JSON Pointer. Omit to read the entire retained result. */
84
+ path?: string;
85
+ fields?: string[];
86
+ /** Zero-based character offset in the selected, pretty-printed value. */
87
+ offset?: number;
88
+ /** Maximum characters, additionally bounded by the configured output limits. */
89
+ limit?: number;
90
+ }
91
+ export declare function readMcpResult(input: ReadMcpResultInput, options?: McpOutputGuardOptions): Promise<{
92
+ content: {
93
+ type: "text";
94
+ text: string;
95
+ }[];
96
+ details: {
97
+ path?: string;
98
+ ref: string;
99
+ offset: number;
100
+ nextOffset: number | null;
101
+ totalCharacters: number;
102
+ error?: never;
103
+ message?: never;
104
+ };
105
+ } | {
106
+ content: {
107
+ type: "text";
108
+ text: string;
109
+ }[];
110
+ details: {
111
+ error: string;
112
+ message: string;
113
+ };
114
+ }>;
@@ -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
  export const DEFAULT_MCP_OUTPUT_MAX_BYTES = 50 * 1024;
6
6
  export const DEFAULT_MCP_OUTPUT_MAX_LINES = 2000;
7
7
  export const DEFAULT_MCP_DETAILS_MAX_BYTES = 16 * 1024;
@@ -24,6 +24,9 @@ export function guardedMcpDetails(guarded) {
24
24
  return {
25
25
  ...(guarded.mcpResult !== undefined ? { mcpResult: guarded.mcpResult } : {}),
26
26
  ...(guarded.outputGuard ? { outputGuard: guarded.outputGuard } : {}),
27
+ ...(guarded.resultRef ? { resultRef: guarded.resultRef } : {}),
28
+ ...(guarded.resultWriteError ? { resultWriteError: guarded.resultWriteError } : {}),
29
+ ...(guarded.payloadFiles?.length ? { payloadFiles: guarded.payloadFiles } : {}),
27
30
  };
28
31
  }
29
32
  /**
@@ -34,18 +37,17 @@ export function guardedMcpDetails(guarded) {
34
37
  export async function guardMcpOutput(content, options = {}) {
35
38
  const maxBytes = options.maxBytes ?? DEFAULT_MCP_OUTPUT_MAX_BYTES;
36
39
  const maxLines = options.maxLines ?? DEFAULT_MCP_OUTPUT_MAX_LINES;
37
- const detailsMaxBytes = options.detailsMaxBytes ?? DEFAULT_MCP_DETAILS_MAX_BYTES;
38
40
  const prefix = options.prefix ?? "";
39
41
  const suffix = options.suffix ?? "";
40
42
  const normalizedContent = withEmptyTextFallback(content.length > 0
41
43
  ? sanitizeContent(content)
42
44
  : [{ type: "text", text: options.emptyTextFallback ?? "(empty result)" }], options.emptyTextFallback);
43
- if (options.enabled === false) {
44
- return {
45
- content: addAffixes(normalizedContent, prefix, suffix),
46
- ...(options.rawMcpResult !== undefined ? { mcpResult: options.rawMcpResult } : {}),
47
- };
48
- }
45
+ const retained = options.retainedMcpResult ?? (options.rawMcpResult === undefined ? {} : await retainMcpResult(options.rawMcpResult, options));
46
+ const reference = retained.resultRef ? formatMcpResultReference(retained.resultRef)
47
+ : retained.resultWriteError ? `[MCP result could not be saved: ${retained.resultWriteError}]` : undefined;
48
+ const withReference = (blocks) => reference ? [...blocks, { type: "text", text: reference }] : blocks;
49
+ if (options.enabled === false)
50
+ return { content: withReference(addAffixes(normalizedContent, prefix, suffix)), ...retained };
49
51
  const imageBlocks = normalizedContent.filter((block) => block.type === "image");
50
52
  const textOutput = normalizedContent
51
53
  .filter((block) => block.type === "text")
@@ -53,15 +55,17 @@ export async function guardMcpOutput(content, options = {}) {
53
55
  .join("\n");
54
56
  const composedOutput = `${prefix}${textOutput}${suffix}`;
55
57
  const stats = textStats(composedOutput);
58
+ const referenceSuffix = reference ? `\n${reference}` : "";
59
+ const totalStats = textStats(composedOutput ? composedOutput + referenceSuffix : reference ?? "");
56
60
  let guardedContent = addAffixes(normalizedContent, prefix, suffix);
57
61
  let outputGuard;
58
- if (stats.bytes > maxBytes || stats.lines > maxLines) {
62
+ if (totalStats.bytes > maxBytes || totalStats.lines > maxLines) {
59
63
  const { path: fullOutputPath, error: writeError } = await saveArtifact("output", composedOutput, options.outputDirectory);
60
64
  const notice = formatTruncationNotice(stats, fullOutputPath, writeError);
61
- const previewBudget = reserveBudget(maxBytes, maxLines, notice);
65
+ const previewBudget = reserveBudget(maxBytes, maxLines, notice + referenceSuffix);
62
66
  const preview = truncateHead(composedOutput, previewBudget.maxBytes, previewBudget.maxLines);
63
67
  const finalText = `${preview.content}\n\n${notice}`;
64
- const finalStats = textStats(finalText);
68
+ const finalStats = textStats(finalText + referenceSuffix);
65
69
  guardedContent = [{ type: "text", text: finalText }, ...imageBlocks];
66
70
  outputGuard = {
67
71
  truncated: true,
@@ -74,13 +78,10 @@ export async function guardMcpOutput(content, options = {}) {
74
78
  ...(writeError !== undefined ? { writeError } : {}),
75
79
  };
76
80
  }
77
- const mcpResult = options.rawMcpResult === undefined
78
- ? undefined
79
- : await boundMcpResult(options.rawMcpResult, detailsMaxBytes, options.outputDirectory);
80
81
  return {
81
- content: guardedContent,
82
+ content: withReference(guardedContent),
82
83
  ...(outputGuard ? { outputGuard } : {}),
83
- ...(mcpResult !== undefined ? { mcpResult } : {}),
84
+ ...retained,
84
85
  };
85
86
  }
86
87
  function sanitizeContent(content) {
@@ -187,15 +188,45 @@ function formatTruncationNotice(stats, fullOutputPath, writeError) {
187
188
  * detailsMaxBytes; otherwise replace it with a compact summary and spill the
188
189
  * raw JSON to a temp file.
189
190
  */
190
- async function boundMcpResult(result, detailsMaxBytes, outputDirectory) {
191
+ export async function retainMcpResult(result, options = {}, rawResult = false) {
191
192
  const raw = safeStringify(result);
192
193
  const rawBytes = byteLength(raw);
193
- if (rawBytes <= detailsMaxBytes)
194
- return result;
195
- return summarizeMcpResult(result, raw, rawBytes, outputDirectory);
194
+ const record = asRecord(result);
195
+ const content = Array.isArray(record?.content) ? record.content
196
+ : Array.isArray(record?.contents) ? record.contents.map(resource => ({ type: "resource", resource })) : [];
197
+ const payloadFiles = (await Promise.all(content.map(async (block, index) => {
198
+ const item = asRecord(block);
199
+ const resource = asRecord(item?.resource);
200
+ const data = item?.type === "audio" ? item.data : item?.type === "resource" ? resource?.blob : undefined;
201
+ if (typeof data !== "string")
202
+ return undefined;
203
+ const mimeType = item?.mimeType ?? resource?.mimeType;
204
+ return { index, kind: item.type, ...(typeof resource?.uri === "string" ? { uri: resource.uri } : {}), ...(typeof mimeType === "string" ? { mimeType } : {}), ...await saveArtifact("payload", Buffer.from(data, "base64"), options.outputDirectory) };
205
+ }))).filter((file) => file !== undefined);
206
+ const oversized = options.enabled !== false && rawBytes > (options.detailsMaxBytes ?? DEFAULT_MCP_DETAILS_MAX_BYTES);
207
+ const shouldSave = oversized || !record || Object.keys(record).some(key => key !== "content" && key !== "isError")
208
+ || content.some(block => {
209
+ const item = asRecord(block);
210
+ return item?.type !== "text" || Object.keys(item).some(key => key !== "type" && key !== "text");
211
+ });
212
+ if (!shouldSave)
213
+ return { mcpResult: result };
214
+ const artifact = await saveArtifact("mcp-result", raw, options.outputDirectory);
215
+ return {
216
+ mcpResult: oversized && !rawResult ? summarizeMcpResult(result, rawBytes, artifact) : result,
217
+ ...(artifact.path ? { resultRef: artifact.path } : {}),
218
+ ...(artifact.error ? { resultWriteError: artifact.error } : {}),
219
+ ...(payloadFiles.length ? { payloadFiles } : {}),
220
+ };
196
221
  }
197
- async function summarizeMcpResult(result, raw, rawBytes, outputDirectory) {
198
- const { path: fullResultPath, error: resultWriteError } = await saveArtifact("mcp-result", raw, outputDirectory);
222
+ export function formatMcpPayloadFile(file) {
223
+ 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}`}]`;
224
+ }
225
+ export function formatMcpResultReference(ref) {
226
+ 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.]`;
227
+ }
228
+ function summarizeMcpResult(result, rawBytes, artifact) {
229
+ const { path: fullResultPath, error: resultWriteError } = artifact;
199
230
  const record = asRecord(result);
200
231
  const content = Array.isArray(record?.content) ? record.content : [];
201
232
  const summary = {
@@ -281,7 +312,7 @@ async function saveArtifact(kind, text, outputDirectory) {
281
312
  if (outputDirectory !== undefined)
282
313
  await mkdir(parent, { recursive: true, mode: 0o700 });
283
314
  const dir = await mkdtemp(join(parent, "pi-mcp-output-"));
284
- const path = join(dir, `${kind}-${randomBytes(4).toString("hex")}.txt`);
315
+ const path = join(dir, `${kind}-${randomBytes(4).toString("hex")}.${typeof text === "string" ? "txt" : "bin"}`);
285
316
  await writeFile(path, text, { encoding: "utf8", mode: 0o600 });
286
317
  return { path };
287
318
  }
@@ -289,6 +320,80 @@ async function saveArtifact(kind, text, outputDirectory) {
289
320
  return { error: error instanceof Error ? error.message : String(error) };
290
321
  }
291
322
  }
323
+ export async function readMcpResult(input, options = {}) {
324
+ try {
325
+ if (!input || typeof input.ref !== "string")
326
+ throw new Error("ref must be a retained MCP result path");
327
+ // Script-only hosts deliberately have no general filesystem capability.
328
+ const root = await realpath(options.outputDirectory === undefined ? tmpdir() : resolve(options.outputDirectory));
329
+ const ref = await realpath(resolve(input.ref));
330
+ if (dirname(dirname(ref)) !== root || !/^pi-mcp-output-/.test(basename(dirname(ref)))
331
+ || !/^(mcp-result|output)-[a-f0-9]+\.txt$/.test(basename(ref)))
332
+ throw new Error("ref is not an MCP output artifact in this host's output directory");
333
+ let rendered = await readFile(ref, "utf8");
334
+ if (input.path !== undefined || input.fields !== undefined || basename(ref).startsWith("mcp-result-")) {
335
+ let value = JSON.parse(rendered);
336
+ if (input.path !== undefined && input.path !== "") {
337
+ if (typeof input.path !== "string" || !input.path.startsWith("/") || /~(?:[^01]|$)/.test(input.path))
338
+ throw new Error("path must be an RFC 6901 JSON Pointer");
339
+ for (const token of input.path.slice(1).split("/")) {
340
+ const key = token.replace(/~1/g, "/").replace(/~0/g, "~");
341
+ if (value === null || typeof value !== "object" || (Array.isArray(value) && !/^(0|[1-9]\d*)$/.test(key)) || !Object.hasOwn(value, key))
342
+ throw new Error(`JSON Pointer does not exist: ${input.path}`);
343
+ value = value[key];
344
+ }
345
+ }
346
+ if (input.fields !== undefined) {
347
+ if (!Array.isArray(input.fields) || input.fields.some(field => typeof field !== "string"))
348
+ throw new Error("fields must be an array of keys");
349
+ const select = (item) => {
350
+ if (!item || typeof item !== "object" || Array.isArray(item))
351
+ throw new Error("fields requires an object or array of objects");
352
+ return Object.fromEntries(input.fields.filter(key => Object.hasOwn(item, key)).map(key => [key, item[key]]));
353
+ };
354
+ value = Array.isArray(value) ? value.map(select) : select(value);
355
+ }
356
+ rendered = JSON.stringify(value, null, 2);
357
+ }
358
+ const offset = input.offset ?? 0;
359
+ const limit = input.limit ?? 12_000;
360
+ if (!Number.isSafeInteger(offset) || offset < 0 || !Number.isSafeInteger(limit) || limit < 1)
361
+ throw new Error("offset must be a non-negative integer and limit a positive integer");
362
+ if (offset > 0 && rendered.codePointAt(offset - 1) > 0xffff)
363
+ throw new Error("offset must not split a Unicode surrogate pair");
364
+ const maxBytes = options.maxBytes ?? DEFAULT_MCP_OUTPUT_MAX_BYTES;
365
+ const maxLines = options.maxLines ?? DEFAULT_MCP_OUTPUT_MAX_LINES;
366
+ const notice = (end) => `[MCP result page: ${offset}–${end} of ${rendered.length} characters. Continue with offset: ${end}.]`;
367
+ const pageEnd = (byteCap, lineCap) => {
368
+ let end = offset;
369
+ let bytes = 0;
370
+ let lines = 1;
371
+ while (end < rendered.length && end - offset < limit) {
372
+ const width = rendered.codePointAt(end) > 0xffff ? 2 : 1;
373
+ const char = rendered.slice(end, end + width);
374
+ bytes += byteLength(char);
375
+ if (char === "\n")
376
+ lines++;
377
+ // Tiny caps still return one complete character plus recovery guidance.
378
+ if (end > offset && (bytes > byteCap || lines > lineCap || end + width - offset > limit))
379
+ break;
380
+ end += width;
381
+ }
382
+ return end;
383
+ };
384
+ let end = pageEnd(maxBytes, maxLines);
385
+ if (end < rendered.length) {
386
+ // The final offset cannot need more digits than the total length. Include the block separator.
387
+ end = pageEnd(maxBytes - byteLength(notice(rendered.length)) - 1, maxLines - 1);
388
+ }
389
+ const nextOffset = end < rendered.length ? end : null;
390
+ return { content: [{ type: "text", text: rendered.slice(offset, end) }, ...(nextOffset !== null ? [{ type: "text", text: notice(end) }] : [])], details: { ref, offset, nextOffset, totalCharacters: rendered.length, ...(input.path !== undefined ? { path: input.path } : {}) } };
391
+ }
392
+ catch (error) {
393
+ const message = error instanceof Error ? error.message : String(error);
394
+ return { content: [{ type: "text", text: message }], details: { error: "result_read_failed", message } };
395
+ }
396
+ }
292
397
  function asRecord(value) {
293
398
  return typeof value === "object" && value !== null ? value : undefined;
294
399
  }
@@ -1,6 +1,6 @@
1
1
  import { type PanelKeybindings } from "./panel-keys.ts";
2
2
  import type { McpConfig, McpPanelCallbacks, McpPanelResult, ServerProvenance } from "./types.ts";
3
- import type { MetadataCache } from "./metadata-cache.ts";
3
+ import { type MetadataCache } from "./metadata-cache.ts";
4
4
  declare class McpPanel {
5
5
  private callbacks;
6
6
  private done;