@fitchmultz/pi-mcp-adapter 8.0.0 → 8.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +36 -7
- package/LICENSE +1 -0
- package/OAUTH.md +3 -3
- package/README.md +43 -858
- package/dist/app-bridge.bundle.js +14 -14
- package/dist/client-operations.d.ts +5 -0
- package/dist/client-operations.js +19 -0
- package/dist/commands.d.ts +1 -1
- package/dist/commands.js +3 -1
- package/dist/config.js +1 -1
- package/dist/connection-runtime.d.ts +503 -0
- package/dist/connection-runtime.js +98 -0
- package/dist/direct-tools.js +1 -1
- package/dist/host-html-template.js +24 -12
- package/dist/index.js +36 -29
- package/dist/init.d.ts +1 -1
- package/dist/init.js +66 -38
- package/dist/lifecycle.d.ts +1 -1
- package/dist/lifecycle.js +1 -1
- package/dist/mcp-auth-flow.d.ts +2 -43
- package/dist/mcp-auth-flow.js +3 -138
- package/dist/mcp-callback-server.js +1 -1
- package/dist/mcp-code.d.ts +0 -1
- package/dist/mcp-code.js +20 -7
- package/dist/mcp-oauth-callback-config.d.ts +8 -0
- package/dist/mcp-oauth-callback-config.js +28 -0
- package/dist/mcp-oauth-provider.d.ts +1 -8
- package/dist/mcp-oauth-provider.js +2 -28
- package/dist/mcp-oauth-runtime.d.ts +83 -0
- package/dist/mcp-oauth-runtime.js +145 -0
- package/dist/mcp-output-guard.d.ts +1 -1
- package/dist/mcp-output-guard.js +5 -108
- package/dist/mcp-output-preview.d.ts +29 -0
- package/dist/mcp-output-preview.js +220 -0
- package/dist/mcp-panel.js +1 -2
- package/dist/mcp-script-worker.mjs +14 -2
- package/dist/mcp-setup-panel.js +1 -1
- package/dist/metadata-cache.d.ts +1 -1
- package/dist/metadata-cache.js +56 -14
- package/dist/plain-result-reader.d.ts +7 -0
- package/dist/plain-result-reader.js +201 -0
- package/dist/prompts.js +1 -1
- package/dist/proxy-modes.d.ts +1 -1
- package/dist/proxy-modes.js +14 -5
- package/dist/public-types.d.ts +2 -0
- package/dist/public-types.js +2 -0
- package/dist/resource-tools.d.ts +0 -1
- package/dist/resource-tools.js +0 -13
- package/dist/server-manager.d.ts +4 -4
- package/dist/server-manager.js +25 -125
- package/dist/tool-loader.js +10 -4
- package/dist/tool-metadata.js +20 -3
- package/dist/tool-result-renderer.d.ts +1 -1
- package/dist/tool-result-renderer.js +10 -3
- package/dist/types.d.ts +1 -1
- package/dist/types.js +0 -3
- package/dist/ui-message-history.js +3 -2
- package/dist/ui-resource-handler.d.ts +2 -1
- package/dist/ui-resource-handler.js +4 -3
- package/dist/ui-server.js +2 -1
- package/dist/ui-session.js +2 -1
- package/dist/utils.d.ts +1 -0
- package/dist/utils.js +1 -0
- package/package.json +10 -13
package/dist/mcp-output-guard.js
CHANGED
|
@@ -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 {
|
|
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
|
|
65
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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${
|
|
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" }
|
package/dist/mcp-setup-panel.js
CHANGED
package/dist/metadata-cache.d.ts
CHANGED
|
@@ -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[]): {
|
package/dist/metadata-cache.js
CHANGED
|
@@ -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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
return
|
|
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 (
|
|
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
|
-
|
|
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
|
+
}>;
|