@fitchmultz/pi-mcp-adapter 8.0.1 → 8.0.3

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 (60) hide show
  1. package/CHANGELOG.md +42 -1
  2. package/README.md +3 -3
  3. package/dist/app-bridge.bundle.js +13 -13
  4. package/dist/client-operations.d.ts +5 -0
  5. package/dist/client-operations.js +19 -0
  6. package/dist/commands.d.ts +1 -1
  7. package/dist/commands.js +3 -1
  8. package/dist/connection-runtime.d.ts +503 -0
  9. package/dist/connection-runtime.js +98 -0
  10. package/dist/direct-tools.js +1 -1
  11. package/dist/host-html-template.js +24 -12
  12. package/dist/index.js +36 -29
  13. package/dist/init.d.ts +1 -1
  14. package/dist/init.js +66 -38
  15. package/dist/lifecycle.d.ts +1 -1
  16. package/dist/lifecycle.js +1 -1
  17. package/dist/mcp-auth-flow.d.ts +2 -43
  18. package/dist/mcp-auth-flow.js +3 -138
  19. package/dist/mcp-callback-server.js +1 -1
  20. package/dist/mcp-code.d.ts +0 -1
  21. package/dist/mcp-code.js +20 -7
  22. package/dist/mcp-oauth-callback-config.d.ts +8 -0
  23. package/dist/mcp-oauth-callback-config.js +28 -0
  24. package/dist/mcp-oauth-provider.d.ts +1 -8
  25. package/dist/mcp-oauth-provider.js +2 -28
  26. package/dist/mcp-oauth-runtime.d.ts +83 -0
  27. package/dist/mcp-oauth-runtime.js +145 -0
  28. package/dist/mcp-output-guard.d.ts +1 -1
  29. package/dist/mcp-output-guard.js +5 -108
  30. package/dist/mcp-output-preview.d.ts +29 -0
  31. package/dist/mcp-output-preview.js +220 -0
  32. package/dist/mcp-panel.js +1 -2
  33. package/dist/mcp-script-worker.mjs +14 -2
  34. package/dist/metadata-cache.d.ts +1 -1
  35. package/dist/metadata-cache.js +56 -14
  36. package/dist/plain-result-reader.d.ts +7 -0
  37. package/dist/plain-result-reader.js +201 -0
  38. package/dist/prompts.js +1 -1
  39. package/dist/proxy-modes.d.ts +1 -1
  40. package/dist/proxy-modes.js +14 -5
  41. package/dist/public-types.d.ts +2 -0
  42. package/dist/public-types.js +2 -0
  43. package/dist/resource-tools.d.ts +0 -1
  44. package/dist/resource-tools.js +0 -13
  45. package/dist/server-manager.d.ts +4 -4
  46. package/dist/server-manager.js +25 -125
  47. package/dist/tool-loader.js +10 -4
  48. package/dist/tool-metadata.js +20 -3
  49. package/dist/tool-result-renderer.d.ts +1 -1
  50. package/dist/tool-result-renderer.js +10 -3
  51. package/dist/types.d.ts +1 -1
  52. package/dist/types.js +0 -3
  53. package/dist/ui-message-history.js +3 -2
  54. package/dist/ui-resource-handler.d.ts +2 -1
  55. package/dist/ui-resource-handler.js +4 -3
  56. package/dist/ui-server.js +2 -1
  57. package/dist/ui-session.js +2 -1
  58. package/dist/utils.d.ts +1 -0
  59. package/dist/utils.js +1 -0
  60. package/package.json +7 -7
@@ -1,8 +1,9 @@
1
1
  import { randomBytes } from "node:crypto";
2
2
  import { mkdir, mkdtemp, open, readFile, realpath, writeFile } from "node:fs/promises";
3
- import { createReadStream } from "node:fs";
3
+ import { readPlainResult } from "./plain-result-reader.js";
4
4
  import { tmpdir } from "node:os";
5
5
  import { basename, dirname, join, resolve } from "node:path";
6
+ import { byteLength, sanitizeContent, textStats, truncatedOutput } from "./mcp-output-preview.js";
6
7
  export const DEFAULT_MCP_OUTPUT_MAX_BYTES = 50 * 1024;
7
8
  export const DEFAULT_MCP_OUTPUT_MAX_LINES = 2000;
8
9
  export const DEFAULT_MCP_DETAILS_MAX_BYTES = 16 * 1024;
