@automatalabs/pi-acp 0.1.3 → 0.2.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/README.md +7 -3
- package/dist/agent.d.ts +11 -7
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +374 -60
- package/dist/child-process-registry.d.ts +82 -0
- package/dist/child-process-registry.d.ts.map +1 -0
- package/dist/child-process-registry.js +408 -0
- package/dist/config.d.ts +6 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +22 -6
- package/dist/deps.d.ts +3 -3
- package/dist/deps.d.ts.map +1 -1
- package/dist/deps.js +2 -2
- package/dist/errors.d.ts +17 -4
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +10 -5
- package/dist/index.js +5 -6
- package/dist/mcp-bridge.d.ts +235 -17
- package/dist/mcp-bridge.d.ts.map +1 -1
- package/dist/mcp-bridge.js +1331 -144
- package/dist/mcp-sampling-payload.d.ts +25 -0
- package/dist/mcp-sampling-payload.d.ts.map +1 -0
- package/dist/mcp-sampling-payload.js +263 -0
- package/dist/session.d.ts +27 -9
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +301 -145
- package/dist/translate.d.ts.map +1 -1
- package/dist/translate.js +10 -6
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +2 -0
- package/package.json +1 -1
- package/dist/structured-output.d.ts +0 -14
- package/dist/structured-output.d.ts.map +0 -1
- package/dist/structured-output.js +0 -71
package/dist/mcp-bridge.js
CHANGED
|
@@ -1,105 +1,748 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
import { pathToFileURL } from "node:url";
|
|
1
3
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
4
|
+
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
|
2
5
|
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
6
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
7
|
+
import { CreateMessageRequestSchema, ElicitRequestSchema, ElicitationCompleteNotificationSchema, ErrorCode, ListRootsRequestSchema, LoggingMessageNotificationSchema, McpError, PromptListChangedNotificationSchema, ResourceListChangedNotificationSchema, ResourceUpdatedNotificationSchema, ToolListChangedNotificationSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
8
|
+
import { AjvJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/ajv";
|
|
9
|
+
import { Type } from "typebox";
|
|
3
10
|
import { adapterError } from "./errors.js";
|
|
11
|
+
import { createMcpSamplingPayload } from "./mcp-sampling-payload.js";
|
|
12
|
+
import { PKG_VERSION } from "./version.js";
|
|
13
|
+
const NO_RECONNECT = {
|
|
14
|
+
initialReconnectionDelay: 0,
|
|
15
|
+
maxReconnectionDelay: 0,
|
|
16
|
+
reconnectionDelayGrowFactor: 1,
|
|
17
|
+
maxRetries: 0,
|
|
18
|
+
};
|
|
19
|
+
const NEVER_ABORTED = new AbortController().signal;
|
|
4
20
|
export class McpTimeoutError extends Error {
|
|
5
21
|
constructor() {
|
|
6
22
|
super("MCP operation timed out");
|
|
7
23
|
this.name = "McpTimeoutError";
|
|
8
24
|
}
|
|
9
25
|
}
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
26
|
+
export class McpOperationTerminalError extends Error {
|
|
27
|
+
terminalCause;
|
|
28
|
+
terminalReason;
|
|
29
|
+
constructor(terminalCause, terminalReason) {
|
|
30
|
+
super(`MCP operation terminated by ${terminalCause}`);
|
|
31
|
+
this.terminalCause = terminalCause;
|
|
32
|
+
this.terminalReason = terminalReason;
|
|
33
|
+
this.name = "McpOperationTerminalError";
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export class McpIncomingTerminalError extends Error {
|
|
37
|
+
terminalCause;
|
|
38
|
+
terminalReason;
|
|
39
|
+
constructor(terminalCause, terminalReason) {
|
|
40
|
+
super(`Incoming MCP operation terminated by ${terminalCause}`);
|
|
41
|
+
this.terminalCause = terminalCause;
|
|
42
|
+
this.terminalReason = terminalReason;
|
|
43
|
+
this.name = "McpIncomingTerminalError";
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function exactMcpError(code, message) {
|
|
47
|
+
const error = new McpError(code, message);
|
|
48
|
+
// McpError adds a local-display prefix, but Protocol serializes error.message verbatim.
|
|
49
|
+
error.message = message;
|
|
50
|
+
return error;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* One terminal arbiter for every MCP request. Claims are committed in a
|
|
54
|
+
* microtask so conditions that become observable at the same boundary are
|
|
55
|
+
* resolved by the frozen precedence instead of Promise.race scheduling.
|
|
56
|
+
*/
|
|
57
|
+
export function settleMcpOperation(operation, lifecycleSignal, sessionSignal, peerSignal, timeoutMs, sleep, onCommit) {
|
|
58
|
+
const requestSignal = anySignal([lifecycleSignal, sessionSignal, peerSignal]);
|
|
59
|
+
return new Promise((resolve, reject) => {
|
|
60
|
+
const timer = new AbortController();
|
|
61
|
+
let settled = false;
|
|
62
|
+
let commitQueued = false;
|
|
63
|
+
let timedOut = false;
|
|
64
|
+
let operationOutcome;
|
|
65
|
+
const removers = [];
|
|
66
|
+
const finish = (callback) => {
|
|
67
|
+
if (settled)
|
|
68
|
+
return;
|
|
69
|
+
settled = true;
|
|
70
|
+
timer.abort();
|
|
71
|
+
for (const remove of removers)
|
|
72
|
+
remove();
|
|
73
|
+
callback();
|
|
74
|
+
};
|
|
75
|
+
const commit = () => {
|
|
76
|
+
commitQueued = false;
|
|
77
|
+
if (settled)
|
|
78
|
+
return;
|
|
79
|
+
if (lifecycleSignal?.aborted) {
|
|
80
|
+
finish(() => {
|
|
81
|
+
onCommit?.({ status: "terminal", cause: "lifecycle", reason: lifecycleSignal.reason });
|
|
82
|
+
reject(new McpOperationTerminalError("lifecycle", lifecycleSignal.reason));
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
else if (sessionSignal?.aborted) {
|
|
86
|
+
finish(() => {
|
|
87
|
+
onCommit?.({ status: "terminal", cause: "session", reason: sessionSignal.reason });
|
|
88
|
+
reject(new McpOperationTerminalError("session", sessionSignal.reason));
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
else if (peerSignal?.aborted) {
|
|
92
|
+
finish(() => {
|
|
93
|
+
onCommit?.({ status: "terminal", cause: "peer", reason: peerSignal.reason });
|
|
94
|
+
reject(new McpOperationTerminalError("peer", peerSignal.reason));
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
else if (timedOut) {
|
|
98
|
+
finish(() => {
|
|
99
|
+
const reason = new McpTimeoutError();
|
|
100
|
+
onCommit?.({ status: "terminal", cause: "timeout", reason });
|
|
101
|
+
reject(new McpOperationTerminalError("timeout", reason));
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
const outcome = operationOutcome;
|
|
106
|
+
if (outcome?.status === "fulfilled") {
|
|
107
|
+
finish(() => {
|
|
108
|
+
onCommit?.(outcome);
|
|
109
|
+
resolve(outcome.value);
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
else if (outcome?.status === "rejected") {
|
|
113
|
+
finish(() => {
|
|
114
|
+
onCommit?.(outcome);
|
|
115
|
+
reject(outcome.reason);
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
const claim = () => {
|
|
121
|
+
if (settled || commitQueued)
|
|
122
|
+
return;
|
|
123
|
+
commitQueued = true;
|
|
124
|
+
queueMicrotask(commit);
|
|
125
|
+
};
|
|
126
|
+
const observe = (signal) => {
|
|
127
|
+
if (!signal)
|
|
128
|
+
return;
|
|
129
|
+
if (signal.aborted)
|
|
130
|
+
claim();
|
|
131
|
+
else {
|
|
132
|
+
signal.addEventListener("abort", claim, { once: true });
|
|
133
|
+
removers.push(() => signal.removeEventListener("abort", claim));
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
observe(lifecycleSignal);
|
|
137
|
+
observe(sessionSignal);
|
|
138
|
+
observe(peerSignal);
|
|
139
|
+
const expiry = sleep(timeoutMs, timer.signal).then(() => { timedOut = true; claim(); }, () => undefined);
|
|
140
|
+
expiry.catch(() => undefined);
|
|
141
|
+
const running = Promise.resolve().then(() => {
|
|
142
|
+
if (settled)
|
|
143
|
+
throw new Error("MCP operation was cancelled before admission");
|
|
144
|
+
return operation(requestSignal);
|
|
145
|
+
});
|
|
146
|
+
running.then((value) => { operationOutcome = { status: "fulfilled", value }; claim(); }, (reason) => { operationOutcome = { status: "rejected", reason }; claim(); });
|
|
16
147
|
});
|
|
17
148
|
}
|
|
18
149
|
export async function bounded(operation, signal, timeoutMs, sleep) {
|
|
19
|
-
const timeoutController = new AbortController();
|
|
20
|
-
const timeout = sleep(timeoutMs, timeoutController.signal).then(() => {
|
|
21
|
-
throw new McpTimeoutError();
|
|
22
|
-
});
|
|
23
|
-
operation.then(() => undefined, () => undefined);
|
|
24
150
|
try {
|
|
25
|
-
return await
|
|
151
|
+
return await settleMcpOperation(() => typeof operation === "function" ? operation() : operation, signal, undefined, undefined, timeoutMs, sleep);
|
|
26
152
|
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
153
|
+
catch (error) {
|
|
154
|
+
if (error instanceof McpOperationTerminalError) {
|
|
155
|
+
if (error.terminalCause === "timeout")
|
|
156
|
+
throw new McpTimeoutError();
|
|
157
|
+
throw error.terminalReason;
|
|
158
|
+
}
|
|
159
|
+
throw error;
|
|
30
160
|
}
|
|
31
161
|
}
|
|
32
|
-
export async function
|
|
33
|
-
const client = new Client({ name: "@automatalabs/pi-acp", version: "0.0.0" });
|
|
34
|
-
const transport = new StdioClientTransport({
|
|
35
|
-
command: server.command,
|
|
36
|
-
args: server.args,
|
|
37
|
-
env: Object.fromEntries(server.env.map(({ name, value }) => [name, value])),
|
|
38
|
-
});
|
|
162
|
+
export async function settleIncomingMcpOperation(operation, peerSignal, sessionSignal, turnSignal, timeoutMs, sleep, onCommit) {
|
|
39
163
|
try {
|
|
40
|
-
|
|
164
|
+
// Positional mapping gives the incoming arbiter its distinct frozen order:
|
|
165
|
+
// peer/transport > session disposal > active turn > timeout > completion.
|
|
166
|
+
return await settleMcpOperation(operation, peerSignal, sessionSignal, turnSignal, timeoutMs, sleep, onCommit);
|
|
41
167
|
}
|
|
42
168
|
catch (error) {
|
|
43
|
-
|
|
44
|
-
|
|
169
|
+
if (!(error instanceof McpOperationTerminalError))
|
|
170
|
+
throw error;
|
|
171
|
+
const cause = error.terminalCause === "lifecycle"
|
|
172
|
+
? "peer"
|
|
173
|
+
: error.terminalCause === "peer"
|
|
174
|
+
? "turn"
|
|
175
|
+
: error.terminalCause;
|
|
176
|
+
throw new McpIncomingTerminalError(cause, error.terminalReason);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
function isMcpTimeout(error) {
|
|
180
|
+
return error instanceof McpTimeoutError
|
|
181
|
+
|| (error instanceof McpOperationTerminalError && error.terminalCause === "timeout");
|
|
182
|
+
}
|
|
183
|
+
function anySignal(signals) {
|
|
184
|
+
const present = signals.filter((signal) => signal !== undefined);
|
|
185
|
+
return present.length === 0 ? NEVER_ABORTED : AbortSignal.any(present);
|
|
186
|
+
}
|
|
187
|
+
function headers(values) {
|
|
188
|
+
const result = new Headers();
|
|
189
|
+
for (const { name, value } of values)
|
|
190
|
+
result.append(name, value);
|
|
191
|
+
return result;
|
|
192
|
+
}
|
|
193
|
+
export class CloseSignallingTransport {
|
|
194
|
+
raw;
|
|
195
|
+
terminate;
|
|
196
|
+
onRawError;
|
|
197
|
+
onRawClose;
|
|
198
|
+
timeoutMs;
|
|
199
|
+
sleep;
|
|
200
|
+
serverToken;
|
|
201
|
+
onclose;
|
|
202
|
+
onerror;
|
|
203
|
+
onmessage;
|
|
204
|
+
signalled = false;
|
|
205
|
+
closePromise;
|
|
206
|
+
constructor(raw, terminate, onRawError, onRawClose, timeoutMs, sleep, serverToken) {
|
|
207
|
+
this.raw = raw;
|
|
208
|
+
this.terminate = terminate;
|
|
209
|
+
this.onRawError = onRawError;
|
|
210
|
+
this.onRawClose = onRawClose;
|
|
211
|
+
this.timeoutMs = timeoutMs;
|
|
212
|
+
this.sleep = sleep;
|
|
213
|
+
this.serverToken = serverToken;
|
|
214
|
+
raw.onclose = () => {
|
|
215
|
+
this.signalClose();
|
|
216
|
+
this.onRawClose();
|
|
217
|
+
};
|
|
218
|
+
raw.onerror = (error) => {
|
|
219
|
+
if (this.onRawError(error))
|
|
220
|
+
this.onerror?.(error);
|
|
221
|
+
};
|
|
222
|
+
raw.onmessage = (message, extra) => this.onmessage?.(message, extra);
|
|
223
|
+
}
|
|
224
|
+
get sessionId() { return this.raw.sessionId; }
|
|
225
|
+
setProtocolVersion(version) { this.raw.setProtocolVersion?.(version); }
|
|
226
|
+
start() { return this.raw.start(); }
|
|
227
|
+
send(message, options) {
|
|
228
|
+
return this.raw.send(message, options);
|
|
229
|
+
}
|
|
230
|
+
signalClose() {
|
|
231
|
+
if (this.signalled)
|
|
232
|
+
return;
|
|
233
|
+
this.signalled = true;
|
|
234
|
+
this.onclose?.();
|
|
235
|
+
}
|
|
236
|
+
close() {
|
|
237
|
+
this.closePromise ??= this.closeOwned();
|
|
238
|
+
return this.closePromise;
|
|
239
|
+
}
|
|
240
|
+
async closeOwned() {
|
|
241
|
+
this.signalClose();
|
|
242
|
+
const timer = new AbortController();
|
|
243
|
+
const expired = this.sleep(this.timeoutMs, timer.signal).then(() => {
|
|
244
|
+
throw new McpTimeoutError();
|
|
245
|
+
});
|
|
246
|
+
expired.catch(() => undefined);
|
|
247
|
+
if (this.terminate) {
|
|
248
|
+
try {
|
|
249
|
+
const terminating = this.terminate();
|
|
250
|
+
terminating.catch(() => undefined);
|
|
251
|
+
await Promise.race([terminating, expired]);
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
console.error(`[mcp:${this.serverToken}] session termination failed`);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
let physical;
|
|
45
258
|
try {
|
|
46
|
-
|
|
259
|
+
physical = this.raw.close();
|
|
47
260
|
}
|
|
48
261
|
catch {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
262
|
+
console.error(`[mcp:${this.serverToken}] close failed`);
|
|
263
|
+
timer.abort();
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
physical.catch(() => undefined);
|
|
267
|
+
try {
|
|
268
|
+
await Promise.race([physical, expired]);
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
console.error(`[mcp:${this.serverToken}] close failed`);
|
|
272
|
+
}
|
|
273
|
+
finally {
|
|
274
|
+
timer.abort();
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
function safeToken(value) {
|
|
279
|
+
const sanitized = value.replace(/[^A-Za-z0-9_-]+/g, "_").replace(/_+/g, "_");
|
|
280
|
+
return sanitized || "_";
|
|
281
|
+
}
|
|
282
|
+
function createTransport(server, serverToken, sleep, fatal, timeoutMs) {
|
|
283
|
+
let raw;
|
|
284
|
+
let terminate;
|
|
285
|
+
if (!("type" in server)) {
|
|
286
|
+
raw = new StdioClientTransport({
|
|
287
|
+
command: server.command,
|
|
288
|
+
args: server.args,
|
|
289
|
+
env: Object.fromEntries(server.env.map(({ name, value }) => [name, value])),
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
else if (server.type === "http") {
|
|
293
|
+
let open = true;
|
|
294
|
+
const observedFetch = async (url, init) => {
|
|
295
|
+
// Fatal disable closes the ordinary fetch lane before the owner invokes
|
|
296
|
+
// close(). The retained raw transport must still be able to send its
|
|
297
|
+
// one explicit session DELETE; permitting DELETE here cannot reconnect
|
|
298
|
+
// either GET or POST traffic.
|
|
299
|
+
if (!open && init?.method !== "DELETE")
|
|
300
|
+
throw new Error("MCP transport closed");
|
|
301
|
+
const response = await fetch(url, init);
|
|
302
|
+
if (init?.method === "GET" && response.ok && response.headers.get("content-type")?.includes("text/event-stream") && !response.body) {
|
|
303
|
+
throw new Error("MCP event stream has no body");
|
|
304
|
+
}
|
|
305
|
+
return response;
|
|
306
|
+
};
|
|
307
|
+
const http = new StreamableHTTPClientTransport(new URL(server.url), {
|
|
308
|
+
requestInit: { headers: headers(server.headers) },
|
|
309
|
+
fetch: observedFetch,
|
|
310
|
+
reconnectionOptions: NO_RECONNECT,
|
|
311
|
+
});
|
|
312
|
+
raw = http;
|
|
313
|
+
terminate = () => http.terminateSession();
|
|
314
|
+
const wrapper = new CloseSignallingTransport(raw, terminate, (error) => {
|
|
315
|
+
open = false;
|
|
316
|
+
wrapper.signalClose();
|
|
317
|
+
fatal(error);
|
|
318
|
+
void wrapper.close();
|
|
319
|
+
return false;
|
|
320
|
+
}, () => fatal(), timeoutMs, sleep, serverToken);
|
|
321
|
+
return wrapper;
|
|
322
|
+
}
|
|
323
|
+
else if (server.type === "sse") {
|
|
324
|
+
let open = true;
|
|
325
|
+
const guardedFetch = (url, init) => {
|
|
326
|
+
if (!open)
|
|
327
|
+
return Promise.reject(new Error("MCP transport closed"));
|
|
328
|
+
return fetch(url, init);
|
|
329
|
+
};
|
|
330
|
+
raw = new SSEClientTransport(new URL(server.url), {
|
|
331
|
+
requestInit: { headers: headers(server.headers) },
|
|
332
|
+
eventSourceInit: { fetch: guardedFetch },
|
|
333
|
+
fetch: guardedFetch,
|
|
334
|
+
});
|
|
335
|
+
const wrapper = new CloseSignallingTransport(raw, undefined, (error) => {
|
|
336
|
+
open = false;
|
|
337
|
+
wrapper.signalClose();
|
|
338
|
+
fatal(error);
|
|
339
|
+
void wrapper.close();
|
|
340
|
+
return false;
|
|
341
|
+
}, () => fatal(), timeoutMs, sleep, serverToken);
|
|
342
|
+
return wrapper;
|
|
343
|
+
}
|
|
344
|
+
else {
|
|
345
|
+
throw adapterError("unsupported_mcp_transport", { server: server.name });
|
|
346
|
+
}
|
|
347
|
+
const wrapper = new CloseSignallingTransport(raw, terminate, (error) => {
|
|
348
|
+
// stdio parser/pipe errors are diagnostic-only; natural close is observed by onclose.
|
|
349
|
+
void error;
|
|
350
|
+
return true;
|
|
351
|
+
}, () => {
|
|
352
|
+
fatal();
|
|
353
|
+
void wrapper.close();
|
|
354
|
+
}, timeoutMs, sleep, serverToken);
|
|
355
|
+
return wrapper;
|
|
356
|
+
}
|
|
357
|
+
let elicitationCounter = 0n;
|
|
358
|
+
let elicitationOwnerCounter = 0n;
|
|
359
|
+
const elicitationOwners = new WeakMap();
|
|
360
|
+
const urlElicitations = new Map();
|
|
361
|
+
const consumedElicitations = new Set();
|
|
362
|
+
function elicitationKey(binding, token, remote) {
|
|
363
|
+
const owner = binding.ownerToken ?? binding;
|
|
364
|
+
let ownerId = elicitationOwners.get(owner);
|
|
365
|
+
if (ownerId === undefined) {
|
|
366
|
+
ownerId = ++elicitationOwnerCounter;
|
|
367
|
+
elicitationOwners.set(owner, ownerId);
|
|
368
|
+
}
|
|
369
|
+
return `${ownerId}\u0000${binding.sessionId}\u0000${token}\u0000${remote}`;
|
|
370
|
+
}
|
|
371
|
+
function clearElicitations(binding, token) {
|
|
372
|
+
if (!binding)
|
|
373
|
+
return;
|
|
374
|
+
const prefix = elicitationKey(binding, token, "");
|
|
375
|
+
for (const [key, entry] of urlElicitations) {
|
|
376
|
+
if (key.startsWith(prefix)) {
|
|
377
|
+
entry.declinePending();
|
|
378
|
+
entry.markCommitted();
|
|
379
|
+
urlElicitations.delete(key);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
for (const key of consumedElicitations) {
|
|
383
|
+
if (key.startsWith(prefix))
|
|
384
|
+
consumedElicitations.delete(key);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
function progress(extra, token, value, diagnostic) {
|
|
388
|
+
if (token === undefined)
|
|
389
|
+
return;
|
|
390
|
+
extra.sendNotification({ method: "notifications/progress", params: { progressToken: token, progress: value, total: 1 } })
|
|
391
|
+
.catch(diagnostic);
|
|
392
|
+
}
|
|
393
|
+
export function createMcpRootsResult(binding, progressToken, extra, onProgressFailure) {
|
|
394
|
+
extra.signal.throwIfAborted();
|
|
395
|
+
binding.sessionSignal.throwIfAborted();
|
|
396
|
+
progress(extra, progressToken, 0, onProgressFailure);
|
|
397
|
+
const result = { roots: [{ uri: pathToFileURL(binding.cwd).href, name: basename(binding.cwd) }] };
|
|
398
|
+
progress(extra, progressToken, 1, onProgressFailure);
|
|
399
|
+
return result;
|
|
400
|
+
}
|
|
401
|
+
export function mapMcpSamplingResult(message, stopSequences = []) {
|
|
402
|
+
if (message.stopReason === "error")
|
|
403
|
+
throw exactMcpError(ErrorCode.InternalError, "MCP sampling failed");
|
|
404
|
+
if (message.stopReason === "aborted")
|
|
405
|
+
throw exactMcpError(ErrorCode.InternalError, "MCP sampling cancelled");
|
|
406
|
+
if (message.content.some((block) => block.type === "toolCall")) {
|
|
407
|
+
throw exactMcpError(ErrorCode.InternalError, "MCP sampling returned unsupported tool output");
|
|
408
|
+
}
|
|
409
|
+
let text = message.content.filter((block) => block.type === "text").map((block) => block.text).join("");
|
|
410
|
+
let stopReason = message.stopReason === "length" ? "maxTokens" : "endTurn";
|
|
411
|
+
let earliest = -1;
|
|
412
|
+
for (const stop of stopSequences) {
|
|
413
|
+
const index = text.indexOf(stop);
|
|
414
|
+
if (index >= 0 && (earliest < 0 || index < earliest))
|
|
415
|
+
earliest = index;
|
|
416
|
+
}
|
|
417
|
+
if (earliest >= 0) {
|
|
418
|
+
text = text.slice(0, earliest);
|
|
419
|
+
stopReason = "stopSequence";
|
|
420
|
+
}
|
|
421
|
+
return {
|
|
422
|
+
role: "assistant",
|
|
423
|
+
model: `${message.provider}/${message.responseModel ?? message.model}`,
|
|
424
|
+
content: { type: "text", text },
|
|
425
|
+
stopReason,
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
function installClientHandlers(client, binding, token, validator, timeoutMs, sleep) {
|
|
429
|
+
if (!binding)
|
|
430
|
+
return;
|
|
431
|
+
const diagnostic = (suffix) => binding.emitDiagnostic(`[mcp:${token}] ${suffix}`);
|
|
432
|
+
client.setRequestHandler(CreateMessageRequestSchema, async (request, extra) => {
|
|
433
|
+
if (request.params.task || (request.params.includeContext && request.params.includeContext !== "none") || request.params.tools || request.params.toolChoice) {
|
|
434
|
+
throw exactMcpError(ErrorCode.InvalidParams, request.params.task ? "Unsupported experimental MCP task" : "Unsupported MCP sampling capability");
|
|
435
|
+
}
|
|
436
|
+
const progressToken = request.params._meta?.progressToken;
|
|
437
|
+
const turnSignal = binding.getTurnSignal();
|
|
438
|
+
try {
|
|
439
|
+
const result = await settleIncomingMcpOperation((signal) => {
|
|
440
|
+
progress(extra, progressToken, 0, () => diagnostic("progress notification failed"));
|
|
441
|
+
const pi = binding.getPi();
|
|
442
|
+
const model = pi?.model;
|
|
443
|
+
if (!pi || !model) {
|
|
444
|
+
throw exactMcpError(ErrorCode.InternalError, "MCP sampling requires an active pi session model");
|
|
52
445
|
}
|
|
53
|
-
|
|
54
|
-
|
|
446
|
+
const prepared = createMcpSamplingPayload(request.params, model);
|
|
447
|
+
return (binding.modelRuntime ?? pi.modelRuntime).completeSimple(model, prepared.context, {
|
|
448
|
+
signal,
|
|
449
|
+
maxTokens: request.params.maxTokens,
|
|
450
|
+
temperature: request.params.temperature,
|
|
451
|
+
metadata: request.params.metadata,
|
|
452
|
+
onPayload: prepared.onPayload,
|
|
453
|
+
}).then((message) => mapMcpSamplingResult(message, request.params.stopSequences));
|
|
454
|
+
}, extra.signal, binding.sessionSignal, turnSignal, timeoutMs, sleep);
|
|
455
|
+
progress(extra, progressToken, 1, () => diagnostic("progress notification failed"));
|
|
456
|
+
return result;
|
|
457
|
+
}
|
|
458
|
+
catch (error) {
|
|
459
|
+
if (error instanceof McpIncomingTerminalError) {
|
|
460
|
+
if (error.terminalCause === "peer" || error.terminalCause === "session") {
|
|
461
|
+
throw error.terminalReason;
|
|
462
|
+
}
|
|
463
|
+
if (error.terminalCause === "turn") {
|
|
464
|
+
throw exactMcpError(ErrorCode.InternalError, "MCP sampling cancelled");
|
|
55
465
|
}
|
|
466
|
+
throw exactMcpError(ErrorCode.InternalError, "MCP sampling timed out");
|
|
56
467
|
}
|
|
57
|
-
|
|
58
|
-
|
|
468
|
+
if (error instanceof McpError)
|
|
469
|
+
throw error;
|
|
470
|
+
throw exactMcpError(ErrorCode.InternalError, "MCP sampling failed");
|
|
471
|
+
}
|
|
472
|
+
});
|
|
473
|
+
client.setRequestHandler(ListRootsRequestSchema, (request, extra) => createMcpRootsResult(binding, request.params?._meta?.progressToken, extra, () => diagnostic("progress notification failed")));
|
|
474
|
+
client.setRequestHandler(ElicitRequestSchema, async (request, extra) => {
|
|
475
|
+
if (request.params.task)
|
|
476
|
+
throw exactMcpError(ErrorCode.InvalidParams, "Unsupported experimental MCP task");
|
|
477
|
+
// Snapshot publication at handler admission. An elicitation received
|
|
478
|
+
// during open remains local even if session/new publishes while it is
|
|
479
|
+
// settling, while still receiving the ordinary 0/1 progress pair.
|
|
480
|
+
const publishedAtAdmission = binding.isPublished();
|
|
481
|
+
const progressToken = request.params._meta?.progressToken;
|
|
482
|
+
const turnSignal = binding.getTurnSignal();
|
|
483
|
+
let urlKey;
|
|
484
|
+
try {
|
|
485
|
+
const response = await settleIncomingMcpOperation(async () => {
|
|
486
|
+
progress(extra, progressToken, 0, () => diagnostic("progress notification failed"));
|
|
487
|
+
if (!publishedAtAdmission)
|
|
488
|
+
return { action: "decline" };
|
|
489
|
+
if (request.params.mode === "form") {
|
|
490
|
+
let validate;
|
|
491
|
+
try {
|
|
492
|
+
validate = validator.getValidator(request.params.requestedSchema);
|
|
493
|
+
}
|
|
494
|
+
catch {
|
|
495
|
+
throw exactMcpError(ErrorCode.InternalError, "MCP elicitation schema validation failed");
|
|
496
|
+
}
|
|
497
|
+
const value = await binding.client.request("elicitation/create", {
|
|
498
|
+
sessionId: binding.sessionId,
|
|
499
|
+
mode: "form",
|
|
500
|
+
message: request.params.message,
|
|
501
|
+
requestedSchema: request.params.requestedSchema,
|
|
502
|
+
});
|
|
503
|
+
if (value.action !== "accept")
|
|
504
|
+
return value;
|
|
505
|
+
const checked = validate(value.content);
|
|
506
|
+
if (!checked.valid)
|
|
507
|
+
throw exactMcpError(ErrorCode.InvalidParams, "Invalid MCP elicitation response");
|
|
508
|
+
return { action: "accept", content: checked.data };
|
|
509
|
+
}
|
|
510
|
+
const urlParams = request.params;
|
|
511
|
+
urlKey = elicitationKey(binding, token, urlParams.elicitationId);
|
|
512
|
+
if (urlElicitations.has(urlKey)) {
|
|
513
|
+
diagnostic("duplicate elicitation id");
|
|
514
|
+
return { action: "decline" };
|
|
515
|
+
}
|
|
516
|
+
if (consumedElicitations.has(urlKey)) {
|
|
517
|
+
diagnostic("reused elicitation id");
|
|
518
|
+
return { action: "decline" };
|
|
519
|
+
}
|
|
520
|
+
const opaque = `pi-acp-elicitation-${++elicitationCounter}`;
|
|
521
|
+
let declinePending;
|
|
522
|
+
let markCommitted;
|
|
523
|
+
const earlyCompletion = new Promise((resolve) => {
|
|
524
|
+
declinePending = () => resolve({ action: "decline" });
|
|
525
|
+
});
|
|
526
|
+
const committed = new Promise((resolve) => { markCommitted = resolve; });
|
|
527
|
+
urlElicitations.set(urlKey, {
|
|
528
|
+
opaque,
|
|
529
|
+
remote: urlParams.elicitationId,
|
|
530
|
+
state: "pending",
|
|
531
|
+
declinePending,
|
|
532
|
+
committed,
|
|
533
|
+
markCommitted,
|
|
534
|
+
});
|
|
535
|
+
const acpRequest = binding.client.request("elicitation/create", {
|
|
536
|
+
sessionId: binding.sessionId,
|
|
537
|
+
mode: "url",
|
|
538
|
+
message: urlParams.message,
|
|
539
|
+
elicitationId: opaque,
|
|
540
|
+
url: urlParams.url,
|
|
541
|
+
});
|
|
542
|
+
acpRequest.then(() => undefined, () => undefined);
|
|
543
|
+
return Promise.race([acpRequest, earlyCompletion]);
|
|
544
|
+
}, extra.signal, binding.sessionSignal, turnSignal, timeoutMs, sleep, (outcome) => {
|
|
545
|
+
if (!urlKey)
|
|
546
|
+
return;
|
|
547
|
+
const entry = urlElicitations.get(urlKey);
|
|
548
|
+
if (outcome.status === "fulfilled" && outcome.value.action === "accept" && entry) {
|
|
549
|
+
entry.state = "accepted";
|
|
550
|
+
entry.markCommitted();
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
urlElicitations.delete(urlKey);
|
|
554
|
+
consumedElicitations.add(urlKey);
|
|
555
|
+
entry?.markCommitted();
|
|
556
|
+
});
|
|
557
|
+
progress(extra, progressToken, 1, () => diagnostic("progress notification failed"));
|
|
558
|
+
if (response.action === "accept") {
|
|
559
|
+
return request.params.mode === "form"
|
|
560
|
+
? { action: "accept", content: response.content }
|
|
561
|
+
: { action: "accept" };
|
|
59
562
|
}
|
|
60
|
-
|
|
61
|
-
|
|
563
|
+
return { action: response.action };
|
|
564
|
+
}
|
|
565
|
+
catch (error) {
|
|
566
|
+
if (error instanceof McpIncomingTerminalError) {
|
|
567
|
+
if (error.terminalCause === "peer" || error.terminalCause === "session") {
|
|
568
|
+
throw error.terminalReason;
|
|
569
|
+
}
|
|
570
|
+
return { action: "cancel" };
|
|
62
571
|
}
|
|
572
|
+
if (error instanceof McpError)
|
|
573
|
+
throw error;
|
|
574
|
+
progress(extra, progressToken, 1, () => diagnostic("progress notification failed"));
|
|
575
|
+
return { action: "decline" };
|
|
576
|
+
}
|
|
577
|
+
});
|
|
578
|
+
client.setNotificationHandler(ElicitationCompleteNotificationSchema, async (notification) => {
|
|
579
|
+
const key = elicitationKey(binding, token, notification.params.elicitationId);
|
|
580
|
+
let entry = urlElicitations.get(key);
|
|
581
|
+
if (!entry) {
|
|
582
|
+
diagnostic(consumedElicitations.has(key) ? "late elicitation completion" : "unknown elicitation completion");
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
if (entry.state === "pending") {
|
|
586
|
+
entry.declinePending();
|
|
587
|
+
await entry.committed;
|
|
588
|
+
entry = urlElicitations.get(key);
|
|
589
|
+
if (!entry) {
|
|
590
|
+
diagnostic("late elicitation completion");
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
urlElicitations.delete(key);
|
|
595
|
+
consumedElicitations.add(key);
|
|
596
|
+
try {
|
|
597
|
+
await binding.client.notify("elicitation/complete", { elicitationId: entry.opaque });
|
|
63
598
|
}
|
|
599
|
+
catch {
|
|
600
|
+
diagnostic("ACP elicitation completion failed");
|
|
601
|
+
}
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
export async function connectDefaultMcpClient(server, signal, timeoutMs, sleep, binding) {
|
|
605
|
+
const token = binding?.serverToken ?? safeToken(server.name);
|
|
606
|
+
const validator = new AjvJsonSchemaValidator();
|
|
607
|
+
const client = new Client({ name: "@automatalabs/pi-acp", version: PKG_VERSION }, {
|
|
608
|
+
enforceStrictCapabilities: true,
|
|
609
|
+
capabilities: { sampling: {}, roots: { listChanged: false }, elicitation: { form: {}, url: {} } },
|
|
610
|
+
jsonSchemaValidator: validator,
|
|
611
|
+
});
|
|
612
|
+
let state = "opening";
|
|
613
|
+
const fatalController = new AbortController();
|
|
614
|
+
let disabledHandler = () => { };
|
|
615
|
+
let toolsChangedHandler;
|
|
616
|
+
let pendingToolsChanged = false;
|
|
617
|
+
const fatal = () => {
|
|
618
|
+
if (state === "opening") {
|
|
619
|
+
clearElicitations(binding, token);
|
|
620
|
+
fatalController.abort(new Error("MCP transport closed while opening"));
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
if (state !== "open")
|
|
624
|
+
return;
|
|
625
|
+
state = "disabled";
|
|
626
|
+
fatalController.abort(new Error("MCP peer closed"));
|
|
627
|
+
clearElicitations(binding, token);
|
|
628
|
+
binding?.emitDiagnostic(`[mcp:${token}] connection closed; server disabled`);
|
|
629
|
+
disabledHandler();
|
|
630
|
+
};
|
|
631
|
+
const transport = createTransport(server, token, sleep, fatal, timeoutMs);
|
|
632
|
+
installClientHandlers(client, binding, token, validator, timeoutMs, sleep);
|
|
633
|
+
const capabilityDiagnostic = (method) => binding?.emitDiagnostic(`[mcp:${token}] ${method}`);
|
|
634
|
+
client.setNotificationHandler(ToolListChangedNotificationSchema, () => {
|
|
635
|
+
const caps = client.getServerCapabilities();
|
|
636
|
+
if (!caps?.tools?.listChanged)
|
|
637
|
+
return capabilityDiagnostic("unexpected notifications/tools/list_changed");
|
|
638
|
+
if (toolsChangedHandler)
|
|
639
|
+
toolsChangedHandler();
|
|
640
|
+
else
|
|
641
|
+
pendingToolsChanged = true;
|
|
642
|
+
});
|
|
643
|
+
client.setNotificationHandler(ResourceListChangedNotificationSchema, () => {
|
|
644
|
+
const caps = client.getServerCapabilities();
|
|
645
|
+
capabilityDiagnostic(caps?.resources?.listChanged ? "notifications/resources/list_changed" : "unexpected notifications/resources/list_changed");
|
|
646
|
+
});
|
|
647
|
+
client.setNotificationHandler(ResourceUpdatedNotificationSchema, (notification) => {
|
|
648
|
+
const caps = client.getServerCapabilities();
|
|
649
|
+
capabilityDiagnostic(caps?.resources?.subscribe
|
|
650
|
+
? `notifications/resources/updated uri=${notification.params.uri}`
|
|
651
|
+
: "unexpected notifications/resources/updated");
|
|
652
|
+
});
|
|
653
|
+
client.setNotificationHandler(PromptListChangedNotificationSchema, () => {
|
|
654
|
+
const caps = client.getServerCapabilities();
|
|
655
|
+
capabilityDiagnostic(caps?.prompts?.listChanged ? "notifications/prompts/list_changed" : "unexpected notifications/prompts/list_changed");
|
|
656
|
+
});
|
|
657
|
+
client.setNotificationHandler(LoggingMessageNotificationSchema, (notification) => {
|
|
658
|
+
if (!client.getServerCapabilities()?.logging) {
|
|
659
|
+
capabilityDiagnostic("unexpected notifications/message");
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
const data = typeof notification.params.data === "string"
|
|
663
|
+
? notification.params.data
|
|
664
|
+
: JSON.stringify(notification.params.data) ?? String(notification.params.data);
|
|
665
|
+
binding?.emitDiagnostic(`[mcp:${token}] ${notification.params.level}: ${data}`);
|
|
666
|
+
});
|
|
667
|
+
client.onerror = () => {
|
|
668
|
+
if (state === "opening" || state === "open")
|
|
669
|
+
binding?.emitDiagnostic(`[mcp:${token}] transport error`);
|
|
670
|
+
};
|
|
671
|
+
try {
|
|
672
|
+
await settleMcpOperation((connectSignal) => client.connect(transport, { timeout: timeoutMs, signal: connectSignal }), signal, binding?.sessionSignal, fatalController.signal, timeoutMs, sleep);
|
|
673
|
+
state = "open";
|
|
674
|
+
}
|
|
675
|
+
catch (error) {
|
|
676
|
+
state = "closing";
|
|
677
|
+
await transport.close();
|
|
678
|
+
state = "closed";
|
|
64
679
|
throw error;
|
|
65
680
|
}
|
|
66
|
-
|
|
681
|
+
const options = (requestSignal, requestTimeout, onprogress) => ({
|
|
682
|
+
signal: requestSignal,
|
|
683
|
+
timeout: requestTimeout,
|
|
684
|
+
...(onprogress ? { onprogress } : {}),
|
|
685
|
+
});
|
|
67
686
|
return {
|
|
68
687
|
async listTools(cursor, requestSignal, requestTimeout) {
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
timeout: requestTimeout,
|
|
72
|
-
});
|
|
73
|
-
return {
|
|
74
|
-
tools: result.tools.map((tool) => ({
|
|
75
|
-
name: tool.name,
|
|
76
|
-
description: tool.description,
|
|
77
|
-
inputSchema: tool.inputSchema,
|
|
78
|
-
})),
|
|
79
|
-
nextCursor: result.nextCursor,
|
|
80
|
-
};
|
|
688
|
+
const raw = await client.listTools(cursor ? { cursor } : undefined, options(requestSignal, requestTimeout));
|
|
689
|
+
return { tools: raw.tools, nextCursor: raw.nextCursor, raw };
|
|
81
690
|
},
|
|
82
|
-
callTool(name, args, requestSignal, requestTimeout) {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
691
|
+
async callTool(name, args, requestSignal, requestTimeout, onprogress) {
|
|
692
|
+
const result = await client.callTool({ name, arguments: typeof args === "object" && args !== null ? args : {} }, undefined, options(requestSignal, requestTimeout, onprogress));
|
|
693
|
+
if (!("content" in result))
|
|
694
|
+
throw new Error("MCP task result did not contain tool content");
|
|
695
|
+
return result;
|
|
696
|
+
},
|
|
697
|
+
async ping(requestSignal, requestTimeout) { await client.ping(options(requestSignal, requestTimeout)); },
|
|
698
|
+
getCapabilities: () => client.getServerCapabilities(),
|
|
699
|
+
getInstructions: () => client.getInstructions(),
|
|
700
|
+
async setLoggingLevel(requestSignal, requestTimeout) { await client.setLoggingLevel("info", options(requestSignal, requestTimeout)); },
|
|
701
|
+
listResources: (cursor, requestOptions) => client.listResources(cursor ? { cursor } : undefined, requestOptions),
|
|
702
|
+
listResourceTemplates: (cursor, requestOptions) => client.listResourceTemplates(cursor ? { cursor } : undefined, requestOptions),
|
|
703
|
+
readResource: (uri, requestOptions) => client.readResource({ uri }, requestOptions),
|
|
704
|
+
subscribeResource: (uri, requestOptions) => client.subscribeResource({ uri }, requestOptions),
|
|
705
|
+
unsubscribeResource: (uri, requestOptions) => client.unsubscribeResource({ uri }, requestOptions),
|
|
706
|
+
listPrompts: (cursor, requestOptions) => client.listPrompts(cursor ? { cursor } : undefined, requestOptions),
|
|
707
|
+
getPrompt: (name, args, requestOptions) => client.getPrompt({ name, arguments: args }, requestOptions),
|
|
708
|
+
complete: (params, requestOptions) => client.complete(params, requestOptions),
|
|
709
|
+
setToolsChangedHandler(handler) {
|
|
710
|
+
toolsChangedHandler = handler;
|
|
711
|
+
if (pendingToolsChanged) {
|
|
712
|
+
pendingToolsChanged = false;
|
|
713
|
+
handler();
|
|
714
|
+
}
|
|
88
715
|
},
|
|
716
|
+
setDisabledHandler(handler) {
|
|
717
|
+
disabledHandler = handler;
|
|
718
|
+
if (state === "disabled")
|
|
719
|
+
handler();
|
|
720
|
+
},
|
|
721
|
+
...("type" in server && server.type === "http" ? {
|
|
722
|
+
disableOnTimeout: () => {
|
|
723
|
+
fatal();
|
|
724
|
+
void transport.close();
|
|
725
|
+
},
|
|
726
|
+
} : {}),
|
|
727
|
+
getPeerSignal: () => fatalController.signal,
|
|
728
|
+
jsonSchemaValidator: validator,
|
|
729
|
+
closeIsBounded: true,
|
|
89
730
|
async close() {
|
|
90
|
-
if (closed)
|
|
731
|
+
if (state === "closed" || state === "closing")
|
|
91
732
|
return;
|
|
92
|
-
|
|
93
|
-
|
|
733
|
+
state = "closing";
|
|
734
|
+
clearElicitations(binding, token);
|
|
735
|
+
// Protocol._onclose() deliberately drops its transport reference as
|
|
736
|
+
// soon as our logical close signal fires. Retain and close the wrapper
|
|
737
|
+
// owner directly so EOF/fatal paths still join HTTP DELETE + physical
|
|
738
|
+
// close instead of turning Client.close() into a no-op.
|
|
739
|
+
await transport.close();
|
|
740
|
+
state = "closed";
|
|
94
741
|
},
|
|
95
742
|
};
|
|
96
743
|
}
|
|
97
|
-
function slug(value) {
|
|
98
|
-
const sanitized = value.replace(/[^A-Za-z0-9_-]+/g, "_").replace(/_+/g, "_");
|
|
99
|
-
return sanitized || "_";
|
|
100
|
-
}
|
|
101
744
|
export function allocateAlias(server, tool, used) {
|
|
102
|
-
const base = `mcp__${
|
|
745
|
+
const base = `mcp__${safeToken(server)}__${safeToken(tool)}`;
|
|
103
746
|
let candidate = base.slice(0, 128);
|
|
104
747
|
if (!used.has(candidate)) {
|
|
105
748
|
used.add(candidate);
|
|
@@ -116,124 +759,668 @@ export function allocateAlias(server, tool, used) {
|
|
|
116
759
|
}
|
|
117
760
|
export function convertMcpContent(content) {
|
|
118
761
|
switch (content.type) {
|
|
119
|
-
case "text":
|
|
120
|
-
|
|
121
|
-
case "
|
|
122
|
-
|
|
123
|
-
case "
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
case "resource":
|
|
128
|
-
return {
|
|
129
|
-
type: "text",
|
|
130
|
-
text: "text" in content.resource
|
|
131
|
-
? content.resource.text
|
|
132
|
-
: `[embedded resource: ${content.resource.uri}]`,
|
|
133
|
-
};
|
|
134
|
-
default: {
|
|
135
|
-
const exhaustive = content;
|
|
136
|
-
return exhaustive;
|
|
137
|
-
}
|
|
762
|
+
case "text": return { type: "text", text: content.text };
|
|
763
|
+
case "image": return { type: "image", data: content.data, mimeType: content.mimeType };
|
|
764
|
+
case "audio": return { type: "text", text: `[audio mime=${content.mimeType} bytes=${Buffer.from(content.data, "base64").byteLength}]` };
|
|
765
|
+
case "resource_link": return { type: "text", text: `[${content.title ?? content.name ?? content.uri}](${content.uri})` };
|
|
766
|
+
case "resource": return "text" in content.resource
|
|
767
|
+
? { type: "text", text: content.resource.text }
|
|
768
|
+
: { type: "text", text: `[embedded resource uri=${content.resource.uri} mime=${content.resource.mimeType ?? "application/octet-stream"} bytes=${Buffer.from(content.resource.blob, "base64").byteLength}]` };
|
|
769
|
+
default: throw new Error("Unsupported MCP content block");
|
|
138
770
|
}
|
|
139
771
|
}
|
|
140
772
|
export function convertMcpResult(result) {
|
|
141
|
-
return {
|
|
142
|
-
content: result.content.map(convertMcpContent),
|
|
143
|
-
...(result.structuredContent === undefined ? {} : { details: result.structuredContent }),
|
|
144
|
-
};
|
|
773
|
+
return { content: result.content.map(convertMcpContent), details: result };
|
|
145
774
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
775
|
+
const EMPTY_SCHEMA = Type.Object({});
|
|
776
|
+
const URI_SCHEMA = Type.Object({ uri: Type.String() });
|
|
777
|
+
const PROMPT_SCHEMA = Type.Object({ name: Type.String(), arguments: Type.Optional(Type.Record(Type.String(), Type.String())) });
|
|
778
|
+
const COMPLETE_SCHEMA = Type.Object({
|
|
779
|
+
ref: Type.Union([
|
|
780
|
+
Type.Object({ type: Type.Literal("ref/prompt"), name: Type.String() }),
|
|
781
|
+
Type.Object({ type: Type.Literal("ref/resource"), uri: Type.String() }),
|
|
782
|
+
]),
|
|
783
|
+
argument: Type.Object({ name: Type.String(), value: Type.String() }),
|
|
784
|
+
context: Type.Optional(Type.Object({ arguments: Type.Optional(Type.Record(Type.String(), Type.String())) })),
|
|
785
|
+
});
|
|
786
|
+
async function pageAll(request, signal, deps, field, onUpdate, serverToken = "_") {
|
|
787
|
+
const items = [];
|
|
788
|
+
const pages = [];
|
|
789
|
+
const seen = new Set();
|
|
790
|
+
let cursor;
|
|
791
|
+
do {
|
|
792
|
+
if (cursor !== undefined) {
|
|
793
|
+
if (seen.has(cursor))
|
|
794
|
+
throw new Error("cycling pagination cursor");
|
|
795
|
+
seen.add(cursor);
|
|
796
|
+
}
|
|
797
|
+
const page = await bounded(request(cursor, {
|
|
798
|
+
signal,
|
|
799
|
+
timeout: deps.mcpTimeoutMs,
|
|
800
|
+
...(onUpdate ? { onprogress: (value) => {
|
|
801
|
+
const item = value;
|
|
802
|
+
onUpdate({
|
|
803
|
+
content: [{
|
|
804
|
+
type: "text",
|
|
805
|
+
text: `[mcp:${serverToken}] ${String(item.progress)}${item.total === undefined ? "" : `/${String(item.total)}`}${item.message === undefined ? "" : ` ${String(item.message)}`}`,
|
|
806
|
+
}],
|
|
807
|
+
details: value,
|
|
808
|
+
});
|
|
809
|
+
} } : {}),
|
|
810
|
+
}), signal, deps.mcpTimeoutMs, deps.sleep);
|
|
811
|
+
pages.push(page);
|
|
812
|
+
const values = page[field];
|
|
813
|
+
if (!Array.isArray(values))
|
|
814
|
+
throw new Error(`invalid ${field} result`);
|
|
815
|
+
items.push(...values);
|
|
816
|
+
cursor = typeof page.nextCursor === "string" ? page.nextCursor : undefined;
|
|
817
|
+
} while (cursor !== undefined);
|
|
818
|
+
return { items, pages };
|
|
819
|
+
}
|
|
820
|
+
function syntheticTool(alias, description, parameters, execute) {
|
|
821
|
+
return { name: alias, label: alias, description, parameters, execute };
|
|
150
822
|
}
|
|
151
|
-
export async function bridgeMcpServers(servers, openSignal, deps) {
|
|
823
|
+
export async function bridgeMcpServers(servers, openSignal, deps, binding) {
|
|
152
824
|
const seenNames = new Set();
|
|
153
825
|
for (const server of servers) {
|
|
154
826
|
if (seenNames.has(server.name))
|
|
155
827
|
throw adapterError("mcp_init_error", { server: server.name });
|
|
156
828
|
seenNames.add(server.name);
|
|
157
|
-
if ("type" in server)
|
|
829
|
+
if ("type" in server && server.type === "acp")
|
|
158
830
|
throw adapterError("unsupported_mcp_transport", { server: server.name });
|
|
159
|
-
}
|
|
160
831
|
}
|
|
161
|
-
const
|
|
832
|
+
const states = [];
|
|
833
|
+
const acquiredHandles = [];
|
|
834
|
+
const failedResults = new Map();
|
|
835
|
+
const aliasServers = new Map();
|
|
162
836
|
const tools = [];
|
|
163
837
|
const aliases = [];
|
|
164
|
-
const aliasServers = new Map();
|
|
165
|
-
const failedResults = new Map();
|
|
166
838
|
const usedAliases = new Set();
|
|
839
|
+
const usedServerTokens = new Set();
|
|
840
|
+
let extensionApi;
|
|
841
|
+
let piSession;
|
|
842
|
+
let refreshQueue = Promise.resolve();
|
|
843
|
+
let refreshScheduled = false;
|
|
844
|
+
let closing = false;
|
|
845
|
+
let poisoned = false;
|
|
846
|
+
let refreshController = new AbortController();
|
|
847
|
+
let refreshPaused = false;
|
|
848
|
+
let boundaryTail = Promise.resolve();
|
|
849
|
+
const assertReady = () => {
|
|
850
|
+
const dead = states.find((state) => state.peerDead || state.handle.getPeerSignal?.().aborted);
|
|
851
|
+
if (dead)
|
|
852
|
+
throw adapterError("mcp_init_error", { server: dead.server.name });
|
|
853
|
+
};
|
|
854
|
+
const acquireTurnBoundary = async () => {
|
|
855
|
+
let release;
|
|
856
|
+
const held = new Promise((resolve) => { release = resolve; });
|
|
857
|
+
const prior = boundaryTail;
|
|
858
|
+
boundaryTail = prior.then(() => held);
|
|
859
|
+
await prior;
|
|
860
|
+
return release;
|
|
861
|
+
};
|
|
862
|
+
const allocateServerToken = (name) => {
|
|
863
|
+
const base = safeToken(name);
|
|
864
|
+
let candidate = base;
|
|
865
|
+
for (let index = 2; usedServerTokens.has(candidate); index += 1)
|
|
866
|
+
candidate = `${base}_${index}`;
|
|
867
|
+
usedServerTokens.add(candidate);
|
|
868
|
+
return candidate;
|
|
869
|
+
};
|
|
870
|
+
const requestOptions = (signal, onprogress) => ({
|
|
871
|
+
signal,
|
|
872
|
+
timeout: deps.mcpTimeoutMs,
|
|
873
|
+
...(onprogress ? { onprogress } : {}),
|
|
874
|
+
});
|
|
875
|
+
const makeSynthetic = (state, operation) => {
|
|
876
|
+
const alias = allocateAlias(state.token, operation, usedAliases);
|
|
877
|
+
state.syntheticAliases.push(alias);
|
|
878
|
+
state.validAliases.add(alias);
|
|
879
|
+
aliases.push(alias);
|
|
880
|
+
aliasServers.set(alias, state.server.name);
|
|
881
|
+
const executeRequest = async (toolCallId, signal, onUpdate, operation) => {
|
|
882
|
+
if (state.disabled || !state.validAliases.has(alias)) {
|
|
883
|
+
throw new Error(`MCP tool ${alias} is no longer available`);
|
|
884
|
+
}
|
|
885
|
+
let acceptingUpdates = true;
|
|
886
|
+
const guardedUpdate = (update) => {
|
|
887
|
+
if (acceptingUpdates)
|
|
888
|
+
onUpdate?.(update);
|
|
889
|
+
};
|
|
890
|
+
let result;
|
|
891
|
+
try {
|
|
892
|
+
result = await settleMcpOperation((requestSignal) => operation(requestSignal, guardedUpdate), signal, binding?.sessionSignal, state.peerDead ? undefined : state.handle.getPeerSignal?.(), deps.mcpTimeoutMs, deps.sleep);
|
|
893
|
+
}
|
|
894
|
+
catch (error) {
|
|
895
|
+
if (isMcpTimeout(error))
|
|
896
|
+
state.handle.disableOnTimeout?.();
|
|
897
|
+
throw new Error(isMcpTimeout(error) ? `MCP tool ${alias} timed out` : `MCP tool ${alias} failed`);
|
|
898
|
+
}
|
|
899
|
+
finally {
|
|
900
|
+
acceptingUpdates = false;
|
|
901
|
+
}
|
|
902
|
+
void toolCallId;
|
|
903
|
+
return result;
|
|
904
|
+
};
|
|
905
|
+
const updateProgress = (onUpdate) => (value) => {
|
|
906
|
+
const item = value;
|
|
907
|
+
onUpdate?.({
|
|
908
|
+
content: [{
|
|
909
|
+
type: "text",
|
|
910
|
+
text: `[mcp:${state.token}] ${String(item.progress)}${item.total === undefined ? "" : `/${String(item.total)}`}${item.message === undefined ? "" : ` ${String(item.message)}`}`,
|
|
911
|
+
}],
|
|
912
|
+
details: value,
|
|
913
|
+
});
|
|
914
|
+
};
|
|
915
|
+
switch (operation) {
|
|
916
|
+
case "list_resources": return syntheticTool(alias, "List MCP resources", EMPTY_SCHEMA, async (_id, _params, signal, onUpdate) => {
|
|
917
|
+
const result = await executeRequest(_id, signal, onUpdate, (requestSignal, guardedUpdate) => pageAll(state.handle.listResources.bind(state.handle), requestSignal, deps, "resources", guardedUpdate, state.token));
|
|
918
|
+
const paged = result;
|
|
919
|
+
return { content: [{ type: "text", text: JSON.stringify({ resources: paged.items }) }], details: { pages: paged.pages } };
|
|
920
|
+
});
|
|
921
|
+
case "list_resource_templates": return syntheticTool(alias, "List MCP resource templates", EMPTY_SCHEMA, async (_id, _params, signal, onUpdate) => {
|
|
922
|
+
const paged = await executeRequest(_id, signal, onUpdate, (requestSignal, guardedUpdate) => pageAll(state.handle.listResourceTemplates.bind(state.handle), requestSignal, deps, "resourceTemplates", guardedUpdate, state.token));
|
|
923
|
+
return { content: [{ type: "text", text: JSON.stringify({ resourceTemplates: paged.items }) }], details: { pages: paged.pages } };
|
|
924
|
+
});
|
|
925
|
+
case "read_resource": return syntheticTool(alias, "Read an MCP resource", URI_SCHEMA, async (_id, params, signal, onUpdate) => {
|
|
926
|
+
const input = params;
|
|
927
|
+
const result = await executeRequest(_id, signal, onUpdate, (requestSignal, guardedUpdate) => state.handle.readResource(input.uri, requestOptions(requestSignal, updateProgress(guardedUpdate))));
|
|
928
|
+
return { content: result.contents.map((content) => content.text !== undefined
|
|
929
|
+
? { type: "text", text: content.text }
|
|
930
|
+
: { type: "text", text: `[embedded resource uri=${content.uri} mime=${content.mimeType ?? "application/octet-stream"} bytes=${Buffer.from(content.blob ?? "", "base64").byteLength}]` }), details: result };
|
|
931
|
+
});
|
|
932
|
+
case "subscribe_resource": return syntheticTool(alias, "Subscribe to an MCP resource", URI_SCHEMA, async (_id, params, signal, onUpdate) => {
|
|
933
|
+
const input = params;
|
|
934
|
+
const result = await executeRequest(_id, signal, onUpdate, (requestSignal, guardedUpdate) => state.handle.subscribeResource(input.uri, requestOptions(requestSignal, updateProgress(guardedUpdate))));
|
|
935
|
+
return { content: [{ type: "text", text: `Subscribed to ${input.uri}` }], details: result };
|
|
936
|
+
});
|
|
937
|
+
case "unsubscribe_resource": return syntheticTool(alias, "Unsubscribe from an MCP resource", URI_SCHEMA, async (_id, params, signal, onUpdate) => {
|
|
938
|
+
const input = params;
|
|
939
|
+
const result = await executeRequest(_id, signal, onUpdate, (requestSignal, guardedUpdate) => state.handle.unsubscribeResource(input.uri, requestOptions(requestSignal, updateProgress(guardedUpdate))));
|
|
940
|
+
return { content: [{ type: "text", text: `Unsubscribed from ${input.uri}` }], details: result };
|
|
941
|
+
});
|
|
942
|
+
case "list_prompts": return syntheticTool(alias, "List MCP prompts", EMPTY_SCHEMA, async (_id, _params, signal, onUpdate) => {
|
|
943
|
+
const paged = await executeRequest(_id, signal, onUpdate, (requestSignal, guardedUpdate) => pageAll(state.handle.listPrompts.bind(state.handle), requestSignal, deps, "prompts", guardedUpdate, state.token));
|
|
944
|
+
return { content: [{ type: "text", text: JSON.stringify({ prompts: paged.items }) }], details: { pages: paged.pages } };
|
|
945
|
+
});
|
|
946
|
+
case "get_prompt": return syntheticTool(alias, "Get an MCP prompt", PROMPT_SCHEMA, async (_id, params, signal, onUpdate) => {
|
|
947
|
+
const input = params;
|
|
948
|
+
const result = await executeRequest(_id, signal, onUpdate, (requestSignal, guardedUpdate) => state.handle.getPrompt(input.name, input.arguments, requestOptions(requestSignal, updateProgress(guardedUpdate))));
|
|
949
|
+
const content = [
|
|
950
|
+
...(result.description ? [{ type: "text", text: `[mcp prompt description]\n${result.description}` }] : []),
|
|
951
|
+
...result.messages.flatMap((message) => [{ type: "text", text: `[mcp prompt role=${message.role}]` }, convertMcpContent(message.content)]),
|
|
952
|
+
];
|
|
953
|
+
return { content, details: result };
|
|
954
|
+
});
|
|
955
|
+
case "complete": return syntheticTool(alias, "Complete an MCP prompt or resource argument", COMPLETE_SCHEMA, async (_id, params, signal, onUpdate) => {
|
|
956
|
+
const result = await executeRequest(_id, signal, onUpdate, (requestSignal, guardedUpdate) => state.handle.complete(params, requestOptions(requestSignal, updateProgress(guardedUpdate))));
|
|
957
|
+
return { content: [{ type: "text", text: JSON.stringify(result.completion) }], details: result };
|
|
958
|
+
});
|
|
959
|
+
default: throw new Error("unknown synthetic MCP operation");
|
|
960
|
+
}
|
|
961
|
+
};
|
|
962
|
+
const remoteDefinition = (state, remote, alias) => ({
|
|
963
|
+
name: alias,
|
|
964
|
+
label: remote.title ?? remote.annotations?.title ?? remote.name,
|
|
965
|
+
description: remote.description ?? `MCP tool ${remote.name}`,
|
|
966
|
+
parameters: remote.inputSchema,
|
|
967
|
+
execute: async (toolCallId, params, signal, onUpdate) => {
|
|
968
|
+
if (state.disabled || !state.validAliases.has(alias) || state.aliases.get(remote.name) !== alias) {
|
|
969
|
+
throw new Error(`MCP tool ${alias} is no longer available`);
|
|
970
|
+
}
|
|
971
|
+
let acceptingUpdates = true;
|
|
972
|
+
let result;
|
|
973
|
+
try {
|
|
974
|
+
result = await settleMcpOperation((requestSignal) => state.handle.callTool(remote.name, params, requestSignal, deps.mcpTimeoutMs, (value) => {
|
|
975
|
+
if (!acceptingUpdates)
|
|
976
|
+
return;
|
|
977
|
+
const progressValue = value;
|
|
978
|
+
const text = `[mcp:${state.token}] ${String(progressValue.progress)}${progressValue.total === undefined ? "" : `/${String(progressValue.total)}`}${progressValue.message === undefined ? "" : ` ${String(progressValue.message)}`}`;
|
|
979
|
+
onUpdate?.({ content: [{ type: "text", text }], details: value });
|
|
980
|
+
}), signal, binding?.sessionSignal, state.peerDead ? undefined : state.handle.getPeerSignal?.(), deps.mcpTimeoutMs, deps.sleep);
|
|
981
|
+
const validate = state.validators.get(alias);
|
|
982
|
+
if (validate && !result.isError) {
|
|
983
|
+
if (result.structuredContent === undefined || !validate(result.structuredContent).valid)
|
|
984
|
+
throw new Error("invalid MCP tool output");
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
catch (error) {
|
|
988
|
+
if (isMcpTimeout(error))
|
|
989
|
+
state.handle.disableOnTimeout?.();
|
|
990
|
+
throw new Error(isMcpTimeout(error) ? `MCP tool ${alias} timed out` : `MCP tool ${alias} failed`);
|
|
991
|
+
}
|
|
992
|
+
finally {
|
|
993
|
+
acceptingUpdates = false;
|
|
994
|
+
}
|
|
995
|
+
const projection = convertMcpResult(result);
|
|
996
|
+
if (result.isError) {
|
|
997
|
+
failedResults.set(toolCallId, projection);
|
|
998
|
+
throw new Error(`MCP tool ${alias} failed`);
|
|
999
|
+
}
|
|
1000
|
+
return projection;
|
|
1001
|
+
},
|
|
1002
|
+
});
|
|
1003
|
+
const enumerate = async (state, lifecycleSignal) => {
|
|
1004
|
+
const listed = [];
|
|
1005
|
+
const pages = [];
|
|
1006
|
+
const seenCursors = new Set();
|
|
1007
|
+
const seenNames = new Set();
|
|
1008
|
+
let cursor;
|
|
1009
|
+
do {
|
|
1010
|
+
if (cursor !== undefined) {
|
|
1011
|
+
if (seenCursors.has(cursor))
|
|
1012
|
+
throw new Error("cycling tools/list cursor");
|
|
1013
|
+
seenCursors.add(cursor);
|
|
1014
|
+
}
|
|
1015
|
+
let page;
|
|
1016
|
+
try {
|
|
1017
|
+
page = await settleMcpOperation((requestSignal) => state.handle.listTools(cursor, requestSignal, deps.mcpTimeoutMs), lifecycleSignal, binding?.sessionSignal, state.handle.getPeerSignal?.(), deps.mcpTimeoutMs, deps.sleep);
|
|
1018
|
+
}
|
|
1019
|
+
catch (error) {
|
|
1020
|
+
if (isMcpTimeout(error))
|
|
1021
|
+
state.handle.disableOnTimeout?.();
|
|
1022
|
+
throw error;
|
|
1023
|
+
}
|
|
1024
|
+
pages.push(page.raw ?? page);
|
|
1025
|
+
for (const tool of page.tools) {
|
|
1026
|
+
if (seenNames.has(tool.name) || tool.execution?.taskSupport === "required")
|
|
1027
|
+
throw new Error("invalid MCP tool catalog");
|
|
1028
|
+
seenNames.add(tool.name);
|
|
1029
|
+
listed.push(tool);
|
|
1030
|
+
}
|
|
1031
|
+
cursor = page.nextCursor;
|
|
1032
|
+
} while (cursor !== undefined);
|
|
1033
|
+
return { tools: listed, pages };
|
|
1034
|
+
};
|
|
1035
|
+
const poison = (state) => {
|
|
1036
|
+
if (poisoned)
|
|
1037
|
+
return;
|
|
1038
|
+
poisoned = true;
|
|
1039
|
+
closing = true;
|
|
1040
|
+
refreshController.abort(new Error("MCP refresh commit failed"));
|
|
1041
|
+
binding?.emitDiagnostic(`[mcp:${state.token}] tools/list refresh commit failed; session terminated`);
|
|
1042
|
+
binding?.poison?.(state.server.name);
|
|
1043
|
+
};
|
|
1044
|
+
const refreshOne = async (state) => {
|
|
1045
|
+
if (closing || refreshPaused || state.peerDead || state.disabled || !extensionApi || !piSession)
|
|
1046
|
+
return;
|
|
1047
|
+
let candidate;
|
|
1048
|
+
let candidateUsed;
|
|
1049
|
+
let nextAliases;
|
|
1050
|
+
let definitions;
|
|
1051
|
+
let validators;
|
|
1052
|
+
let removed;
|
|
1053
|
+
const addedReservations = [];
|
|
1054
|
+
try {
|
|
1055
|
+
candidate = await enumerate(state, refreshController.signal);
|
|
1056
|
+
candidateUsed = new Set(usedAliases);
|
|
1057
|
+
nextAliases = new Map(state.aliases);
|
|
1058
|
+
const previousNames = new Set(state.tools.map((tool) => tool.name));
|
|
1059
|
+
definitions = [];
|
|
1060
|
+
validators = new Map();
|
|
1061
|
+
for (const remote of candidate.tools) {
|
|
1062
|
+
let alias = nextAliases.get(remote.name);
|
|
1063
|
+
if (!alias) {
|
|
1064
|
+
alias = allocateAlias(state.token, remote.name, candidateUsed);
|
|
1065
|
+
nextAliases.set(remote.name, alias);
|
|
1066
|
+
addedReservations.push({ alias, server: state.server.name });
|
|
1067
|
+
}
|
|
1068
|
+
if (remote.outputSchema)
|
|
1069
|
+
validators.set(alias, state.validatorProvider.getValidator(remote.outputSchema));
|
|
1070
|
+
definitions.push(remoteDefinition(state, remote, alias));
|
|
1071
|
+
previousNames.delete(remote.name);
|
|
1072
|
+
}
|
|
1073
|
+
removed = [...previousNames]
|
|
1074
|
+
.map((name) => state.aliases.get(name))
|
|
1075
|
+
.filter((value) => value !== undefined);
|
|
1076
|
+
}
|
|
1077
|
+
catch (error) {
|
|
1078
|
+
if (isMcpTimeout(error))
|
|
1079
|
+
state.handle.disableOnTimeout?.();
|
|
1080
|
+
if (refreshPaused && !closing && !state.peerDead && !state.disabled)
|
|
1081
|
+
state.dirty = true;
|
|
1082
|
+
if (!closing && !refreshPaused && !state.peerDead && !state.disabled)
|
|
1083
|
+
binding?.emitDiagnostic(`[mcp:${state.token}] tools/list refresh failed`);
|
|
1084
|
+
return;
|
|
1085
|
+
}
|
|
1086
|
+
const release = await acquireTurnBoundary();
|
|
1087
|
+
if (closing || refreshPaused || state.peerDead || state.disabled || !extensionApi || !piSession) {
|
|
1088
|
+
if (refreshPaused && !closing && !state.peerDead && !state.disabled)
|
|
1089
|
+
state.dirty = true;
|
|
1090
|
+
release();
|
|
1091
|
+
return;
|
|
1092
|
+
}
|
|
1093
|
+
let mutationStarted = false;
|
|
1094
|
+
try {
|
|
1095
|
+
for (const definition of definitions) {
|
|
1096
|
+
mutationStarted = true;
|
|
1097
|
+
extensionApi.registerTool(definition);
|
|
1098
|
+
}
|
|
1099
|
+
const active = new Set(piSession.getActiveToolNames());
|
|
1100
|
+
for (const alias of removed)
|
|
1101
|
+
active.delete(alias);
|
|
1102
|
+
for (const definition of definitions)
|
|
1103
|
+
active.add(definition.name);
|
|
1104
|
+
mutationStarted = true;
|
|
1105
|
+
piSession.setActiveToolsByName([...active]);
|
|
1106
|
+
usedAliases.clear();
|
|
1107
|
+
for (const alias of candidateUsed)
|
|
1108
|
+
usedAliases.add(alias);
|
|
1109
|
+
for (const reservation of addedReservations) {
|
|
1110
|
+
aliases.push(reservation.alias);
|
|
1111
|
+
aliasServers.set(reservation.alias, reservation.server);
|
|
1112
|
+
}
|
|
1113
|
+
state.aliases = nextAliases;
|
|
1114
|
+
state.tools = candidate.tools;
|
|
1115
|
+
state.pages = candidate.pages;
|
|
1116
|
+
state.validators = validators;
|
|
1117
|
+
state.validAliases = new Set([...state.syntheticAliases, ...definitions.map(({ name }) => name)]);
|
|
1118
|
+
}
|
|
1119
|
+
catch {
|
|
1120
|
+
if (mutationStarted)
|
|
1121
|
+
poison(state);
|
|
1122
|
+
else
|
|
1123
|
+
binding?.emitDiagnostic(`[mcp:${state.token}] tools/list refresh failed`);
|
|
1124
|
+
}
|
|
1125
|
+
finally {
|
|
1126
|
+
release();
|
|
1127
|
+
}
|
|
1128
|
+
};
|
|
1129
|
+
const runRefreshBatches = async () => {
|
|
1130
|
+
while (!closing && !refreshPaused) {
|
|
1131
|
+
const batch = states.filter((state) => state.dirty && !state.initializing && !state.peerDead && !state.disabled);
|
|
1132
|
+
if (batch.length === 0)
|
|
1133
|
+
return;
|
|
1134
|
+
for (const state of batch)
|
|
1135
|
+
state.dirty = false;
|
|
1136
|
+
for (const state of batch)
|
|
1137
|
+
await refreshOne(state);
|
|
1138
|
+
}
|
|
1139
|
+
};
|
|
1140
|
+
const scheduleRefreshes = () => {
|
|
1141
|
+
if (refreshScheduled || closing || refreshPaused || !extensionApi || !piSession)
|
|
1142
|
+
return;
|
|
1143
|
+
refreshScheduled = true;
|
|
1144
|
+
refreshQueue = refreshQueue
|
|
1145
|
+
.then(runRefreshBatches)
|
|
1146
|
+
.finally(() => {
|
|
1147
|
+
refreshScheduled = false;
|
|
1148
|
+
if (!refreshPaused && states.some((state) => state.dirty && !state.initializing && !state.peerDead && !state.disabled))
|
|
1149
|
+
scheduleRefreshes();
|
|
1150
|
+
});
|
|
1151
|
+
refreshQueue.catch(() => undefined);
|
|
1152
|
+
};
|
|
1153
|
+
const refresh = (state) => {
|
|
1154
|
+
if (closing || state.peerDead || state.disabled)
|
|
1155
|
+
return;
|
|
1156
|
+
state.dirty = true;
|
|
1157
|
+
if (!state.initializing && !refreshPaused)
|
|
1158
|
+
scheduleRefreshes();
|
|
1159
|
+
};
|
|
167
1160
|
try {
|
|
168
1161
|
for (const server of servers) {
|
|
1162
|
+
const token = allocateServerToken(server.name);
|
|
169
1163
|
let handle;
|
|
1164
|
+
let state;
|
|
170
1165
|
try {
|
|
171
|
-
|
|
1166
|
+
const serverBinding = binding ? { ...binding, serverToken: token } : undefined;
|
|
1167
|
+
const connecting = deps.connectMcpClient(server, openSignal, serverBinding);
|
|
1168
|
+
connecting.then(() => undefined, () => undefined);
|
|
1169
|
+
try {
|
|
1170
|
+
handle = await settleMcpOperation(() => connecting, openSignal, binding?.sessionSignal, undefined, deps.mcpTimeoutMs, deps.sleep);
|
|
1171
|
+
}
|
|
1172
|
+
catch (error) {
|
|
1173
|
+
// The outer transport-start bound can win before the factory returns its owner. Observe and
|
|
1174
|
+
// close a detached late handle so a real stdio child cannot escape rollback.
|
|
1175
|
+
void connecting.then((late) => late.close()).catch(() => undefined);
|
|
1176
|
+
throw error;
|
|
1177
|
+
}
|
|
1178
|
+
// Ownership transfers immediately when connect returns. Ping, logging,
|
|
1179
|
+
// and enumeration are all post-connect work and rollback must close
|
|
1180
|
+
// this handle if any of them fails.
|
|
1181
|
+
acquiredHandles.push(handle);
|
|
1182
|
+
state = {
|
|
1183
|
+
server,
|
|
1184
|
+
token,
|
|
1185
|
+
handle,
|
|
1186
|
+
tools: [],
|
|
1187
|
+
pages: [],
|
|
1188
|
+
aliases: new Map(),
|
|
1189
|
+
validators: new Map(),
|
|
1190
|
+
validatorProvider: handle.jsonSchemaValidator ?? new AjvJsonSchemaValidator(),
|
|
1191
|
+
syntheticAliases: [],
|
|
1192
|
+
validAliases: new Set(),
|
|
1193
|
+
peerDead: false,
|
|
1194
|
+
disabled: false,
|
|
1195
|
+
dirty: false,
|
|
1196
|
+
initializing: true,
|
|
1197
|
+
};
|
|
1198
|
+
states.push(state);
|
|
1199
|
+
handle.setToolsChangedHandler?.(() => refresh(state));
|
|
1200
|
+
handle.setDisabledHandler?.(() => {
|
|
1201
|
+
if (state.peerDead || state.disabled || closing)
|
|
1202
|
+
return;
|
|
1203
|
+
// Transport death is observable immediately, but alias validity is
|
|
1204
|
+
// committed only while holding the turn boundary. The running turn
|
|
1205
|
+
// therefore retains its selected definition and receives the remote
|
|
1206
|
+
// connection failure, not a premature tombstone.
|
|
1207
|
+
state.peerDead = true;
|
|
1208
|
+
state.dirty = false;
|
|
1209
|
+
refreshQueue = refreshQueue.then(async () => {
|
|
1210
|
+
if (!piSession || closing)
|
|
1211
|
+
return;
|
|
1212
|
+
const release = await acquireTurnBoundary();
|
|
1213
|
+
try {
|
|
1214
|
+
if (!piSession || closing)
|
|
1215
|
+
return;
|
|
1216
|
+
const active = new Set(piSession.getActiveToolNames());
|
|
1217
|
+
for (const alias of [...state.syntheticAliases, ...state.aliases.values()])
|
|
1218
|
+
active.delete(alias);
|
|
1219
|
+
piSession.setActiveToolsByName([...active]);
|
|
1220
|
+
state.validAliases.clear();
|
|
1221
|
+
state.disabled = true;
|
|
1222
|
+
}
|
|
1223
|
+
catch {
|
|
1224
|
+
poison(state);
|
|
1225
|
+
}
|
|
1226
|
+
finally {
|
|
1227
|
+
release();
|
|
1228
|
+
}
|
|
1229
|
+
});
|
|
1230
|
+
refreshQueue.catch(() => undefined);
|
|
1231
|
+
});
|
|
1232
|
+
if (handle.ping) {
|
|
1233
|
+
await settleMcpOperation((requestSignal) => handle.ping(requestSignal, deps.mcpTimeoutMs), openSignal, binding?.sessionSignal, handle.getPeerSignal?.(), deps.mcpTimeoutMs, deps.sleep);
|
|
1234
|
+
}
|
|
1235
|
+
else if (handle.getPeerSignal?.().aborted) {
|
|
1236
|
+
throw new McpOperationTerminalError("peer", handle.getPeerSignal?.().reason);
|
|
1237
|
+
}
|
|
172
1238
|
}
|
|
173
1239
|
catch (error) {
|
|
1240
|
+
if (error instanceof McpOperationTerminalError
|
|
1241
|
+
&& (error.terminalCause === "lifecycle" || error.terminalCause === "session")) {
|
|
1242
|
+
throw error.terminalReason;
|
|
1243
|
+
}
|
|
174
1244
|
if (openSignal.aborted)
|
|
175
|
-
throw
|
|
1245
|
+
throw openSignal.reason;
|
|
176
1246
|
throw adapterError("mcp_init_error", { server: server.name });
|
|
177
1247
|
}
|
|
178
|
-
|
|
179
|
-
const serverTools = [];
|
|
180
|
-
const cursors = new Set();
|
|
181
|
-
let cursor;
|
|
1248
|
+
const caps = handle.getCapabilities?.();
|
|
182
1249
|
try {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
1250
|
+
if (caps?.logging && handle.setLoggingLevel) {
|
|
1251
|
+
await settleMcpOperation((requestSignal) => handle.setLoggingLevel(requestSignal, deps.mcpTimeoutMs), openSignal, binding?.sessionSignal, handle.getPeerSignal?.(), deps.mcpTimeoutMs, deps.sleep);
|
|
1252
|
+
}
|
|
1253
|
+
const operations = [];
|
|
1254
|
+
if (caps?.resources)
|
|
1255
|
+
operations.push("list_resources", "list_resource_templates", "read_resource");
|
|
1256
|
+
if (caps?.resources?.subscribe)
|
|
1257
|
+
operations.push("subscribe_resource", "unsubscribe_resource");
|
|
1258
|
+
if (caps?.prompts)
|
|
1259
|
+
operations.push("list_prompts", "get_prompt");
|
|
1260
|
+
if (caps?.completions)
|
|
1261
|
+
operations.push("complete");
|
|
1262
|
+
for (const operation of operations)
|
|
1263
|
+
tools.push(makeSynthetic(state, operation));
|
|
1264
|
+
const initial = caps?.tools
|
|
1265
|
+
? await enumerate(state, openSignal)
|
|
1266
|
+
: { tools: [], pages: [] };
|
|
1267
|
+
if (state.peerDead || handle.getPeerSignal?.().aborted) {
|
|
1268
|
+
throw new McpOperationTerminalError("peer", handle.getPeerSignal?.().reason);
|
|
1269
|
+
}
|
|
1270
|
+
state.tools = initial.tools;
|
|
1271
|
+
state.pages = initial.pages;
|
|
193
1272
|
}
|
|
194
1273
|
catch (error) {
|
|
1274
|
+
if (error instanceof McpOperationTerminalError
|
|
1275
|
+
&& (error.terminalCause === "lifecycle" || error.terminalCause === "session")) {
|
|
1276
|
+
throw error.terminalReason;
|
|
1277
|
+
}
|
|
195
1278
|
if (openSignal.aborted)
|
|
196
|
-
throw
|
|
1279
|
+
throw openSignal.reason;
|
|
197
1280
|
throw adapterError("mcp_init_error", { server: server.name });
|
|
198
1281
|
}
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
1282
|
+
}
|
|
1283
|
+
// Keep the initialization window bridge-wide. Once every configured server has completed its
|
|
1284
|
+
// first catalog, snapshot the dirty set and close that window for all servers. Notifications
|
|
1285
|
+
// accepted while these coalesced passes run become ordinary post-open dirty work and therefore do
|
|
1286
|
+
// not create an unbounded open-time quiescence loop.
|
|
1287
|
+
const initialDirty = states.filter((state) => state.dirty);
|
|
1288
|
+
for (const state of states)
|
|
1289
|
+
state.initializing = false;
|
|
1290
|
+
for (const state of initialDirty) {
|
|
1291
|
+
state.dirty = false;
|
|
1292
|
+
try {
|
|
1293
|
+
const refreshed = state.handle.getCapabilities?.()?.tools
|
|
1294
|
+
? await enumerate(state, openSignal)
|
|
1295
|
+
: { tools: [], pages: [] };
|
|
1296
|
+
state.tools = refreshed.tools;
|
|
1297
|
+
state.pages = refreshed.pages;
|
|
1298
|
+
}
|
|
1299
|
+
catch (error) {
|
|
1300
|
+
if (error instanceof McpOperationTerminalError
|
|
1301
|
+
&& (error.terminalCause === "lifecycle" || error.terminalCause === "session")) {
|
|
1302
|
+
throw error.terminalReason;
|
|
1303
|
+
}
|
|
1304
|
+
if (openSignal.aborted)
|
|
1305
|
+
throw openSignal.reason;
|
|
1306
|
+
binding?.emitDiagnostic(`[mcp:${state.token}] tools/list refresh failed`);
|
|
1307
|
+
}
|
|
1308
|
+
if (state.peerDead || state.handle.getPeerSignal?.().aborted) {
|
|
1309
|
+
throw adapterError("mcp_init_error", { server: state.server.name });
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
// Every capability-conditioned synthetic reservation precedes every remote tool reservation.
|
|
1313
|
+
for (const state of states) {
|
|
1314
|
+
try {
|
|
1315
|
+
for (const remote of state.tools) {
|
|
1316
|
+
const alias = allocateAlias(state.token, remote.name, usedAliases);
|
|
1317
|
+
state.aliases.set(remote.name, alias);
|
|
1318
|
+
aliases.push(alias);
|
|
1319
|
+
aliasServers.set(alias, state.server.name);
|
|
1320
|
+
state.validAliases.add(alias);
|
|
1321
|
+
if (remote.outputSchema)
|
|
1322
|
+
state.validators.set(alias, state.validatorProvider.getValidator(remote.outputSchema));
|
|
1323
|
+
tools.push(remoteDefinition(state, remote, alias));
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
catch {
|
|
1327
|
+
throw adapterError("mcp_init_error", { server: state.server.name });
|
|
229
1328
|
}
|
|
230
1329
|
}
|
|
231
|
-
return { clients, tools, aliases, aliasServers, failedResults };
|
|
232
1330
|
}
|
|
233
1331
|
catch (error) {
|
|
234
|
-
await closeClients(
|
|
1332
|
+
await closeClients(acquiredHandles, deps);
|
|
235
1333
|
throw error;
|
|
236
1334
|
}
|
|
1335
|
+
const inlineExtension = {
|
|
1336
|
+
name: "agentprism-pi-acp-mcp",
|
|
1337
|
+
factory(api) {
|
|
1338
|
+
extensionApi = api;
|
|
1339
|
+
for (const tool of tools)
|
|
1340
|
+
api.registerTool(tool);
|
|
1341
|
+
},
|
|
1342
|
+
};
|
|
1343
|
+
const instructionsExtension = {
|
|
1344
|
+
name: "agentprism-pi-acp-control",
|
|
1345
|
+
factory(api) {
|
|
1346
|
+
api.on("before_agent_start", (event) => {
|
|
1347
|
+
const suffix = states
|
|
1348
|
+
.filter((state) => !state.disabled)
|
|
1349
|
+
.map((state) => ({ token: state.token, instructions: state.handle.getInstructions?.() }))
|
|
1350
|
+
.filter((item) => Boolean(item.instructions))
|
|
1351
|
+
.map((item) => `\n\n# MCP server instructions (${item.token})\n${item.instructions}`)
|
|
1352
|
+
.join("");
|
|
1353
|
+
return suffix ? { systemPrompt: `${event.systemPrompt}${suffix}` } : undefined;
|
|
1354
|
+
});
|
|
1355
|
+
},
|
|
1356
|
+
};
|
|
1357
|
+
let physicalCloses;
|
|
1358
|
+
let closePromise;
|
|
1359
|
+
const startDisposal = () => {
|
|
1360
|
+
if (!closing)
|
|
1361
|
+
closing = true;
|
|
1362
|
+
physicalCloses ??= closeClients(states.map(({ handle }) => handle), deps);
|
|
1363
|
+
physicalCloses.catch(() => undefined);
|
|
1364
|
+
};
|
|
1365
|
+
const abortRefreshes = () => {
|
|
1366
|
+
refreshPaused = true;
|
|
1367
|
+
if (!refreshController.signal.aborted)
|
|
1368
|
+
refreshController.abort(new Error("MCP refresh aborted"));
|
|
1369
|
+
};
|
|
1370
|
+
return {
|
|
1371
|
+
clients: states.map(({ handle }) => handle),
|
|
1372
|
+
tools,
|
|
1373
|
+
aliases,
|
|
1374
|
+
aliasServers,
|
|
1375
|
+
failedResults,
|
|
1376
|
+
inlineExtension,
|
|
1377
|
+
instructionsExtension,
|
|
1378
|
+
bindSession(session) {
|
|
1379
|
+
assertReady();
|
|
1380
|
+
piSession = session;
|
|
1381
|
+
scheduleRefreshes();
|
|
1382
|
+
},
|
|
1383
|
+
assertReady,
|
|
1384
|
+
acquireTurnBoundary,
|
|
1385
|
+
startDisposal,
|
|
1386
|
+
abortRefreshes,
|
|
1387
|
+
resumeRefreshes() {
|
|
1388
|
+
if (closing || !refreshPaused)
|
|
1389
|
+
return;
|
|
1390
|
+
refreshController = new AbortController();
|
|
1391
|
+
refreshPaused = false;
|
|
1392
|
+
scheduleRefreshes();
|
|
1393
|
+
},
|
|
1394
|
+
drainRefreshes: () => refreshQueue,
|
|
1395
|
+
close() {
|
|
1396
|
+
startDisposal();
|
|
1397
|
+
abortRefreshes();
|
|
1398
|
+
closePromise ??= (async () => {
|
|
1399
|
+
await refreshQueue.catch(() => undefined);
|
|
1400
|
+
const release = await acquireTurnBoundary();
|
|
1401
|
+
release();
|
|
1402
|
+
await physicalCloses;
|
|
1403
|
+
})();
|
|
1404
|
+
return closePromise;
|
|
1405
|
+
},
|
|
1406
|
+
};
|
|
1407
|
+
}
|
|
1408
|
+
async function closeClients(clients, deps) {
|
|
1409
|
+
const closes = [...clients].reverse().map((client) => {
|
|
1410
|
+
let close;
|
|
1411
|
+
try {
|
|
1412
|
+
// Invocation itself is part of the synchronous logical-close prefix.
|
|
1413
|
+
close = client.close();
|
|
1414
|
+
}
|
|
1415
|
+
catch {
|
|
1416
|
+
return Promise.resolve();
|
|
1417
|
+
}
|
|
1418
|
+
close.catch(() => undefined);
|
|
1419
|
+
return client.closeIsBounded
|
|
1420
|
+
? close.catch(() => undefined)
|
|
1421
|
+
: bounded(close, NEVER_ABORTED, deps.mcpTimeoutMs, deps.sleep).catch(() => undefined);
|
|
1422
|
+
});
|
|
1423
|
+
await Promise.allSettled(closes);
|
|
237
1424
|
}
|
|
238
1425
|
export async function disposeMcpBridge(clients, deps) {
|
|
239
1426
|
await closeClients(clients, deps);
|