@zackbart/connecta 0.7.3 → 0.7.4

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.
Files changed (86) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +3 -1
  3. package/dist/connector-scope.d.ts +7 -4
  4. package/dist/connector-scope.d.ts.map +1 -1
  5. package/dist/connector-scope.js +36 -16
  6. package/dist/connector-scope.js.map +1 -1
  7. package/dist/connectors/remote-mcp.d.ts.map +1 -1
  8. package/dist/connectors/remote-mcp.js +47 -16
  9. package/dist/connectors/remote-mcp.js.map +1 -1
  10. package/dist/credential-health.d.ts +3 -2
  11. package/dist/credential-health.d.ts.map +1 -1
  12. package/dist/credential-health.js +9 -9
  13. package/dist/credential-health.js.map +1 -1
  14. package/dist/execute.d.ts +3 -0
  15. package/dist/execute.d.ts.map +1 -1
  16. package/dist/execute.js +70 -29
  17. package/dist/execute.js.map +1 -1
  18. package/dist/executor-admission.d.ts +53 -0
  19. package/dist/executor-admission.d.ts.map +1 -0
  20. package/dist/executor-admission.js +151 -0
  21. package/dist/executor-admission.js.map +1 -0
  22. package/dist/executor-result.d.ts +13 -0
  23. package/dist/executor-result.d.ts.map +1 -0
  24. package/dist/executor-result.js +57 -0
  25. package/dist/executor-result.js.map +1 -0
  26. package/dist/executors/quickjs-child.d.ts +2 -0
  27. package/dist/executors/quickjs-child.d.ts.map +1 -0
  28. package/dist/executors/quickjs-child.js +131 -0
  29. package/dist/executors/quickjs-child.js.map +1 -0
  30. package/dist/executors/quickjs-protocol.d.ts +55 -0
  31. package/dist/executors/quickjs-protocol.d.ts.map +1 -0
  32. package/dist/executors/quickjs-protocol.js +22 -0
  33. package/dist/executors/quickjs-protocol.js.map +1 -0
  34. package/dist/executors/quickjs-runtime.d.ts +26 -0
  35. package/dist/executors/quickjs-runtime.d.ts.map +1 -0
  36. package/dist/executors/quickjs-runtime.js +383 -0
  37. package/dist/executors/quickjs-runtime.js.map +1 -0
  38. package/dist/executors/quickjs.d.ts +20 -15
  39. package/dist/executors/quickjs.d.ts.map +1 -1
  40. package/dist/executors/quickjs.js +459 -311
  41. package/dist/executors/quickjs.js.map +1 -1
  42. package/dist/index.d.ts +13 -2
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +7 -0
  45. package/dist/index.js.map +1 -1
  46. package/dist/meta-tools.d.ts +4 -0
  47. package/dist/meta-tools.d.ts.map +1 -1
  48. package/dist/meta-tools.js +3 -2
  49. package/dist/meta-tools.js.map +1 -1
  50. package/dist/node.d.ts.map +1 -1
  51. package/dist/node.js +36 -3
  52. package/dist/node.js.map +1 -1
  53. package/dist/registry.d.ts +3 -2
  54. package/dist/registry.d.ts.map +1 -1
  55. package/dist/registry.js +4 -4
  56. package/dist/registry.js.map +1 -1
  57. package/dist/server.d.ts.map +1 -1
  58. package/dist/server.js +11 -4
  59. package/dist/server.js.map +1 -1
  60. package/dist/types.d.ts +17 -0
  61. package/dist/types.d.ts.map +1 -1
  62. package/dist/ui.d.ts +2 -1
  63. package/dist/ui.d.ts.map +1 -1
  64. package/dist/ui.js +3 -3
  65. package/dist/ui.js.map +1 -1
  66. package/dist/version.d.ts +1 -1
  67. package/dist/version.js +1 -1
  68. package/package.json +1 -1
  69. package/src/connector-scope.ts +43 -18
  70. package/src/connectors/remote-mcp.ts +56 -19
  71. package/src/credential-health.ts +20 -6
  72. package/src/execute.ts +93 -35
  73. package/src/executor-admission.ts +229 -0
  74. package/src/executor-result.ts +63 -0
  75. package/src/executors/quickjs-child.ts +168 -0
  76. package/src/executors/quickjs-protocol.ts +77 -0
  77. package/src/executors/quickjs-runtime.ts +440 -0
  78. package/src/executors/quickjs.ts +660 -353
  79. package/src/index.ts +28 -4
  80. package/src/meta-tools.ts +9 -1
  81. package/src/node.ts +36 -2
  82. package/src/registry.ts +5 -2
  83. package/src/server.ts +10 -2
  84. package/src/types.ts +17 -0
  85. package/src/ui.ts +6 -1
  86. package/src/version.ts +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zackbart/connecta",
