@fitchmultz/pi-mcp-adapter 5.0.0 → 6.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -1
- package/OAUTH.md +3 -3
- package/README.md +153 -85
- package/app-bridge.bundle.js +75 -33
- package/commands.ts +9 -6
- package/direct-tools.ts +25 -358
- package/dist/app-bridge.bundle.js +75 -33
- package/dist/commands.js +9 -6
- package/dist/direct-tools.d.ts +2 -4
- package/dist/direct-tools.js +21 -319
- package/dist/gateway-arguments.d.ts +33 -0
- package/dist/gateway-arguments.js +63 -0
- package/dist/index.js +181 -276
- package/dist/init.js +6 -20
- package/dist/mcp-code.d.ts +2 -1
- package/dist/mcp-code.js +87 -41
- package/dist/mcp-output-guard.d.ts +57 -2
- package/dist/mcp-output-guard.js +129 -24
- package/dist/mcp-panel.d.ts +1 -1
- package/dist/mcp-panel.js +34 -97
- package/dist/mcp-script-worker.mjs +5 -2
- package/dist/mcp-status.js +2 -1
- package/dist/metadata-cache.d.ts +1 -1
- package/dist/metadata-cache.js +25 -88
- package/dist/prompts.d.ts +1 -1
- package/dist/prompts.js +1 -1
- package/dist/proxy-modes.d.ts +40 -6
- package/dist/proxy-modes.js +270 -485
- package/dist/resource-tools.d.ts +15 -0
- package/dist/resource-tools.js +26 -1
- package/dist/sampling-handler.js +4 -1
- package/dist/search-ranking.d.ts +0 -2
- package/dist/search-ranking.js +66 -137
- package/dist/state.d.ts +1 -1
- package/dist/tool-loader.d.ts +32 -0
- package/dist/tool-loader.js +152 -0
- package/dist/tool-metadata.d.ts +67 -0
- package/dist/tool-metadata.js +23 -17
- package/dist/tool-registrar.d.ts +4 -4
- package/dist/tool-registrar.js +18 -13
- package/dist/tool-result-renderer.d.ts +3 -0
- package/dist/tool-result-renderer.js +8 -3
- package/dist/types.d.ts +11 -36
- package/dist/utils.js +2 -4
- package/gateway-arguments.ts +73 -0
- package/index.ts +166 -299
- package/init.ts +6 -20
- package/mcp-code.ts +71 -45
- package/mcp-output-guard.ts +145 -27
- package/mcp-panel.ts +38 -109
- package/mcp-script-worker.mjs +5 -2
- package/mcp-status.ts +2 -1
- package/metadata-cache.ts +31 -93
- package/package.json +14 -9
- package/prompts.ts +1 -1
- package/proxy-modes.ts +265 -517
- package/resource-tools.ts +36 -1
- package/sampling-handler.ts +4 -1
- package/scripts/build-app-bridge.mjs +15 -0
- package/scripts/build.mjs +3 -2
- package/scripts/prepare.mjs +1 -0
- package/search-ranking.ts +71 -135
- package/skills/mcp-scripting/SKILL.md +67 -20
- package/state.ts +1 -1
- package/tool-loader.ts +153 -0
- package/tool-metadata.ts +22 -17
- package/tool-registrar.ts +18 -13
- package/tool-result-renderer.ts +10 -2
- package/types.ts +15 -40
- package/utils.ts +2 -4
- package/dist/ts-shape.d.ts +0 -2
- package/dist/ts-shape.js +0 -147
- package/ts-shape.ts +0 -145
package/dist/mcp-code.d.ts
CHANGED
|
@@ -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
|
|
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 {
|
|
8
|
-
import {
|
|
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"
|
|
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
|
|
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
|
-
|
|
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:
|
|
168
|
+
data: details.mcpResult !== undefined ? details.mcpResult : textFromContent(result.content),
|
|
169
|
+
...(details.resultRef ? { resultRef: details.resultRef } : {}),
|
|
165
170
|
};
|
|
166
171
|
};
|
|
167
|
-
const
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
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 ?
|
|
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
|
|
42
|
-
*
|
|
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
|
+
}>;
|
package/dist/mcp-output-guard.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
|
-
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
|
|
2
|
+
import { mkdir, mkdtemp, readFile, realpath, writeFile } from "node:fs/promises";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
|
-
import { join, resolve } from "node:path";
|
|
4
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
5
5
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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 (
|
|
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
|
-
...
|
|
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
|
|
191
|
+
export async function retainMcpResult(result, options = {}, rawResult = false) {
|
|
191
192
|
const raw = safeStringify(result);
|
|
192
193
|
const rawBytes = byteLength(raw);
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
198
|
-
|
|
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")}
|
|
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
|
}
|
package/dist/mcp-panel.d.ts
CHANGED
|
@@ -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
|
|
3
|
+
import { type MetadataCache } from "./metadata-cache.ts";
|
|
4
4
|
declare class McpPanel {
|
|
5
5
|
private callbacks;
|
|
6
6
|
private done;
|