@automatalabs/pi-acp 0.1.3 → 0.2.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.
@@ -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
- function abortPromise(signal) {
11
- return new Promise((_, reject) => {
12
- if (signal.aborted)
13
- reject(signal.reason);
14
- else
15
- signal.addEventListener("abort", () => reject(signal.reason), { once: true });
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 Promise.race([operation, abortPromise(signal), timeout]);
151
+ return await settleMcpOperation(() => typeof operation === "function" ? operation() : operation, signal, undefined, undefined, timeoutMs, sleep);
26
152
  }
27
- finally {
28
- timeoutController.abort();
29
- timeout.catch(() => undefined);
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 connectDefaultMcpClient(server, signal, timeoutMs, sleep) {
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
- await bounded(client.connect(transport), signal, timeoutMs, sleep);
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
- const pid = transport.pid;
44
- const close = transport.close().catch(() => undefined);
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
- await bounded(close, new AbortController().signal, timeoutMs, sleep);
259
+ physical = this.raw.close();
47
260
  }
48
261
  catch {
49
- if (pid !== null) {
50
- try {
51
- process.kill(pid, "SIGKILL");
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
- catch {
54
- // The child may have exited between the timeout and the kill.
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
- try {
58
- await bounded(close, new AbortController().signal, timeoutMs, sleep);
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
- catch {
61
- close.then(() => undefined, () => undefined);
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
- let closed = false;
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 result = await client.listTools(cursor ? { cursor } : undefined, {
70
- signal: requestSignal,
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
- return client.callTool({ name, arguments: typeof args === "object" && args !== null ? args : {} }, undefined, { signal: requestSignal, timeout: requestTimeout }).then((result) => {
84
- if (!("content" in result))
85
- throw new Error("MCP task result did not contain tool content");
86
- return result;
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
- closed = true;
93
- await client.close();
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__${slug(server)}__${slug(tool)}`;
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
- return { type: "text", text: content.text };
121
- case "image":
122
- return { type: "image", data: content.data, mimeType: content.mimeType };
123
- case "audio":
124
- return { type: "text", text: "[unsupported audio tool-result omitted]" };
125
- case "resource_link":
126
- return { type: "text", text: `[${content.title ?? content.name ?? content.uri}](${content.uri})` };
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
- async function closeClients(clients, deps) {
147
- await Promise.allSettled(clients.map((client) => bounded(client.close(), new AbortController().signal, deps.mcpTimeoutMs, deps.sleep).catch((error) => {
148
- console.error("pi-acp MCP close error:", error);
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 clients = [];
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
- handle = await bounded(deps.connectMcpClient(server, openSignal), openSignal, deps.mcpTimeoutMs, deps.sleep);
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 error;
1245
+ throw openSignal.reason;
176
1246
  throw adapterError("mcp_init_error", { server: server.name });
177
1247
  }
178
- clients.push(handle);
179
- const serverTools = [];
180
- const cursors = new Set();
181
- let cursor;
1248
+ const caps = handle.getCapabilities?.();
182
1249
  try {
183
- do {
184
- if (cursor !== undefined) {
185
- if (cursors.has(cursor))
186
- throw new Error("cycling tools/list cursor");
187
- cursors.add(cursor);
188
- }
189
- const page = await bounded(handle.listTools(cursor, openSignal, deps.mcpTimeoutMs), openSignal, deps.mcpTimeoutMs, deps.sleep);
190
- serverTools.push(...page.tools);
191
- cursor = page.nextCursor;
192
- } while (cursor !== undefined);
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 error;
1279
+ throw openSignal.reason;
197
1280
  throw adapterError("mcp_init_error", { server: server.name });
198
1281
  }
199
- for (const remoteTool of serverTools) {
200
- const alias = allocateAlias(server.name, remoteTool.name, usedAliases);
201
- aliases.push(alias);
202
- aliasServers.set(alias, server.name);
203
- const tool = {
204
- name: alias,
205
- label: remoteTool.name,
206
- description: remoteTool.description ?? `MCP tool ${remoteTool.name}`,
207
- parameters: remoteTool.inputSchema,
208
- execute: async (_toolCallId, params, signal) => {
209
- const turnSignal = signal ?? new AbortController().signal;
210
- let result;
211
- try {
212
- result = await bounded(handle.callTool(remoteTool.name, params, turnSignal, deps.mcpTimeoutMs), turnSignal, deps.mcpTimeoutMs, deps.sleep);
213
- }
214
- catch (error) {
215
- if (error instanceof McpTimeoutError) {
216
- throw new Error(`MCP tool ${alias} timed out`);
217
- }
218
- throw new Error(`MCP tool ${alias} failed`);
219
- }
220
- const converted = convertMcpResult(result);
221
- if (result.isError) {
222
- failedResults.set(_toolCallId, converted);
223
- throw new Error(`MCP tool ${alias} returned an error result`);
224
- }
225
- return converted;
226
- },
227
- };
228
- tools.push(tool);
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(clients, deps);
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);