3
- "version": "0.7.3",
3
+ "version": "0.7.4",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "description": "One MCP to rule them all — a single MCP endpoint aggregating many downstream connectors behind nine meta-tools.",
@@ -3,38 +3,63 @@ import type { Connector, ConnectorContext } from "./types.js";
3
3
  /** Enough for local transport abort/close without letting cleanup own latency. */
4
4
  const CONNECTOR_SCOPE_CLOSE_BUDGET_MS = 100;
5
5
 
6
+ /**
7
+ * Bound on cleanup continued after the caller-facing window expires.
8
+ *
9
+ * `remoteMcp` spends at most one second asking the downstream to terminate its
10
+ * session, leaving another second for the local close. Custom hooks still get a
11
+ * finite background window: handing a never-settling promise to a Worker's
12
+ * `waitUntil` would otherwise keep the invocation alive until the platform cap.
13
+ */
14
+ const CONNECTOR_SCOPE_DEFER_BUDGET_MS = 2_000;
15
+
16
+ /** Runtime hook for work that may safely continue after a response is ready. */
17
+ export type DeferredWork = (promise: Promise<unknown>) => void;
18
+
19
+ /** Resolve when `work` settles or `budgetMs` expires; never reject. */
20
+ function waitAtMost(work: Promise<void>, budgetMs: number): Promise<void> {
21
+ return new Promise((resolve) => {
22
+ const timer = setTimeout(resolve, budgetMs);
23
+ work.then(() => {
24
+ clearTimeout(timer);
25
+ resolve();
26
+ });
27
+ });
28
+ }
29
+
6
30
  /**
7
31
  * Tell a connector that a scope owned by the core has ended.
8
32
  *
9
33
  * Scope teardown is deliberately best-effort: a missing hook is a no-op and a
10
34
  * rejected hook is swallowed so cleanup can never replace the probe result that
11
35
  * caused it. The hook gets a small, fixed completion window so edge runtimes do
12
- * not cut off a real close as the response ends, but one that never settles
13
- * cannot hold the completed probe open indefinitely. Callers own the
14
- * at-most-once guarantee and must not use the scope again after this returns.
36
+ * not cut off a real close as the response ends. When the runtime supplies
37
+ * `defer`, the bounded tail is handed to it without extending the caller-facing
38
+ * window. Callers own the at-most-once guarantee and must not use the scope
39
+ * again after this returns.
15
40
  */
