@fitchmultz/pi-mcp-adapter 7.1.1 → 8.0.1
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 +30 -7
- package/LICENSE +1 -0
- package/OAUTH.md +4 -4
- package/README.md +43 -860
- package/dist/app-bridge.bundle.js +13 -13
- package/dist/config.js +1 -1
- package/dist/direct-tools.js +6 -5
- package/dist/error-signal.d.ts +3 -5
- package/dist/error-signal.js +3 -5
- package/dist/index.js +0 -11
- package/dist/init.js +0 -1
- package/dist/lifecycle.d.ts +0 -4
- package/dist/lifecycle.js +1 -14
- package/dist/mcp-auth-flow.d.ts +3 -9
- package/dist/mcp-auth-flow.js +4 -43
- package/dist/mcp-code.js +0 -1
- package/dist/mcp-output-guard.d.ts +5 -0
- package/dist/mcp-output-guard.js +55 -1
- package/dist/mcp-setup-panel.js +1 -1
- package/dist/proxy-modes.d.ts +1 -1
- package/dist/proxy-modes.js +89 -9
- package/dist/runtime-owner.d.ts +0 -4
- package/dist/runtime-owner.js +5 -18
- package/dist/server-manager.d.ts +2 -9
- package/dist/server-manager.js +7 -99
- package/dist/tool-loader.js +35 -6
- package/dist/tool-registrar.d.ts +11 -0
- package/dist/tool-registrar.js +23 -0
- package/dist/types.d.ts +5 -0
- package/dist/ui-message-history.d.ts +11 -0
- package/dist/ui-message-history.js +81 -0
- package/dist/ui-server.js +17 -10
- package/dist/ui-session.js +3 -2
- package/package.json +10 -13
- package/dist/checkpoint.d.ts +0 -18
- package/dist/checkpoint.js +0 -53
package/dist/runtime-owner.js
CHANGED
|
@@ -3,12 +3,10 @@ export function createMcpRuntimeOwner() {
|
|
|
3
3
|
const controller = new AbortController();
|
|
4
4
|
const cleanups = [];
|
|
5
5
|
let stopPromise;
|
|
6
|
-
let checkpoint;
|
|
7
|
-
const beforeActivity = () => checkpoint?.invalidate();
|
|
8
6
|
// These two host hooks can outlive abortable() at their call sites. Count their underlying
|
|
9
7
|
// promises through settlement, without cancelling, serializing, or replaying callback work.
|
|
10
8
|
const callbacks = { beforeExecute: 0, onToolCall: 0 };
|
|
11
|
-
const
|
|
9
|
+
const pendingCallback = () => {
|
|
12
10
|
if (callbacks.beforeExecute)
|
|
13
11
|
return "MCP beforeExecute callback is pending";
|
|
14
12
|
if (callbacks.onToolCall)
|
|
@@ -20,10 +18,7 @@ export function createMcpRuntimeOwner() {
|
|
|
20
18
|
};
|
|
21
19
|
return {
|
|
22
20
|
signal: controller.signal,
|
|
23
|
-
beforeActivity,
|
|
24
|
-
getCheckpointBlocker,
|
|
25
21
|
runCallback: async (kind, run) => {
|
|
26
|
-
beforeActivity();
|
|
27
22
|
// Invocation/cancellation policy stays at the call site. In particular, an accepted
|
|
28
23
|
// tool's outcome callback may still need to persist its result during shutdown.
|
|
29
24
|
callbacks[kind]++;
|
|
@@ -34,13 +29,6 @@ export function createMcpRuntimeOwner() {
|
|
|
34
29
|
callbacks[kind]--;
|
|
35
30
|
}
|
|
36
31
|
},
|
|
37
|
-
holdCheckpoint: event => {
|
|
38
|
-
if (checkpoint)
|
|
39
|
-
throw new Error("MCP checkpoint is already held");
|
|
40
|
-
checkpoint = event;
|
|
41
|
-
return () => { if (checkpoint === event)
|
|
42
|
-
checkpoint = undefined; };
|
|
43
|
-
},
|
|
44
32
|
isActive: () => !controller.signal.aborted,
|
|
45
33
|
addCleanup: cleanup => {
|
|
46
34
|
if (controller.signal.aborted) {
|
|
@@ -52,14 +40,13 @@ export function createMcpRuntimeOwner() {
|
|
|
52
40
|
stop: (reason = "MCP extension runtime stopped") => {
|
|
53
41
|
if (stopPromise)
|
|
54
42
|
return stopPromise;
|
|
55
|
-
beforeActivity();
|
|
56
43
|
controller.abort(new Error(reason));
|
|
57
44
|
const pendingCleanups = cleanups.splice(0).reverse().map(cleanup => Promise.resolve().then(cleanup));
|
|
58
45
|
stopPromise = Promise.allSettled(pendingCleanups).then(results => {
|
|
59
46
|
const failures = results.flatMap(result => result.status === "rejected" ? [result.reason] : []);
|
|
60
|
-
const
|
|
61
|
-
if (
|
|
62
|
-
failures.push(new Error(
|
|
47
|
+
const callback = pendingCallback();
|
|
48
|
+
if (callback)
|
|
49
|
+
failures.push(new Error(callback));
|
|
63
50
|
if (failures.length > 0) {
|
|
64
51
|
const aggregate = new AggregateError(failures, "MCP runtime cleanup failed");
|
|
65
52
|
console.error(`MCP: runtime cleanup failed: ${formatTerminalError(aggregate)}`);
|
|
@@ -68,7 +55,7 @@ export function createMcpRuntimeOwner() {
|
|
|
68
55
|
});
|
|
69
56
|
return stopPromise;
|
|
70
57
|
},
|
|
71
|
-
throwIfInactive: () => {
|
|
58
|
+
throwIfInactive: () => { controller.signal.throwIfAborted(); },
|
|
72
59
|
};
|
|
73
60
|
}
|
|
74
61
|
export function combineAbortSignals(...signals) {
|
package/dist/server-manager.d.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import type { McpCheckpointEvent } from "./checkpoint.ts";
|
|
2
1
|
import { Client, type RequestOptions, type GetPromptResult, type ReadResourceResult, type UrlElicitationRequiredError } from "@modelcontextprotocol/client";
|
|
3
2
|
import { type McpTool, type McpResource, type McpPrompt, type ServerDefinition, type ServerStreamResultPatchNotification, type Transport, type McpTraceSettings } from "./types.ts";
|
|
4
3
|
import { type McpOAuthRuntime } from "./mcp-auth-flow.ts";
|
|
@@ -45,15 +44,9 @@ export declare class McpServerManager {
|
|
|
45
44
|
private traceSettings;
|
|
46
45
|
private traceWriter;
|
|
47
46
|
private stopped;
|
|
48
|
-
private checkpoint;
|
|
49
|
-
private readonly beforeActivity;
|
|
50
|
-
holdCheckpoint(event: McpCheckpointEvent): () => void;
|
|
51
|
-
getCheckpointBlocker(): string | undefined;
|
|
52
|
-
flushForCheckpoint(): Promise<void>;
|
|
53
47
|
private releaseClientWhenSettled;
|
|
54
|
-
/**
|
|
55
|
-
|
|
56
|
-
private fenceTransport;
|
|
48
|
+
/** Own native send/auth tails even after a public request has been cancelled. */
|
|
49
|
+
private ownTransportSends;
|
|
57
50
|
/** Default cwd for stdio servers without an explicit config `cwd`. */
|
|
58
51
|
private readonly defaultCwd;
|
|
59
52
|
constructor(defaultCwd?: string);
|
package/dist/server-manager.js
CHANGED
|
@@ -41,8 +41,6 @@ async function drainClientOperations(client) {
|
|
|
41
41
|
class ManagedClient extends Client {
|
|
42
42
|
serverName = "";
|
|
43
43
|
oauthProvider;
|
|
44
|
-
beforeActivity = () => { };
|
|
45
|
-
resourceSubscriptions = new Set();
|
|
46
44
|
own(operation, kind) {
|
|
47
45
|
let pending = clientOperations.get(this);
|
|
48
46
|
if (!pending)
|
|
@@ -53,21 +51,17 @@ class ManagedClient extends Client {
|
|
|
53
51
|
return operation;
|
|
54
52
|
}
|
|
55
53
|
ownNative(run) {
|
|
56
|
-
this.beforeActivity();
|
|
57
54
|
return this.own(run(), "request/refresh");
|
|
58
55
|
}
|
|
59
56
|
ownSend(run) {
|
|
60
|
-
this.beforeActivity();
|
|
61
57
|
// SDK-generated callback replies also perform HTTP/auth work, without calling callTool().
|
|
62
58
|
return this.own(Promise.resolve().then(run), "request/refresh");
|
|
63
59
|
}
|
|
64
60
|
ownCallback(kind, run) {
|
|
65
|
-
this.beforeActivity();
|
|
66
61
|
// Insert before dispatch. Cancellation/close of the SDK response does not settle this promise.
|
|
67
62
|
return this.own(Promise.resolve().then(run), kind);
|
|
68
63
|
}
|
|
69
64
|
async track(start, signal) {
|
|
70
|
-
this.beforeActivity();
|
|
71
65
|
const controller = this.oauthProvider ? new AbortController() : undefined;
|
|
72
66
|
let removeAbortListener = () => { };
|
|
73
67
|
const operation = this.oauthProvider
|
|
@@ -119,19 +113,11 @@ class ManagedClient extends Client {
|
|
|
119
113
|
subscribeResource(...args) {
|
|
120
114
|
return this.track(async (signal) => {
|
|
121
115
|
throwIfAborted(signal);
|
|
122
|
-
// A cancelled/lost response does not prove the server rejected the subscription.
|
|
123
|
-
// Retain the attempted URI until an explicit unsubscribe succeeds or the connection is discarded.
|
|
124
|
-
if (this.getServerCapabilities()?.resources?.subscribe)
|
|
125
|
-
this.resourceSubscriptions.add(args[0].uri);
|
|
126
116
|
return super.subscribeResource(args[0], { ...args[1], ...(signal ? { signal } : {}) });
|
|
127
117
|
}, args[1]?.signal);
|
|
128
118
|
}
|
|
129
119
|
unsubscribeResource(...args) {
|
|
130
|
-
return this.track(
|
|
131
|
-
const result = await super.unsubscribeResource(args[0], { ...args[1], ...(signal ? { signal } : {}) });
|
|
132
|
-
this.resourceSubscriptions.delete(args[0].uri);
|
|
133
|
-
return result;
|
|
134
|
-
}, args[1]?.signal);
|
|
120
|
+
return this.track(signal => super.unsubscribeResource(args[0], { ...args[1], ...(signal ? { signal } : {}) }), args[1]?.signal);
|
|
135
121
|
}
|
|
136
122
|
}
|
|
137
123
|
function boundedStderrChunk(chunk) {
|
|
@@ -184,85 +170,15 @@ export class McpServerManager {
|
|
|
184
170
|
traceSettings;
|
|
185
171
|
traceWriter;
|
|
186
172
|
stopped = false;
|
|
187
|
-
checkpoint;
|
|
188
|
-
beforeActivity = () => this.checkpoint?.invalidate();
|
|
189
|
-
holdCheckpoint(event) {
|
|
190
|
-
if (this.checkpoint)
|
|
191
|
-
throw new Error("MCP manager checkpoint is already held");
|
|
192
|
-
this.checkpoint = event;
|
|
193
|
-
return () => { if (this.checkpoint === event)
|
|
194
|
-
this.checkpoint = undefined; };
|
|
195
|
-
}
|
|
196
|
-
getCheckpointBlocker() {
|
|
197
|
-
if (this.stopped)
|
|
198
|
-
return "MCP manager is stopped";
|
|
199
|
-
if (this.connectPromises.size || this.reconnectPromises.size || this.closePromises.size || this.retiredConnections.size)
|
|
200
|
-
return "MCP connection/discovery/retirement is active";
|
|
201
|
-
if (this.acceptedUrlElicitations.size || this.uiStreamListeners.size)
|
|
202
|
-
return "MCP browser elicitation/UI stream is pending";
|
|
203
|
-
for (const client of this.ownedClients) {
|
|
204
|
-
// Only the SDK's exact auto-opened catalog handle reconstructs during connect().
|
|
205
|
-
// An explicit consumer listen(), even with the same filter, still owns private state.
|
|
206
|
-
const kinds = [...(clientOperations.get(client) ?? [])]
|
|
207
|
-
.filter(([operation, kind]) => kind !== "subscription" || operation !== client.autoOpenedSubscription?.closed)
|
|
208
|
-
.map(([, kind]) => kind);
|
|
209
|
-
const callback = kinds.find(kind => kind === "sampling callback" || kind === "elicitation callback");
|
|
210
|
-
if (callback)
|
|
211
|
-
return `MCP ${client.serverName}: ${callback} is active`;
|
|
212
|
-
if (kinds.includes("request/refresh"))
|
|
213
|
-
return `MCP ${client.serverName}: request/refresh is active`;
|
|
214
|
-
if (kinds.includes("subscription"))
|
|
215
|
-
return `MCP ${client.serverName}: subscription is active`;
|
|
216
|
-
}
|
|
217
|
-
for (const [name, connection] of this.connections) {
|
|
218
|
-
if (connection.inFlight)
|
|
219
|
-
return `MCP ${name}: request/refresh is active`;
|
|
220
|
-
if (connection.client instanceof ManagedClient && connection.client.resourceSubscriptions.size)
|
|
221
|
-
return `MCP ${name}: resource subscription is active or unresolved`;
|
|
222
|
-
if (!(connection.transport instanceof StreamableHTTPClientTransport))
|
|
223
|
-
return `MCP ${name}: only stateless Streamable HTTP is checkpoint-supported`;
|
|
224
|
-
if (connection.transport.sessionId !== undefined)
|
|
225
|
-
return `MCP ${name}: HTTP session state is not reconstructible`;
|
|
226
|
-
const capabilities = connection.client.getServerCapabilities();
|
|
227
|
-
if (capabilities?.tasks)
|
|
228
|
-
return `MCP ${name}: negotiated remote task state is not checkpoint-supported`;
|
|
229
|
-
const opaque = Object.keys(capabilities ?? {}).find(key => !["tools", "resources", "prompts", "logging", "completions"].includes(key)
|
|
230
|
-
&& !(key === "experimental" && Object.keys(capabilities?.experimental ?? {}).length === 0));
|
|
231
|
-
if (opaque)
|
|
232
|
-
return `MCP ${name}: unsupported negotiated capability ${opaque}`;
|
|
233
|
-
}
|
|
234
|
-
return undefined;
|
|
235
|
-
}
|
|
236
|
-
// Diagnostics are best-effort, not session authority or a clean-exit prerequisite.
|
|
237
|
-
async flushForCheckpoint() { await this.traceWriter?.flush(); }
|
|
238
173
|
releaseClientWhenSettled(client) {
|
|
239
174
|
if (client instanceof ManagedClient)
|
|
240
175
|
void drainClientOperations(client).then(() => this.ownedClients.delete(client));
|
|
241
176
|
}
|
|
242
|
-
/**
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
for (const key of ["onmessage", "onerror", "onclose"]) {
|
|
246
|
-
const descriptor = Object.getOwnPropertyDescriptor(transport, key);
|
|
247
|
-
let value = transport[key];
|
|
248
|
-
Object.defineProperty(transport, key, {
|
|
249
|
-
configurable: true,
|
|
250
|
-
get: () => {
|
|
251
|
-
const handler = descriptor?.get ? descriptor.get.call(transport) : value;
|
|
252
|
-
return handler ? (...args) => { this.beforeActivity(); return handler(...args); } : undefined;
|
|
253
|
-
},
|
|
254
|
-
set: next => { if (descriptor?.set)
|
|
255
|
-
descriptor.set.call(transport, next);
|
|
256
|
-
else
|
|
257
|
-
value = next; },
|
|
258
|
-
});
|
|
259
|
-
}
|
|
260
|
-
if (typeof transport.send === "function") {
|
|
177
|
+
/** Own native send/auth tails even after a public request has been cancelled. */
|
|
178
|
+
ownTransportSends(transport, client) {
|
|
179
|
+
if (typeof transport.send === "function" && client instanceof ManagedClient) {
|
|
261
180
|
const send = transport.send.bind(transport);
|
|
262
|
-
transport.send = (...args) =>
|
|
263
|
-
this.beforeActivity();
|
|
264
|
-
return client instanceof ManagedClient ? client.ownSend(() => send(...args)) : send(...args);
|
|
265
|
-
};
|
|
181
|
+
transport.send = (...args) => client.ownSend(() => send(...args));
|
|
266
182
|
}
|
|
267
183
|
}
|
|
268
184
|
/** Default cwd for stdio servers without an explicit config `cwd`. */
|
|
@@ -316,7 +232,6 @@ export class McpServerManager {
|
|
|
316
232
|
};
|
|
317
233
|
}
|
|
318
234
|
async connect(name, definition, signal) {
|
|
319
|
-
this.beforeActivity();
|
|
320
235
|
if (isServerDisabled(definition))
|
|
321
236
|
throw new Error(`MCP server "${name}" is disabled`);
|
|
322
237
|
if (this.stopped)
|
|
@@ -425,7 +340,6 @@ export class McpServerManager {
|
|
|
425
340
|
}
|
|
426
341
|
/** Retire only the expected current client; accepted native work drains before close. */
|
|
427
342
|
retire(name, connection) {
|
|
428
|
-
this.beforeActivity();
|
|
429
343
|
if (this.connections.get(name) !== connection)
|
|
430
344
|
return;
|
|
431
345
|
connection.status = "closed";
|
|
@@ -644,7 +558,7 @@ export class McpServerManager {
|
|
|
644
558
|
}
|
|
645
559
|
async connectClientWithAbort(client, transport, requestOptions, signal) {
|
|
646
560
|
throwIfAborted(signal);
|
|
647
|
-
this.
|
|
561
|
+
this.ownTransportSends(transport, client);
|
|
648
562
|
let abortCleanup;
|
|
649
563
|
const closeTransport = () => {
|
|
650
564
|
abortCleanup = Promise.resolve().then(() => transport.close());
|
|
@@ -714,7 +628,6 @@ export class McpServerManager {
|
|
|
714
628
|
});
|
|
715
629
|
client.serverName = serverName;
|
|
716
630
|
this.ownedClients.add(client);
|
|
717
|
-
client.beforeActivity = this.beforeActivity;
|
|
718
631
|
if (this.samplingConfig) {
|
|
719
632
|
registerSamplingHandler(client, { ...this.samplingConfig, serverName }, run => client.ownCallback("sampling callback", run));
|
|
720
633
|
}
|
|
@@ -740,7 +653,6 @@ export class McpServerManager {
|
|
|
740
653
|
return client;
|
|
741
654
|
}
|
|
742
655
|
handleToolsListChanged(serverName, client, error, tools) {
|
|
743
|
-
this.beforeActivity();
|
|
744
656
|
if (error) {
|
|
745
657
|
logger.debug(`MCP: tools/list_changed refresh failed for ${serverName}: ${error.message}`);
|
|
746
658
|
return;
|
|
@@ -754,7 +666,6 @@ export class McpServerManager {
|
|
|
754
666
|
this.metadataListChangedListener?.(serverName, "tools-list-changed");
|
|
755
667
|
}
|
|
756
668
|
handlePromptsListChanged(serverName, client, error, prompts) {
|
|
757
|
-
this.beforeActivity();
|
|
758
669
|
if (error) {
|
|
759
670
|
logger.debug(`MCP: prompts/list_changed refresh failed for ${serverName}: ${error.message}`);
|
|
760
671
|
return;
|
|
@@ -769,7 +680,6 @@ export class McpServerManager {
|
|
|
769
680
|
this.metadataListChangedListener?.(serverName, "prompts-list-changed");
|
|
770
681
|
}
|
|
771
682
|
handleResourcesListChanged(serverName, client, error, resources) {
|
|
772
|
-
this.beforeActivity();
|
|
773
683
|
if (error) {
|
|
774
684
|
logger.debug(`MCP: resources/list_changed refresh failed for ${serverName}: ${error.message}`);
|
|
775
685
|
return;
|
|
@@ -920,7 +830,6 @@ export class McpServerManager {
|
|
|
920
830
|
const timer = setTimeout(() => {
|
|
921
831
|
if (transportClosed)
|
|
922
832
|
return;
|
|
923
|
-
this.beforeActivity();
|
|
924
833
|
reconnect();
|
|
925
834
|
}, delay);
|
|
926
835
|
return () => clearTimeout(timer);
|
|
@@ -1037,7 +946,6 @@ export class McpServerManager {
|
|
|
1037
946
|
}
|
|
1038
947
|
}
|
|
1039
948
|
async close(name) {
|
|
1040
|
-
this.beforeActivity();
|
|
1041
949
|
this.closeGenerations.set(name, (this.closeGenerations.get(name) ?? 0) + 1);
|
|
1042
950
|
this.connectAttempts.get(name)?.controller.abort(new Error(`MCP connection ${name} was closed`));
|
|
1043
951
|
const pendingClose = this.closePromises.get(name);
|
|
@@ -1116,7 +1024,7 @@ export class McpServerManager {
|
|
|
1116
1024
|
this.elicitationConfig = undefined;
|
|
1117
1025
|
await Promise.all([...this.ownedClients].map(client => drainClientOperations(client)));
|
|
1118
1026
|
try {
|
|
1119
|
-
await this.
|
|
1027
|
+
await this.traceWriter?.flush();
|
|
1120
1028
|
}
|
|
1121
1029
|
catch (error) {
|
|
1122
1030
|
failures.push(error);
|
package/dist/tool-loader.js
CHANGED
|
@@ -4,6 +4,8 @@ import { isServerCacheValid, reconstructToolMetadata } from "./metadata-cache.js
|
|
|
4
4
|
import { createMcpDirectToolCallRenderer, renderMcpToolResult } from "./tool-result-renderer.js";
|
|
5
5
|
import { isServerDisabled } from "./types.js";
|
|
6
6
|
import { normalizeDirectToolInputSchema } from "./utils.js";
|
|
7
|
+
import { nativeMcpOutputSchema } from "./tool-registrar.js";
|
|
8
|
+
import { toolErrorOverride } from "./error-signal.js";
|
|
7
9
|
const featureNames = { gateway: "mcp", script: "mcp_script" };
|
|
8
10
|
const ENTRY = "mcp-tool-selection";
|
|
9
11
|
const key = (value) => JSON.stringify([value.server, value.tool]);
|
|
@@ -27,7 +29,10 @@ export function createToolLoader(pi, getState, getInitPromise, beforeExecute) {
|
|
|
27
29
|
pi.setActiveTools(unique);
|
|
28
30
|
};
|
|
29
31
|
function registerFeature(feature, definition) {
|
|
30
|
-
pi.registerTool(definition)
|
|
32
|
+
pi.registerTool({ defaultActive: false, ...definition, async execute(...args) {
|
|
33
|
+
const result = await definition.execute(...args);
|
|
34
|
+
return { ...result, ...toolErrorOverride(result.details) };
|
|
35
|
+
} });
|
|
31
36
|
registeredFeatures.add(feature);
|
|
32
37
|
}
|
|
33
38
|
function capture() {
|
|
@@ -68,10 +73,25 @@ export function createToolLoader(pi, getState, getInitPromise, beforeExecute) {
|
|
|
68
73
|
inactive = new Map();
|
|
69
74
|
selectedFeatures = new Set();
|
|
70
75
|
inactiveFeatures = new Set();
|
|
71
|
-
|
|
72
|
-
|
|
76
|
+
const manager = ctx.sessionManager;
|
|
77
|
+
function* snapshots() {
|
|
78
|
+
let id = manager.getLeafId();
|
|
79
|
+
while (id) {
|
|
80
|
+
const entry = manager.getEntry(id);
|
|
81
|
+
if (!entry)
|
|
82
|
+
break;
|
|
83
|
+
yield entry;
|
|
84
|
+
id = entry.parentId;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
for (const entry of snapshots()) {
|
|
88
|
+
if (entry?.type !== "custom" || entry.customType !== ENTRY || !entry.data || typeof entry.data !== "object")
|
|
73
89
|
continue;
|
|
74
90
|
const data = entry.data;
|
|
91
|
+
if (!Array.isArray(data.selected) || [data.inactive, data.features, data.inactiveFeatures].some(value => value !== undefined && !Array.isArray(value)))
|
|
92
|
+
continue;
|
|
93
|
+
if ([...data.selected, ...(data.inactive ?? [])].some(value => !value || typeof value.server !== "string" || typeof value.tool !== "string"))
|
|
94
|
+
continue;
|
|
75
95
|
selectedFeatures = new Set(Array.isArray(data.features) ? data.features.filter(value => value === "gateway" || value === "script") : []);
|
|
76
96
|
inactiveFeatures = new Set(Array.isArray(data.inactiveFeatures) ? data.inactiveFeatures.filter(value => value === "gateway" || value === "script") : []);
|
|
77
97
|
for (const [field, target] of [[data.selected, selected], [data.inactive, inactive]]) {
|
|
@@ -81,6 +101,7 @@ export function createToolLoader(pi, getState, getInitPromise, beforeExecute) {
|
|
|
81
101
|
target.set(key(selection), selection);
|
|
82
102
|
}
|
|
83
103
|
}
|
|
104
|
+
break;
|
|
84
105
|
}
|
|
85
106
|
restoring = true;
|
|
86
107
|
}
|
|
@@ -114,11 +135,19 @@ export function createToolLoader(pi, getState, getInitPromise, beforeExecute) {
|
|
|
114
135
|
if (reserved.has(name) || counts.get(name) > 1 || (existing.has(name) && !previousNames.has(name)))
|
|
115
136
|
continue;
|
|
116
137
|
available.add(id);
|
|
117
|
-
const
|
|
138
|
+
const instructions = getState()?.serverInstructions.get(spec.serverName) ?? cache?.servers[spec.serverName]?.instructions;
|
|
139
|
+
const fingerprint = JSON.stringify({ spec, name, instructions });
|
|
118
140
|
if (registered.get(id)?.fingerprint !== fingerprint) {
|
|
119
141
|
pi.registerTool({
|
|
120
142
|
name,
|
|
121
|
-
namespace: { name: `mcp_${spec.serverName}
|
|
143
|
+
namespace: { name: `mcp_${spec.serverName}`, ...(instructions !== undefined ? { instructions } : {}) },
|
|
144
|
+
...(spec.outputSchema ? { outputSchema: nativeMcpOutputSchema(spec.outputSchema) } : {}),
|
|
145
|
+
...(spec.annotations ? { annotations: {
|
|
146
|
+
...(typeof spec.annotations.readOnlyHint === "boolean" ? { readOnlyHint: spec.annotations.readOnlyHint } : {}),
|
|
147
|
+
...(typeof spec.annotations.destructiveHint === "boolean" ? { destructiveHint: spec.annotations.destructiveHint } : {}),
|
|
148
|
+
...(typeof spec.annotations.idempotentHint === "boolean" ? { idempotentHint: spec.annotations.idempotentHint } : {}),
|
|
149
|
+
...(typeof spec.annotations.openWorldHint === "boolean" ? { openWorldHint: spec.annotations.openWorldHint } : {}),
|
|
150
|
+
} } : {}),
|
|
122
151
|
// Inactive direct tools are not callable through codemode or nested execution.
|
|
123
152
|
defaultActive: false,
|
|
124
153
|
...(beforeExecute ? { executionMode: "sequential" } : {}),
|
|
@@ -153,7 +182,7 @@ export function createToolLoader(pi, getState, getInitPromise, beforeExecute) {
|
|
|
153
182
|
}
|
|
154
183
|
}
|
|
155
184
|
}
|
|
156
|
-
//
|
|
185
|
+
// Retain the journal: official 1.0 initial SDK resume has a native loadout restoration gap.
|
|
157
186
|
const ownedNames = new Set([...previousNames, ...[...registered.values()].map(item => item.name)]);
|
|
158
187
|
for (const feature of registeredFeatures)
|
|
159
188
|
ownedNames.add(featureNames[feature]);
|
package/dist/tool-registrar.d.ts
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
import type { McpContent, ContentBlock } from "./types.ts";
|
|
2
2
|
import { type McpPayloadFile } from "./mcp-output-guard.ts";
|
|
3
|
+
import { Type } from "typebox";
|
|
4
|
+
import type { AgentToolResult } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
/** Overflow/not-run outcomes are receipts, never a fabricated server-schema result. */
|
|
6
|
+
export declare function nativeMcpOutputSchema(schema: Record<string, unknown>): Type.TUnion<[Type.TUnsafe<unknown>, Type.TObject<{
|
|
7
|
+
mcpAdapter: Type.TObject<{
|
|
8
|
+
omitted: Type.TLiteral<true>;
|
|
9
|
+
resultRef: Type.TOptional<Type.TString>;
|
|
10
|
+
error: Type.TOptional<Type.TString>;
|
|
11
|
+
}>;
|
|
12
|
+
}>]>;
|
|
13
|
+
export declare function nativeMcpResult(result: AgentToolResult<Record<string, unknown>>, structured?: boolean): AgentToolResult<Record<string, unknown>>;
|
|
3
14
|
/**
|
|
4
15
|
* Transform MCP content types to Pi content blocks.
|
|
5
16
|
*/
|
package/dist/tool-registrar.js
CHANGED
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
// MCP protocol content to model-visible content.
|
|
2
2
|
import { formatMcpPayloadFile } from "./mcp-output-guard.js";
|
|
3
|
+
import { Type } from "typebox";
|
|
4
|
+
import { toolErrorOverride } from "./error-signal.js";
|
|
5
|
+
/** Overflow/not-run outcomes are receipts, never a fabricated server-schema result. */
|
|
6
|
+
export function nativeMcpOutputSchema(schema) {
|
|
7
|
+
return Type.Union([Type.Unsafe(schema), Type.Object({
|
|
8
|
+
mcpAdapter: Type.Object({
|
|
9
|
+
omitted: Type.Literal(true),
|
|
10
|
+
resultRef: Type.Optional(Type.String()),
|
|
11
|
+
error: Type.Optional(Type.String()),
|
|
12
|
+
}),
|
|
13
|
+
})]);
|
|
14
|
+
}
|
|
15
|
+
export function nativeMcpResult(result, structured = false) {
|
|
16
|
+
const { details } = result;
|
|
17
|
+
const raw = details.mcpResult;
|
|
18
|
+
return {
|
|
19
|
+
...result,
|
|
20
|
+
...toolErrorOverride(details),
|
|
21
|
+
...(structured ? { structuredContent: !details.outputGuard && (!details.error || details.error === "tool_error") && raw?.structuredContent !== undefined
|
|
22
|
+
? raw.structuredContent
|
|
23
|
+
: { mcpAdapter: { omitted: true, ...(typeof details.resultRef === "string" ? { resultRef: details.resultRef } : {}), ...(typeof details.error === "string" ? { error: details.error } : {}) } } } : {}),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
3
26
|
/**
|
|
4
27
|
* Transform MCP content types to Pi content blocks.
|
|
5
28
|
*/
|
package/dist/types.d.ts
CHANGED
|
@@ -136,6 +136,11 @@ export declare function parseUiPromptHandoff(prompt: string): UiPromptHandoff |
|
|
|
136
136
|
* Collected during the session and available when it ends.
|
|
137
137
|
*/
|
|
138
138
|
export interface UiSessionMessages {
|
|
139
|
+
/** Private JSONL history, readable with the normal paged result reader. */
|
|
140
|
+
historyRef?: string;
|
|
141
|
+
historyBytes?: number;
|
|
142
|
+
historyCount?: number;
|
|
143
|
+
historyError?: string;
|
|
139
144
|
prompts: string[];
|
|
140
145
|
notifications: string[];
|
|
141
146
|
intents: Array<{
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { UiSessionMessages } from "./types.ts";
|
|
2
|
+
type Category = "prompts" | "intents" | "notifications";
|
|
3
|
+
type Message = string | UiSessionMessages["intents"][number];
|
|
4
|
+
/** Private append-only history; failed writes stay owned in memory, never silently discarded. */
|
|
5
|
+
export declare function createUiMessageHistory(outputDirectory?: string): {
|
|
6
|
+
messages: UiSessionMessages;
|
|
7
|
+
append(category: Category, value: Message): string | undefined;
|
|
8
|
+
};
|
|
9
|
+
export declare function uiMessages(messages: UiSessionMessages, category: Category): AsyncGenerator<Message>;
|
|
10
|
+
export declare function materializeUiMessages(messages: UiSessionMessages): Promise<UiSessionMessages>;
|
|
11
|
+
export {};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { appendFileSync, createReadStream, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { randomBytes } from "node:crypto";
|
|
5
|
+
import { createInterface } from "node:readline";
|
|
6
|
+
/** Private append-only history; failed writes stay owned in memory, never silently discarded. */
|
|
7
|
+
export function createUiMessageHistory(outputDirectory) {
|
|
8
|
+
const messages = { prompts: [], intents: [], notifications: [], contexts: [] };
|
|
9
|
+
let failed = false;
|
|
10
|
+
return {
|
|
11
|
+
messages,
|
|
12
|
+
append(category, value) {
|
|
13
|
+
try {
|
|
14
|
+
if (failed)
|
|
15
|
+
throw new Error(messages.historyError);
|
|
16
|
+
if (!messages.historyRef) {
|
|
17
|
+
const root = outputDirectory === undefined ? tmpdir() : resolve(outputDirectory);
|
|
18
|
+
if (outputDirectory !== undefined)
|
|
19
|
+
mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
20
|
+
const dir = mkdtempSync(join(root, "pi-mcp-output-"));
|
|
21
|
+
const ref = join(dir, `output-${randomBytes(4).toString("hex")}.txt`);
|
|
22
|
+
writeFileSync(ref, "", { mode: 0o600 });
|
|
23
|
+
messages.historyRef = ref;
|
|
24
|
+
messages.historyBytes = 0;
|
|
25
|
+
messages.historyCount = 0;
|
|
26
|
+
}
|
|
27
|
+
const line = JSON.stringify({ category, value }) + "\n";
|
|
28
|
+
// ponytail: synchronous append owns acceptance order; upgrade to an awaited serial writer if local disk latency becomes material.
|
|
29
|
+
appendFileSync(messages.historyRef, line, { flush: true });
|
|
30
|
+
messages.historyBytes += Buffer.byteLength(line);
|
|
31
|
+
messages.historyCount++;
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
failed = true;
|
|
36
|
+
messages.historyError = error instanceof Error ? error.message : String(error);
|
|
37
|
+
if (category === "intents")
|
|
38
|
+
messages.intents.push(value);
|
|
39
|
+
else
|
|
40
|
+
messages[category].push(value);
|
|
41
|
+
return `UI history could not be saved; messages remain in memory for recovery: ${messages.historyError}. Unsaved messages will be lost on reload or process exit.`;
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
export async function* uiMessages(messages, category) {
|
|
47
|
+
if (messages.historyRef && messages.historyBytes) {
|
|
48
|
+
// A failed partial append is not an accepted event; the in-memory tail owns it instead.
|
|
49
|
+
const input = createReadStream(messages.historyRef, { encoding: "utf8", end: messages.historyBytes - 1 });
|
|
50
|
+
let bytes = 0, count = 0;
|
|
51
|
+
input.on("data", chunk => { bytes += Buffer.byteLength(chunk); });
|
|
52
|
+
const lines = createInterface({ input, crlfDelay: Infinity });
|
|
53
|
+
try {
|
|
54
|
+
for await (const line of lines) {
|
|
55
|
+
const record = JSON.parse(line);
|
|
56
|
+
count++;
|
|
57
|
+
if (record.category === category)
|
|
58
|
+
yield record.value;
|
|
59
|
+
}
|
|
60
|
+
if (bytes !== messages.historyBytes || (messages.historyCount !== undefined && count !== messages.historyCount)) {
|
|
61
|
+
throw new Error("UI history is incomplete; accepted messages remain pending recovery");
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
lines.close();
|
|
66
|
+
input.destroy();
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
for (const value of messages[category])
|
|
70
|
+
yield value;
|
|
71
|
+
}
|
|
72
|
+
export async function materializeUiMessages(messages) {
|
|
73
|
+
const result = { prompts: [], intents: [], notifications: [], contexts: messages.contexts };
|
|
74
|
+
for await (const value of uiMessages(messages, "prompts"))
|
|
75
|
+
result.prompts.push(value);
|
|
76
|
+
for await (const value of uiMessages(messages, "intents"))
|
|
77
|
+
result.intents.push(value);
|
|
78
|
+
for await (const value of uiMessages(messages, "notifications"))
|
|
79
|
+
result.notifications.push(value);
|
|
80
|
+
return result;
|
|
81
|
+
}
|
package/dist/ui-server.js
CHANGED
|
@@ -10,6 +10,7 @@ import { logger } from "./logger.js";
|
|
|
10
10
|
import { SessionRecoveryAuthRequiredError, withSessionRecovery } from "./session-recovery.js";
|
|
11
11
|
import { ensureToolCallApproved, isToolCallApprovalRequired } from "./tool-approval.js";
|
|
12
12
|
import { extractUiToolVisibility, isUiToolCallableByApp } from "./ui-tool-visibility.js";
|
|
13
|
+
import { createUiMessageHistory } from "./ui-message-history.js";
|
|
13
14
|
import { createUiModelContextUpdate, extractUiPromptText, getVisualizationStreamEnvelope, isServerDisabled, isToolAllowed, resolveToolPrefix, } from "./types.js";
|
|
14
15
|
const MAX_BODY_SIZE = 2 * 1024 * 1024;
|
|
15
16
|
const ABANDONED_GRACE_MS = 60_000;
|
|
@@ -38,12 +39,8 @@ export async function startUiServer(options) {
|
|
|
38
39
|
const eventLog = [];
|
|
39
40
|
let streamSummary;
|
|
40
41
|
// Track messages from UI for retrieval
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
notifications: [],
|
|
44
|
-
intents: [],
|
|
45
|
-
contexts: [],
|
|
46
|
-
};
|
|
42
|
+
const history = createUiMessageHistory(options.state?.outputDirectory);
|
|
43
|
+
const sessionMessages = history.messages;
|
|
47
44
|
const hostContext = {
|
|
48
45
|
displayMode: currentDisplayMode,
|
|
49
46
|
availableDisplayModes: ["inline", "fullscreen", "pip"],
|
|
@@ -281,6 +278,11 @@ export async function startUiServer(options) {
|
|
|
281
278
|
return;
|
|
282
279
|
if (!validateTokenBody(body, sessionToken, res))
|
|
283
280
|
return;
|
|
281
|
+
// A partial upload is not accepted until its authenticated body is complete.
|
|
282
|
+
if (completed && (url.pathname === "/proxy/ui/message" || url.pathname === "/proxy/ui/context")) {
|
|
283
|
+
sendJson(res, 409, { ok: false, error: "UI session is complete; message was not accepted" });
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
284
286
|
const params = body.params ?? {};
|
|
285
287
|
touchHeartbeat();
|
|
286
288
|
if (url.pathname === "/proxy/tools/call") {
|
|
@@ -376,14 +378,15 @@ export async function startUiServer(options) {
|
|
|
376
378
|
const promptText = extractUiPromptText(msgParams);
|
|
377
379
|
// Track messages by type (order: prompt → intent → notify)
|
|
378
380
|
// Must match the order in index.ts onMessage handler
|
|
381
|
+
let warning;
|
|
379
382
|
if (promptText) {
|
|
380
|
-
|
|
383
|
+
warning = history.append("prompts", promptText);
|
|
381
384
|
log.debug("UI prompt received", { prompt: promptText.slice(0, 100) });
|
|
382
385
|
}
|
|
383
386
|
else if (msgParams.type === "intent" || msgParams.intent) {
|
|
384
387
|
const intentName = msgParams.intent ?? "";
|
|
385
388
|
if (intentName) {
|
|
386
|
-
|
|
389
|
+
warning = history.append("intents", {
|
|
387
390
|
intent: intentName,
|
|
388
391
|
...(msgParams.params !== undefined ? { params: msgParams.params } : {}),
|
|
389
392
|
});
|
|
@@ -393,12 +396,16 @@ export async function startUiServer(options) {
|
|
|
393
396
|
else if (msgParams.type === "notify" || msgParams.message) {
|
|
394
397
|
const notifyText = msgParams.message ?? "";
|
|
395
398
|
if (notifyText) {
|
|
396
|
-
|
|
399
|
+
warning = history.append("notifications", notifyText);
|
|
397
400
|
log.debug("UI notification", { message: notifyText.slice(0, 100) });
|
|
398
401
|
}
|
|
399
402
|
}
|
|
400
403
|
await options.onMessage?.(msgParams);
|
|
401
|
-
|
|
404
|
+
if (warning) {
|
|
405
|
+
log.warn(warning);
|
|
406
|
+
options.state?.ui?.notify(warning, "warning");
|
|
407
|
+
}
|
|
408
|
+
sendJson(res, 200, { ok: true, result: {}, ...(warning ? { warning } : {}) });
|
|
402
409
|
return;
|
|
403
410
|
}
|
|
404
411
|
if (url.pathname === "/proxy/ui/context") {
|
package/dist/ui-session.js
CHANGED
|
@@ -297,7 +297,8 @@ export async function maybeStartUiSession(state, request) {
|
|
|
297
297
|
if (state.uiServer === handle) {
|
|
298
298
|
const messages = handle.getSessionMessages();
|
|
299
299
|
const stream = handle.getStreamSummary();
|
|
300
|
-
const hasContent = messages.
|
|
300
|
+
const hasContent = !!messages.historyCount ||
|
|
301
|
+
messages.prompts.length > 0 ||
|
|
301
302
|
messages.intents.length > 0 ||
|
|
302
303
|
messages.notifications.length > 0 ||
|
|
303
304
|
messages.contexts.length > 0 ||
|
|
@@ -316,7 +317,7 @@ export async function maybeStartUiSession(state, request) {
|
|
|
316
317
|
}
|
|
317
318
|
log.debug("Session completed", {
|
|
318
319
|
reason,
|
|
319
|
-
prompts: messages.prompts.length,
|
|
320
|
+
prompts: messages.historyCount ?? messages.prompts.length,
|
|
320
321
|
intents: messages.intents.length,
|
|
321
322
|
notifications: messages.notifications.length,
|
|
322
323
|
contexts: messages.contexts.length,
|