@@ -61,23 +62,8 @@ export async function guardMcpOutput(content, options = {}) {
61
62
  let guardedContent = addAffixes(normalizedContent, prefix, suffix);
62
63
  let outputGuard;
63
64
  if (totalStats.bytes > maxBytes || totalStats.lines > maxLines) {
64
- const { path: fullOutputPath, error: writeError } = await saveArtifact("output", composedOutput, options.outputDirectory);
65
- const notice = formatTruncationNotice(stats, fullOutputPath, writeError);
66
- const previewBudget = reserveBudget(maxBytes, maxLines, notice + referenceSuffix);
67
- const preview = truncateHead(composedOutput, previewBudget.maxBytes, previewBudget.maxLines);
68
- const finalText = `${preview.content}\n\n${notice}`;
69
- const finalStats = textStats(finalText + referenceSuffix);
70
- guardedContent = [{ type: "text", text: finalText }, ...imageBlocks];
71
- outputGuard = {
72
- truncated: true,
73
- originalBytes: stats.bytes,
74
- returnedBytes: finalStats.bytes,
75
- originalLines: stats.lines,
76
- returnedLines: finalStats.lines,
77
- ...(imageBlocks.length > 0 ? { imageBlocksPassedThrough: imageBlocks.length } : {}),
78
- ...(fullOutputPath !== undefined ? { fullOutputPath } : {}),
79
- ...(writeError !== undefined ? { writeError } : {}),
80
- };
65
+ const artifact = await saveArtifact("output", composedOutput, options.outputDirectory);
66
+ ({ content: guardedContent, outputGuard } = truncatedOutput(composedOutput, stats, artifact, imageBlocks, maxBytes, maxLines, referenceSuffix));
81
67
  }
82
68
  return {
83
69
  content: withReference(guardedContent),
@@ -85,16 +71,6 @@ export async function guardMcpOutput(content, options = {}) {
85
71
  ...retained,
86
72
  };
87
73
  }
88
- function sanitizeContent(content) {
89
- return content.map((block) => {
90
- if (block.type !== "image")
91
- return block;
92
- const mimeType = typeof block.mimeType === "string" && block.mimeType.trim()
93
- ? block.mimeType.trim().slice(0, 100)
94
- : "image/png";
95
- return { ...block, mimeType };
96
- });
97
- }
98
74
  function withEmptyTextFallback(content, fallback) {
99
75
  if (!fallback)
100
76
  return content;
@@ -138,52 +114,6 @@ function addAffixes(content, prefix, suffix) {
138
114
  }
139
115
  return next;
140
116
  }
141
- function reserveBudget(maxBytes, maxLines, notice) {
142
- const noticeStats = textStats(`\n\n${notice}`);
143
- return {
144
- maxBytes: Math.max(0, maxBytes - noticeStats.bytes),
145
- maxLines: Math.max(0, maxLines - noticeStats.lines),
146
- };
147
- }
148
- function truncateHead(text, maxBytes, maxLines) {
149
- const lines = text.split("\n");
150
- const output = [];
151
- let bytes = 0;
152
- for (const line of lines) {
153
- if (output.length >= maxLines)
154
- break;
155
- const separatorBytes = output.length > 0 ? 1 : 0;
156
- const lineBytes = byteLength(line);
157
- if (bytes + separatorBytes + lineBytes > maxBytes) {
158
- const remaining = maxBytes - bytes - separatorBytes;
159
- if (remaining > 0) {
160
- output.push(truncateStringToBytes(line, remaining));
161
- }
162
- break;
163
- }
164
- output.push(line);
165
- bytes += separatorBytes + lineBytes;
166
- }
167
- const content = output.join("\n");
168
- const stats = textStats(content);
169
- return { content, bytes: stats.bytes, lines: stats.lines };
170
- }
171
- function truncateStringToBytes(value, maxBytes) {
172
- if (byteLength(value) <= maxBytes)
173
- return value;
174
- const buffer = Buffer.from(value, "utf8");
175
- let end = Number.isFinite(maxBytes) ? Math.max(0, Math.floor(maxBytes)) : 0;
176
- while (end > 0 && (buffer.readUInt8(end) & 0xc0) === 0x80)
177
- end--;
178
- return buffer.subarray(0, end).toString("utf8");
179
- }
180
- function formatTruncationNotice(stats, fullOutputPath, writeError) {
181
- const base = `[MCP text output truncated: original ${stats.lines.toLocaleString()} lines / ${formatSize(stats.bytes)}.`;
182
- if (fullOutputPath) {
183
- return `${base} Full text saved to: ${fullOutputPath} — use read with offset/limit or grep to inspect.]`;
184
- }
185
- return `${base} Full output could not be saved: ${writeError ?? "unknown error"}]`;
186
- }
187
117
  /**
188
118
  * Bound details.mcpResult: keep the raw result when its JSON fits within
189
119
  * detailsMaxBytes; otherwise replace it with a compact summary and spill the
@@ -359,27 +289,7 @@ export async function readMcpResult(input, options = {}) {
359
289
  throw new Error("offset must be a non-negative integer and limit a positive integer");
360
290
  const maxBytes = Math.max(1, (options.maxBytes ?? DEFAULT_MCP_OUTPUT_MAX_BYTES) - 256);
361
291
  const maxLines = Math.max(1, (options.maxLines ?? DEFAULT_MCP_OUTPUT_MAX_LINES) - 1);
362
- let total = 0, bytes = 0, lines = 1;
363
- let page = "";
364
- let finished = false;
365
- for await (const chunk of createReadStream(ref, { encoding: "utf8" })) {
366
- for (const char of chunk) {
367
- if (total < offset && total + char.length > offset)
368
- throw new Error("offset must not split a Unicode surrogate pair");
369
- if (total >= offset && !finished) {
370
- const nextBytes = bytes + byteLength(char);
371
- const nextLines = lines + (char === "\n" ? 1 : 0);
372
- if (page && (nextBytes > maxBytes || nextLines > maxLines || page.length + char.length > limit))
373
- finished = true;
374
- else {
375
- page += char;
376
- bytes = nextBytes;
377
- lines = nextLines;
378
- }
379
- }
380
- total += char.length;
381
- }
382
- }
292
+ const { page, total } = await readPlainResult(ref, offset, limit, maxBytes, maxLines);
383
293
  const end = offset + page.length;
384
294
  const nextOffset = end < total ? end : null;
385
295
  return { content: [{ type: "text", text: page }, ...(nextOffset !== null ? [{ type: "text", text: `[MCP result page: ${offset}–${end} of ${total} characters. Continue with offset: ${end}.]` }] : [])], details: { ref, offset, nextOffset, totalCharacters: total } };
@@ -460,12 +370,6 @@ function safeStringify(value) {
460
370
  return String(value);
461
371
  }
462
372
  }
463
- function textStats(text) {
464
- return { bytes: byteLength(text), lines: text.length === 0 ? 0 : text.split("\n").length };
465
- }
466
- function byteLength(text) {
467
- return Buffer.byteLength(text, "utf8");
468
- }
469
373
  function positiveInt(value) {
470
374
  if (typeof value !== "number" || !Number.isFinite(value))
471
375
  return undefined;
@@ -482,10 +386,3 @@ function envKillSwitch(name) {
482
386
  return true;
483
387
  return undefined;
484
388
  }
485
- function formatSize(bytes) {
486
- if (bytes < 1024)
487
- return `${bytes} B`;
488
- if (bytes < 1024 * 1024)
489
- return `${(bytes / 1024).toFixed(1)} KiB`;
490
- return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
491
- }
@@ -0,0 +1,29 @@
1
+ import type { GuardedMcpOutput, McpOutputGuardDetails, McpOutputGuardOptions } from "./mcp-output-guard.ts";
2
+ import type { ContentBlock } from "./types.ts";
3
+ /**
4
+ * Guard output that arrives one block at a time without retaining all of it. Blocks stay in
5
+ * memory while they fit the model-visible limits. After that, the joined text is appended to
6
+ * one private output artifact and only the preview head and image blocks are kept. Writes are
7
+ * synchronous, so a producer may be released as soon as push() returns.
8
+ */
9
+ export declare function createMcpOutputSpool(options: Pick<McpOutputGuardOptions, "enabled" | "outputDirectory"> & Required<Pick<McpOutputGuardOptions, "maxBytes" | "maxLines">>): {
10
+ readonly empty: boolean;
11
+ push(block: ContentBlock): void;
12
+ finish(): GuardedMcpOutput;
13
+ };
14
+ export declare function truncatedOutput(text: string, stats: {
15
+ bytes: number;
16
+ lines: number;
17
+ }, { path: fullOutputPath, error: writeError }: {
18
+ path?: string;
19
+ error?: string;
20
+ }, imageBlocks: ContentBlock[], maxBytes: number, maxLines: number, referenceSuffix?: string): {
21
+ content: ContentBlock[];
22
+ outputGuard: McpOutputGuardDetails;
23
+ };
24
+ export declare function sanitizeContent(content: ContentBlock[]): ContentBlock[];
25
+ export declare function textStats(text: string): {
26
+ bytes: number;
27
+ lines: number;
28
+ };
29
+ export declare function byteLength(text: string): number;
@@ -0,0 +1,220 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { closeSync, mkdirSync, mkdtempSync, openSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join, resolve } from "node:path";
5
+ // mcp-code.ts is imported on first use. A Pi process that loaded an older mcp-output-guard.ts
6
+ // before an update links the updated mcp-code.ts against it, so the spool cannot live there.
7
+ const SPOOL_WRITE_CHUNK = 1024 * 1024;
8
+ /**
9
+ * Guard output that arrives one block at a time without retaining all of it. Blocks stay in
10
+ * memory while they fit the model-visible limits. After that, the joined text is appended to
11
+ * one private output artifact and only the preview head and image blocks are kept. Writes are
12
+ * synchronous, so a producer may be released as soon as push() returns.
13
+ */
14
+ export function createMcpOutputSpool(options) {
15
+ const { maxBytes, maxLines } = options;
16
+ let retained = [];
17
+ const images = [];
18
+ let pushed = 0, textBlocks = 0, bytes = 0, newlines = 0, written = 0;
19
+ let head = "", headBytes = 0;
20
+ let artifact = {};
21
+ let fd;
22
+ let pending = [], pendingLength = 0;
23
+ const lines = () => bytes === 0 ? 0 : newlines + 1;
24
+ const fail = (error) => {
25
+ artifact = { error: error instanceof Error ? error.message : String(error) };
26
+ if (fd !== undefined)
27
+ try {
28
+ closeSync(fd);
29
+ }
30
+ catch { /* the write error is the reported failure */ }
31
+ fd = undefined;
32
+ };
33
+ const flush = () => {
34
+ if (fd !== undefined && pending.length > 0) {
35
+ try {
36
+ writeFileSync(fd, pending.length === 1 ? pending[0] : pending.join(""));
37
+ }
38
+ catch (error) {
39
+ fail(error);
40
+ }
41
+ }
42
+ pending = [];
43
+ pendingLength = 0;
44
+ };
45
+ const append = (piece) => {
46
+ if (headBytes <= maxBytes) {
47
+ // Each UTF-16 unit is at least one UTF-8 byte, so the head exceeds every preview budget.
48
+ let end = Math.min(piece.length, maxBytes + 1 - headBytes);
49
+ const last = piece.charCodeAt(end - 1);
50
+ if (end < piece.length && last >= 0xd800 && last <= 0xdbff)
51
+ end++;
52
+ const part = piece.slice(0, end);
53
+ head += part;
54
+ headBytes += byteLength(part);
55
+ }
56
+ if (fd === undefined)
57
+ return;
58
+ if (pendingLength + piece.length > SPOOL_WRITE_CHUNK)
59
+ flush();
60
+ pending.push(piece);
61
+ pendingLength += piece.length;
62
+ };
63
+ const appendText = (text) => {
64
+ if (written++ > 0)
65
+ append("\n");
66
+ append(text);
67
+ };
68
+ const spill = (blocks) => {
69
+ try {
70
+ const parent = options.outputDirectory === undefined ? tmpdir() : resolve(options.outputDirectory);
71
+ if (options.outputDirectory !== undefined)
72
+ mkdirSync(parent, { recursive: true, mode: 0o700 });
73
+ const path = join(mkdtempSync(join(parent, "pi-mcp-output-")), `output-${randomBytes(4).toString("hex")}.txt`);
74
+ fd = openSync(path, "wx", 0o600);
75
+ artifact = { path };
76
+ }
77
+ catch (error) {
78
+ fail(error);
79
+ }
80
+ for (const block of blocks) {
81
+ if (block.type === "image")
82
+ images.push(block);
83
+ else
84
+ appendText(block.text);
85
+ }
86
+ };
87
+ return {
88
+ get empty() { return pushed === 0; },
89
+ push(block) {
90
+ pushed++;
91
+ if (block.type === "image") {
92
+ (retained ?? images).push(...sanitizeContent([block]));
93
+ return;
94
+ }
95
+ const separator = textBlocks++ > 0 ? 1 : 0;
96
+ bytes += separator + byteLength(block.text);
97
+ newlines += separator + countNewlines(block.text);
98
+ if (!retained)
99
+ appendText(block.text);
100
+ else {
101
+ retained.push(block);
102
+ if (options.enabled !== false && (bytes > maxBytes || lines() > maxLines)) {
103
+ const blocks = retained;
104
+ retained = undefined;
105
+ spill(blocks);
106
+ }
107
+ }
108
+ },
109
+ finish() {
110
+ if (retained)
111
+ return { content: retained };
112
+ flush();
113
+ if (fd !== undefined) {
114
+ try {
115
+ closeSync(fd);
116
+ }
117
+ catch (error) {
118
+ artifact = { error: error instanceof Error ? error.message : String(error) };
119
+ }
120
+ fd = undefined;
121
+ }
122
+ return truncatedOutput(head, { bytes, lines: lines() }, artifact, images, maxBytes, maxLines);
123
+ },
124
+ };
125
+ }
126
+ export function truncatedOutput(text, stats, { path: fullOutputPath, error: writeError }, imageBlocks, maxBytes, maxLines, referenceSuffix = "") {
127
+ const notice = formatTruncationNotice(stats, fullOutputPath, writeError);
128
+ const previewBudget = reserveBudget(maxBytes, maxLines, notice + referenceSuffix);
129
+ const preview = truncateHead(text, previewBudget.maxBytes, previewBudget.maxLines);
130
+ const finalText = `${preview.content}\n\n${notice}`;
131
+ const finalStats = textStats(finalText + referenceSuffix);
132
+ return {
133
+ content: [{ type: "text", text: finalText }, ...imageBlocks],
134
+ outputGuard: {
135
+ truncated: true,
136
+ originalBytes: stats.bytes,
137
+ returnedBytes: finalStats.bytes,
138
+ originalLines: stats.lines,
139
+ returnedLines: finalStats.lines,
140
+ ...(imageBlocks.length > 0 ? { imageBlocksPassedThrough: imageBlocks.length } : {}),
141
+ ...(fullOutputPath !== undefined ? { fullOutputPath } : {}),
142
+ ...(writeError !== undefined ? { writeError } : {}),
143
+ },
144
+ };
145
+ }
146
+ function countNewlines(text) {
147
+ let count = 0;
148
+ for (let index = text.indexOf("\n"); index !== -1; index = text.indexOf("\n", index + 1))
149
+ count++;
150
+ return count;
151
+ }
152
+ export function sanitizeContent(content) {
153
+ return content.map((block) => {
154
+ if (block.type !== "image")
155
+ return block;
156
+ const mimeType = typeof block.mimeType === "string" && block.mimeType.trim()
157
+ ? block.mimeType.trim().slice(0, 100)
158
+ : "image/png";
159
+ return { ...block, mimeType };
160
+ });
161
+ }
162
+ function reserveBudget(maxBytes, maxLines, notice) {
163
+ const noticeStats = textStats(`\n\n${notice}`);
164
+ return {
165
+ maxBytes: Math.max(0, maxBytes - noticeStats.bytes),
166
+ maxLines: Math.max(0, maxLines - noticeStats.lines),
167
+ };
168
+ }
169
+ function truncateHead(text, maxBytes, maxLines) {
170
+ const lines = text.split("\n");
171
+ const output = [];
172
+ let bytes = 0;
173
+ for (const line of lines) {
174
+ if (output.length >= maxLines)
175
+ break;
176
+ const separatorBytes = output.length > 0 ? 1 : 0;
177
+ const lineBytes = byteLength(line);
178
+ if (bytes + separatorBytes + lineBytes > maxBytes) {
179
+ const remaining = maxBytes - bytes - separatorBytes;
180
+ if (remaining > 0) {
181
+ output.push(truncateStringToBytes(line, remaining));
182
+ }
183
+ break;
184
+ }
185
+ output.push(line);
186
+ bytes += separatorBytes + lineBytes;
187
+ }
188
+ const content = output.join("\n");
189
+ const stats = textStats(content);
190
+ return { content, bytes: stats.bytes, lines: stats.lines };
191
+ }
192
+ function truncateStringToBytes(value, maxBytes) {
193
+ if (byteLength(value) <= maxBytes)
194
+ return value;
195
+ const buffer = Buffer.from(value, "utf8");
196
+ let end = Number.isFinite(maxBytes) ? Math.max(0, Math.floor(maxBytes)) : 0;
197
+ while (end > 0 && (buffer.readUInt8(end) & 0xc0) === 0x80)
198
+ end--;
199
+ return buffer.subarray(0, end).toString("utf8");
200
+ }
201
+ function formatTruncationNotice(stats, fullOutputPath, writeError) {
202
+ const base = `[MCP text output truncated: original ${stats.lines.toLocaleString()} lines / ${formatSize(stats.bytes)}.`;
203
+ if (fullOutputPath) {
204
+ return `${base} Full text saved to: ${fullOutputPath} — use read with offset/limit or grep to inspect.]`;
205
+ }
206
+ return `${base} Full output could not be saved: ${writeError ?? "unknown error"}]`;
207
+ }
208
+ export function textStats(text) {
209
+ return { bytes: byteLength(text), lines: text.length === 0 ? 0 : text.split("\n").length };
210
+ }
211
+ export function byteLength(text) {
212
+ return Buffer.byteLength(text, "utf8");
213
+ }
214
+ function formatSize(bytes) {
215
+ if (bytes < 1024)
216
+ return `${bytes} B`;
217
+ if (bytes < 1024 * 1024)
218
+ return `${(bytes / 1024).toFixed(1)} KiB`;
219
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
220
+ }
package/dist/mcp-panel.js CHANGED
@@ -1,5 +1,4 @@
1
1
  import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
- import { copyToClipboard } from "@earendil-works/pi-coding-agent";
3
2
  import { createPanelKeys } from "./panel-keys.js";
4
3
  import { sanitizeTerminalText, stripOscSequences } from "./utils.js";
5
4
  import { reconstructToolMetadata } from "./metadata-cache.js";
@@ -380,7 +379,7 @@ class McpPanel {
380
379
  return;
381
380
  const serverName = sanitizeDisplayText(server.name);
382
381
  const failureMessage = sanitizeDisplayText(server.failureMessage);
383
- copyToClipboard(failureMessage).then(() => {
382
+ import("@earendil-works/pi-coding-agent").then(({ copyToClipboard }) => copyToClipboard(failureMessage)).then(() => {
384
383
  this.authNotice = `Copied error for ${serverName} to clipboard`;
385
384
  this.tui.requestRender();
386
385
  }).catch((error) => {
@@ -4,6 +4,7 @@ import vm from "node:vm";
4
4
 
5
5
  const TOOLS_ENUMERATION_ERROR = "tools is not enumerable — use tools.search({ query })";
6
6
  const RESERVED_TOOL_PROPS = new Set(["then", "catch", "finally", "toJSON", "toString", "valueOf"]);
7
+ const { code, outputFlow, outputWindow } = workerData;
7
8
 
8
9
  // Keep this formatting logic in sync with mcp-code.ts; the standalone worker cannot import the TypeScript host module.
9
10
  function needsInspectableFormatting(value, stack = new WeakSet()) {
@@ -98,7 +99,18 @@ const tools = new Proxy(Object.create(null), {
98
99
  });
99
100
 
100
101
  const emit = (value) => {
101
- parentPort.postMessage({ type: "emit", block: toContentBlock(value) });
102
+ const block = toContentBlock(value);
103
+ // A running Pi keeps the adapter it loaded but starts this file from disk, so an older host passes no window.
104
+ if (!outputFlow) {
105
+ parentPort.postMessage({ type: "emit", block });
106
+ return;
107
+ }
108
+ // Charge each queued message too, so many tiny emits cannot outrun the host.
109
+ const size = (block.type === "text" ? block.text.length : block.data.length) + 256;
110
+ Atomics.add(outputFlow, 0, size);
111
+ parentPort.postMessage({ type: "emit", block, size });
112
+ // Block this worker, not the host, until the host consumes queued output.
113
+ for (let queued; (queued = Atomics.load(outputFlow, 0)) > outputWindow;) Atomics.wait(outputFlow, 0, queued);
102
114
  };
103
115
 
104
116
  const capturedConsole = Object.freeze({
@@ -118,7 +130,7 @@ try {
118
130
  codeGeneration: { strings: false, wasm: false },
119
131
  name: "mcp_script",
120
132
  });
121
- const script = new vm.Script(`(async () => {\n${workerData.code}\n})()`, { filename: "mcp_script.js" });
133
+ const script = new vm.Script(`(async () => {\n${code}\n})()`, { filename: "mcp_script.js" });
122
134
  const returnValue = await Promise.resolve(script.runInContext(context));
123
135
  parentPort.postMessage(returnValue === undefined
124
136
  ? { type: "done" }
@@ -3,7 +3,7 @@ import { type ToolPrefix } from "./types.ts";
3
3
  export type { CachedPrompt, CachedResource, CachedTool, MetadataCache, ServerCacheEntry } from "./types.ts";
4
4
  export declare function getMetadataCachePath(): string;
5
5
  export declare function loadMetadataCache(enabled?: boolean): MetadataCache | null;
6
- export declare function saveMetadataCache(cache: MetadataCache, enabled?: boolean): void;
6
+ export declare function saveMetadataCache(cache: MetadataCache, enabled?: boolean, selectedServers?: Iterable<string>): void;
7
7
  export declare function computeServerHash(definition: ServerEntry): string;
8
8
  export declare function isServerCacheValid(entry: ServerCacheEntry, definition: ServerEntry, maxAgeMs?: number): boolean;
9
9
  export declare function parseDirectToolSelectors(selectors: string[]): {
@@ -1,5 +1,5 @@
1
1
  // metadata-cache.ts - Persistent MCP metadata cache
2
- import { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs";
2
+ import { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync, statSync } from "node:fs";
3
3
  import { dirname } from "node:path";
4
4
  import { getAdapterPath } from "./agent-dir.js";
5
5
  import { createHash } from "node:crypto";
@@ -9,6 +9,20 @@ import { interpolateEnvRecord, resolveBearerToken, resolveConfigPath, resolveSer
9
9
  // Version 2 stores raw descriptors; version 1 readers cannot enforce raw UI visibility.
10
10
  const CACHE_VERSION = 2;
11
11
  const CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
12
+ let snapshot;
13
+ const toolProjections = new WeakMap();
14
+ function cacheRevision(path) {
15
+ const stat = statSync(path, { bigint: true });
16
+ return `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}`;
17
+ }
18
+ function freezeMetadata(value) {
19
+ if (value && typeof value === "object" && !Object.isFrozen(value)) {
20
+ for (const child of Object.values(value))
21
+ freezeMetadata(child);
22
+ Object.freeze(value);
23
+ }
24
+ return value;
25
+ }
12
26
  export function getMetadataCachePath() {
13
27
  return getAdapterPath("mcp-cache.json");
14
28
  }
@@ -16,23 +30,25 @@ export function loadMetadataCache(enabled = true) {
16
30
  if (!enabled)
17
31
  return null;
18
32
  const cachePath = getMetadataCachePath();
19
- if (!existsSync(cachePath))
20
- return null;
21
33
  try {
34
+ const revision = cacheRevision(cachePath);
35
+ if (snapshot?.path === cachePath && snapshot.revision === revision)
36
+ return snapshot.cache;
22
37
  const raw = JSON.parse(readFileSync(cachePath, "utf-8"));
23
- if (!raw || typeof raw !== "object")
24
- return null;
25
- if (raw.version !== 1 && raw.version !== CACHE_VERSION)
26
- return null;
27
- if (!raw.servers || typeof raw.servers !== "object")
28
- return null;
29
- return raw;
38
+ const cache = raw && typeof raw === "object" && !Array.isArray(raw)
39
+ && (raw.version === 1 || raw.version === CACHE_VERSION)
40
+ && raw.servers && typeof raw.servers === "object" && !Array.isArray(raw.servers)
41
+ ? freezeMetadata(raw) : null;
42
+ // A concurrent rename must not associate the old bytes with the new file's revision.
43
+ snapshot = cacheRevision(cachePath) === revision ? { path: cachePath, revision, cache } : undefined;
44
+ return cache;
30
45
  }
31
46
  catch {
47
+ snapshot = undefined;
32
48
  return null;
33
49
  }
34
50
  }
35
- export function saveMetadataCache(cache, enabled = true) {
51
+ export function saveMetadataCache(cache, enabled = true, selectedServers = []) {
36
52
  if (!enabled)
37
53
  return;
38
54
  const cachePath = getMetadataCachePath();
@@ -52,6 +68,14 @@ export function saveMetadataCache(cache, enabled = true) {
52
68
  }
53
69
  merged.version = CACHE_VERSION;
54
70
  merged.servers = { ...merged.servers, ...cache.servers };
71
+ const protectedServers = new Set([...selectedServers, ...Object.keys(cache.servers)]);
72
+ const now = Date.now();
73
+ for (const [name, entry] of Object.entries(merged.servers)) {
74
+ if (!protectedServers.has(name) && isCacheEntry(entry)
75
+ && (entry.cachedAt > now || now - entry.cachedAt > CACHE_MAX_AGE_MS)) {
76
+ delete merged.servers[name];
77
+ }
78
+ }
55
79
  const tmpPath = `${cachePath}.${process.pid}.tmp`;
56
80
  writeFileSync(tmpPath, JSON.stringify(merged, null, 2), "utf-8");
57
81
  renameSync(tmpPath, cachePath);
@@ -86,14 +110,24 @@ export function isServerCacheValid(entry, definition, maxAgeMs = CACHE_MAX_AGE_M
86
110
  catch {
87
111
  return false;
88
112
  }
89
- if (!entry || entry.configHash !== configHash)
113
+ if (!isCacheEntry(entry) || entry.configHash !== configHash)
90
114
  return false;
91
- if (!entry.cachedAt || typeof entry.cachedAt !== "number")
115
+ if (entry.cachedAt > Date.now())
92
116
  return false;
93
117
  if (maxAgeMs > 0 && Date.now() - entry.cachedAt > maxAgeMs)
94
118
  return false;
95
119
  return true;
96
120
  }
121
+ function isCacheEntry(entry) {
122
+ if (!entry || typeof entry !== "object" || Array.isArray(entry))
123
+ return false;
124
+ const value = entry;
125
+ return typeof value.configHash === "string"
126
+ && typeof value.cachedAt === "number" && Number.isFinite(value.cachedAt) && value.cachedAt > 0
127
+ && Array.isArray(value.tools) && Array.isArray(value.resources)
128
+ && (value.prompts === undefined || Array.isArray(value.prompts))
129
+ && (value.instructions === undefined || typeof value.instructions === "string");
130
+ }
97
131
  export function parseDirectToolSelectors(selectors) {
98
132
  const servers = new Set();
99
133
  const tools = new Map();
@@ -138,7 +172,15 @@ export function getMissingConfiguredDirectToolServers(config, cache, envOverride
138
172
  return missing;
139
173
  }
140
174
  export function reconstructToolMetadata(serverName, entry, prefix, definition) {
141
- return buildToolMetadata((entry.tools ?? []).filter(tool => tool?.name).map(restoreCachedTool), entry.resources ?? [], definition, serverName, prefix).metadata;
175
+ const key = JSON.stringify([serverName, prefix, definition.exposeResources, definition.includeTools,
176
+ definition.excludeTools, definition.toolPrefix, definition.disabled]);
177
+ const previous = toolProjections.get(entry);
178
+ if (Object.isFrozen(entry) && previous?.key === key)
179
+ return previous.metadata;
180
+ const metadata = buildToolMetadata((entry.tools ?? []).filter(tool => tool?.name).map(restoreCachedTool), entry.resources ?? [], definition, serverName, prefix).metadata;
181
+ if (Object.isFrozen(entry))
182
+ toolProjections.set(entry, { key, metadata: freezeMetadata(metadata) });
183
+ return metadata;
142
184
  }
143
185
  function restoreCachedTool(tool) {
144
186
  const { uiResourceUri, uiVisibility, uiStreamMode, ...descriptor } = tool;
@@ -0,0 +1,7 @@
1
+ /** Only the native, synchronous history writer can certify an append. External changes invalidate. */
2
+ export declare function appendPlainOutput(ref: string, text: string): void;
3
+ /** Sparse byte/UTF16 checkpoints, never retained file contents. Changed/uncacheable files scan afresh. */
4
+ export declare function readPlainResult(ref: string, offset: number, limit: number, maxBytes: number, maxLines: number): Promise<{
5
+ page: string;
6
+ total: number;
7
+ }>;