16
41
  export async function closeConnectorScope(
17
42
  connector: Connector,
18
43
  ctx: ConnectorContext,
44
+ defer?: DeferredWork,
19
45
  ): Promise<void> {
20
46
  try {
21
47
  const closing = connector.closeScope?.(ctx);
22
48
  if (!closing) return;
23
- await new Promise<void>((resolve) => {
24
- const timer = setTimeout(resolve, CONNECTOR_SCOPE_CLOSE_BUDGET_MS);
25
- // Both handlers stay attached after the timer wins, so a late rejection
26
- // is still consumed rather than becoming an unhandled rejection.
27
- closing.then(
28
- () => {
29
- clearTimeout(timer);
30
- resolve();
31
- },
32
- () => {
33
- clearTimeout(timer);
34
- resolve();
35
- },
36
- );
37
- });
49
+ // Attach both handlers before either timer can win, so a late rejection is
50
+ // consumed rather than becoming an unhandled rejection.
51
+ const settled = closing.then(
52
+ () => {},
53
+ () => {},
54
+ );
55
+ if (defer) {
56
+ try {
57
+ defer(waitAtMost(settled, CONNECTOR_SCOPE_DEFER_BUDGET_MS));
58
+ } catch {
59
+ // A runtime hook is best-effort too; the caller cap still applies.
60
+ }
61
+ }
62
+ await waitAtMost(settled, CONNECTOR_SCOPE_CLOSE_BUDGET_MS);
38
63
  } catch {
39
64
  // The scope is over whether or not the connector managed to clean it up.
40
65
  }
@@ -75,15 +75,14 @@ export interface RemoteMcpOptions {
75
75
 
76
76
  /**
77
77
  * How long a downstream gets to answer the session-termination DELETE before
78
- * teardown stops waiting. Deliberately a fraction of the core's scope-close
79
- * budget (`closeConnectorScope`), so the whole teardown still lands inside the
80
- * window that budget makes safe on an edge runtime: whatever is still in flight
81
- * when this expires is aborted by the close that immediately follows. What that
82
- * costs is only the acknowledgement the DELETE is headers-only and has long
83
- * since gone out so a slow provider still usually ends the session; it just
84
- * does not get to tell us so.
78
+ * teardown stops waiting. This is a network round-trip budget, deliberately
79
+ * independent of the core's 100 ms caller-facing scope-close window: an
80
+ * already-established cross-internet connection avoids setup, but 50 ms is
81
+ * still too short for an ordinary round trip plus modest provider scheduling.
82
+ * The bounded tail is deferred on runtimes that can keep it alive after the
83
+ * response, while callers continue to wait at most 100 ms.
85
84
  */
86
- const TERMINATE_SESSION_BUDGET_MS = 50;
85
+ const TERMINATE_SESSION_BUDGET_MS = 1_000;
87
86
 
88
87
  /**
89
88
  * Ceiling on the tools one catalog refresh will accumulate while walking
@@ -199,24 +198,62 @@ function msg(err: unknown): string {
199
198
  * (405 is a legal answer), errors, or never replies all fall through to the
200
199
  * close with the session left to age out as it did before.
201
200
  */
202
- async function terminateSession(transport: Transport): Promise<void> {
201
+ async function terminateSession(
202
+ transport: Transport,
203
+ logger: Logger,
204
+ connectorId: string,
205
+ ): Promise<void> {
203
206
  const terminate = (
204
207
  transport as Transport & { terminateSession?: () => Promise<void> }
205
208
  ).terminateSession;
206
209
  if (typeof terminate !== "function") return;
207
210
  // The SDK issues no request at all when no `mcp-session-id` was captured, so
208
211
  // a stateless downstream never sees a spurious DELETE.
209
- const done = (async () => terminate.call(transport))().catch(() => {
210
- // The session is being abandoned either way; a refusal changes nothing.
211
- // Caught here rather than at the await below so a late rejection — one
212
- // arriving after the budget expired — is still consumed.
213
- });
212
+ const done = Promise.resolve().then(() => terminate.call(transport));
214
213
  await new Promise<void>((resolve) => {
215
- const timer = setTimeout(resolve, TERMINATE_SESSION_BUDGET_MS);
216
- done.then(() => {
217
- clearTimeout(timer);
214
+ let finished = false;
215
+ const warn = (message: string, error?: unknown) => {
216
+ try {
217
+ if (error === undefined) logger.warn(message);
218
+ else logger.warn(message, error);
219
+ } catch {
220
+ // A diagnostic sink cannot make best-effort teardown observable to the
221
+ // caller in the one way this contract forbids: by replacing its result.
222
+ }
223
+ };
224
+ const timer = setTimeout(() => {
225
+ if (finished) return;
226
+ finished = true;
227
+ warn(
228
+ `[connecta] connector "${connectorId}" session termination was not ` +
229
+ `acknowledged within ${TERMINATE_SESSION_BUDGET_MS} ms; the ` +
230
+ "downstream may still finish the headers-only DELETE, otherwise " +
231
+ "the session will remain until its provider timeout.",
232
+ );
218
233
  resolve();
219
- });
234
+ }, TERMINATE_SESSION_BUDGET_MS);
235
+ done.then(
236
+ () => {
237
+ if (finished) return;
238
+ finished = true;
239
+ clearTimeout(timer);
240
+ resolve();
241
+ },
242
+ (error) => {
243
+ // The rejection handler stays attached after the timer wins, so an
244
+ // abort or other late failure is consumed without a duplicate warning.
245
+ if (finished) return;
246
+ finished = true;
247
+ clearTimeout(timer);
248
+ warn(
249
+ `[connecta] connector "${connectorId}" session termination was ` +
250
+ "refused or failed; the downstream session may remain until its " +
251
+ "provider timeout.",
252
+ error,
253
+ );
254
+ resolve();
255
+ },
256
+ );
220
257
  });
221
258
  }
222
259
 
@@ -797,7 +834,7 @@ export function remoteMcp(id: string, opts: RemoteMcpOptions): Connector {
797
834
  // Ask the downstream to drop its session first — closing only aborts our
798
835
  // side, and the DELETE that frees the server's rides on the very
799
836
  // AbortSignal the close is about to trip.
800
- if (transport) await terminateSession(transport);
837
+ if (transport) await terminateSession(transport, ctx.logger, id);
801
838
 
802
839
  // Client.close() owns its connected transport. During an unfinished or
803
840
  // failed connect there is no cached client yet, so close the transport
@@ -21,7 +21,10 @@ import {
21
21
  storedCredentialShape,
22
22
  } from "./credentials.js";
23
23
  import type { CredentialVault } from "./credentials.js";
24
- import { closeConnectorScope } from "./connector-scope.js";
24
+ import {
25
+ closeConnectorScope,
26
+ type DeferredWork,
27
+ } from "./connector-scope.js";
25
28
  import { DEFAULT_PROBE_TIMEOUT_MS, normalizeTimeoutMs, withTimeout } from "./timeout.js";
26
29
  import type {
27
30
  Connector,
@@ -536,6 +539,7 @@ export class CredentialHealthChecker {
536
539
  async check(
537
540
  baseUrl: string,
538
541
  opts: CredentialCheckOptions = {},
542
+ defer?: DeferredWork,
539
543
  ): Promise<CredentialCheckResult[]> {
540
544
  // An id naming no connector is reported, not dropped: a typo in a scheduled
541
545
  // check would otherwise return an empty list that looks exactly like a
@@ -546,7 +550,7 @@ export class CredentialHealthChecker {
546
550
  return mapWithConcurrency(targets, this.concurrency, (target) =>
547
551
  typeof target === "string"
548
552
  ? Promise.resolve({ connectorId: target, skipped: "not_found" as const })
549
- : this.checkOne(target, baseUrl, opts),
553
+ : this.checkOne(target, baseUrl, opts, defer),
550
554
  );
551
555
  }
552
556
 
@@ -556,13 +560,16 @@ export class CredentialHealthChecker {
556
560
  * gate is armed BEFORE the sweep starts, so a burst of concurrent requests
557
561
  * produces one sweep.
558
562
  */
559
- sweepIfDue(baseUrl: string): Promise<CredentialCheckResult[]> | undefined {
563
+ sweepIfDue(
564
+ baseUrl: string,
565
+ defer?: DeferredWork,
566
+ ): Promise<CredentialCheckResult[]> | undefined {
560
567
  if (!this.onRequest || this.sweeping) return undefined;
561
568
  const now = Date.now();
562
569
  if (now < this.nextSweepAt) return undefined;
563
570
  if (!this.hasCheckableConnectors()) return undefined;
564
571
  this.nextSweepAt = now + this.intervalMs;
565
- const sweep = this.check(baseUrl).finally(() => {
572
+ const sweep = this.check(baseUrl, {}, defer).finally(() => {
566
573
  this.sweeping = undefined;
567
574
  });
568
575
  this.sweeping = sweep;
@@ -573,6 +580,7 @@ export class CredentialHealthChecker {
573
580
  connector: Connector,
574
581
  baseUrl: string,
575
582
  opts: CredentialCheckOptions,
583
+ defer?: DeferredWork,
576
584
  ): Promise<CredentialCheckResult> {
577
585
  const connectorId = connector.id;
578
586
  if (!isCheckableConnector(connector)) {
@@ -587,7 +595,12 @@ export class CredentialHealthChecker {
587
595
  ...(await this.recordOrNothing(connectorId)),
588
596
  };
589
597
  }
590
- const run = this.runCheck(connector, baseUrl, opts.force ?? false);
598
+ const run = this.runCheck(
599
+ connector,
600
+ baseUrl,
601
+ opts.force ?? false,
602
+ defer,
603
+ );
591
604
  this.inFlight.set(connectorId, run);
592
605
  try {
593
606
  return await run;
@@ -607,6 +620,7 @@ export class CredentialHealthChecker {
607
620
  connector: Connector,
608
621
  baseUrl: string,
609
622
  force: boolean,
623
+ defer?: DeferredWork,
610
624
  ): Promise<CredentialCheckResult> {
611
625
  const connectorId = connector.id;
612
626
  const started = Date.now();
@@ -711,7 +725,7 @@ export class CredentialHealthChecker {
711
725
  });
712
726
  }
713
727
  } finally {
714
- await closeConnectorScope(connector, ctx);
728
+ await closeConnectorScope(connector, ctx, defer);
715
729
  }
716
730
  }
717
731
 
package/src/execute.ts CHANGED
@@ -8,10 +8,18 @@ import {
8
8
  discoverySearchLimit,
9
9
  errorResult,
10
10
  jsonResult,
11
- serializeResultText,
12
11
  type ToolResult,
13
12
  } from "./meta-tools.js";
13
+ import {
14
+ guardExecuteResultValue,
15
+ MAX_EXECUTE_LOG_CHARS,
16
+ truncateExecuteText,
17
+ } from "./executor-result.js";
14
18
  import { classifyCallError, ConnectorCallError } from "./errors.js";
19
+ import {
20
+ ExecutorAdmissionError,
21
+ isAdmittingExecutor,
22
+ } from "./executor-admission.js";
15
23
  import { unwrapMcpResult } from "./mcp-result.js";
16
24
  import type { RegistryView } from "./registry.js";
17
25
  import type {
@@ -22,9 +30,6 @@ import type {
22
30
  ToolDef,
23
31
  } from "./types.js";
24
32
 
25
- /** ~6k tokens. Sandbox code should filter data down before returning. */
26
- const MAX_RESULT_CHARS = 24_000;
27
- const MAX_LOG_CHARS = 4_000;
28
33
  /** Keep one model-written program from amplifying into an unbounded fan-out. */
29
34
  export const EXECUTE_MAX_HOST_CALLS = 20;
30
35
  export const EXECUTE_MAX_BATCH_CALLS = 10;
@@ -136,6 +141,7 @@ export async function buildSandboxProviders(
136
141
  "connecta",
137
142
  "console",
138
143
  "__invoke",
144
+ "__namespace",
139
145
  "__call",
140
146
  "__log",
141
147
  ]);
@@ -143,9 +149,17 @@ export async function buildSandboxProviders(
143
149
  const catalogStarted = Date.now();
144
150
  const loaded = await Promise.allSettled(
145
151
  connectors.map((connector) =>
146
- registry.getTools(connector.id, baseUrl, requestScope),
152
+ registry.getTools(connector.id, baseUrl, requestScope, {
153
+ signal: limits.signal,
154
+ }),
147
155
  ),
148
156
  );
157
+ if (limits.signal?.aborted) {
158
+ throw new ExecutorAdmissionError(
159
+ "executor_cancelled",
160
+ "Execution was cancelled during catalog construction.",
161
+ );
162
+ }
149
163
  const catalogs = new Map<string, ToolDef[]>();
150
164
  const callAddress = async (address: unknown, args: unknown) => {
151
165
  const resolved = registry.resolveAddress(String(address));
@@ -474,24 +488,6 @@ export async function buildSandboxProviders(
474
488
  return providers;
475
489
  }
476
490
 
477
- function truncate(text: string, max: number): string {
478
- if (text.length <= max) return text;
479
- return `${text.slice(0, max)}\n--- TRUNCATED (${text.length} chars total) — filter/map/slice data inside your code and return only what you need ---`;
480
- }
481
-
482
- function guardResultValue(value: unknown): unknown {
483
- // Same serialization the call_tool guards measure, so a program returning
484
- // nothing is rendered one way across every result path (issue #42).
485
- const text = serializeResultText(value);
486
- if (text.length <= MAX_RESULT_CHARS) return value;
487
- return {
488
- truncated: true,
489
- preview: text.slice(0, MAX_RESULT_CHARS),
490
- totalChars: text.length,
491
- hint: "filter/map/slice data inside execute_code and return only what you need",
492
- };
493
- }
494
-
495
491
  /** The execute_code handler. Exported for direct testing. */
496
492
  export function createExecuteTool(
497
493
  registry: RegistryView,
@@ -500,28 +496,69 @@ export function createExecuteTool(
500
496
  logger: Logger,
501
497
  activity?: ActivityRequestContext,
502
498
  ) {
503
- return async ({ code }: { code: string }): Promise<ToolResult> => {
499
+ return async (
500
+ { code }: { code: string },
501
+ options: { signal?: AbortSignal } = {},
502
+ ): Promise<ToolResult> => {
504
503
  const controller = new AbortController();
505
- const providers = await buildSandboxProviders(
506
- registry,
507
- baseUrl,
508
- logger,
509
- activity,
510
- { signal: controller.signal },
511
- );
504
+ const forwardAbort = () => controller.abort(options.signal?.reason);
505
+ if (options.signal?.aborted) forwardAbort();
506
+ else {
507
+ options.signal?.addEventListener("abort", forwardAbort, { once: true });
508
+ }
509
+ let lease;
512
510
  let outcome;
513
511
  try {
514
- outcome = await executor.execute(code, providers);
512
+ // Admission comes before provider construction: queued calls retain no
513
+ // catalogs, request scopes, or one-closure-per-tool provider arrays.
514
+ if (isAdmittingExecutor(executor)) {
515
+ lease = await executor.acquire({ signal: controller.signal });
516
+ }
517
+ const providers = await buildSandboxProviders(
518
+ registry,
519
+ baseUrl,
520
+ logger,
521
+ activity,
522
+ { signal: controller.signal },
523
+ );
524
+ if (controller.signal.aborted) {
525
+ throw new ExecutorAdmissionError(
526
+ "executor_cancelled",
527
+ "Execution was cancelled during sandbox setup.",
528
+ );
529
+ }
530
+ outcome = lease
531
+ ? await lease.execute(code, providers)
532
+ : await executor.execute(code, providers);
515
533
  } catch (err) {
534
+ if (err instanceof ExecutorAdmissionError) {
535
+ const result = jsonResult({
536
+ error: {
537
+ code: err.code,
538
+ message: err.message,
539
+ retryable: err.retryable,
540
+ ...(err.retryAfterMs !== undefined
541
+ ? { retryAfterMs: err.retryAfterMs }
542
+ : {}),
543
+ },
544
+ });
545
+ result.isError = true;
546
+ return result;
547
+ }
516
548
  return errorResult(`Executor failed: ${msg(err)}`);
517
549
  } finally {
518
550
  // A sandbox timeout or early return must also release any outstanding
519
551
  // host waits and signal cooperative connectors to stop their work.
520
552
  controller.abort();
553
+ lease?.release();
554
+ options.signal?.removeEventListener("abort", forwardAbort);
521
555
  }
522
556
  const logs =
523
557
  outcome.logs && outcome.logs.length > 0
524
- ? truncate(outcome.logs.join("\n"), MAX_LOG_CHARS)
558
+ ? truncateExecuteText(
559
+ outcome.logs.join("\n"),
560
+ MAX_EXECUTE_LOG_CHARS,
561
+ )
525
562
  : undefined;
526
563
  if (outcome.error) {
527
564
  return errorResult(
@@ -533,7 +570,7 @@ export function createExecuteTool(
533
570
  // error path so captured logs survive instead of a raw SDK 500.
534
571
  let result: unknown;
535
572
  try {
536
- result = guardResultValue(outcome.result);
573
+ result = guardExecuteResultValue(outcome.result);
537
574
  } catch (err) {
538
575
  return errorResult(
539
576
  `Error: result is not JSON-serializable: ${msg(err)}${logs ? `\n\nLogs:\n${logs}` : ""}`,
@@ -568,6 +605,7 @@ export function registerExecuteTool(
568
605
  executor: Executor;
569
606
  logger: Logger;
570
607
  activity?: ActivityRequestContext;
608
+ requestSignal?: AbortSignal;
571
609
  },
572
610
  ): void {
573
611
  const handler = createExecuteTool(
@@ -594,6 +632,26 @@ export function registerExecuteTool(
594
632
  openWorldHint: true,
595
633
  },
596
634
  },
597
- async (args) => handler(args as { code: string }),
635
+ async (args, extra) => {
636
+ const controller = new AbortController();
637
+ const signals = [extra.signal, ctx.requestSignal].filter(
638
+ (signal): signal is AbortSignal => signal !== undefined,
639
+ );
640
+ const forwarders = signals.map((signal) => {
641
+ const forward = () => controller.abort(signal.reason);
642
+ if (signal.aborted) forward();
643
+ else signal.addEventListener("abort", forward, { once: true });
644
+ return { signal, forward };
645
+ });
646
+ try {
647
+ return await handler(args as { code: string }, {
648
+ signal: controller.signal,
649
+ });
650
+ } finally {
651
+ for (const { signal, forward } of forwarders) {
652
+ signal.removeEventListener("abort", forward);
653
+ }
654
+ }
655
+ },
598
656
  );
599
657
  }