@zackbart/connecta 0.7.1 → 0.7.3

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 (54) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/README.md +2 -1
  3. package/dist/connectors/remote-mcp.d.ts.map +1 -1
  4. package/dist/connectors/remote-mcp.js +117 -79
  5. package/dist/connectors/remote-mcp.js.map +1 -1
  6. package/dist/credential-health.d.ts +8 -5
  7. package/dist/credential-health.d.ts.map +1 -1
  8. package/dist/credential-health.js +20 -13
  9. package/dist/credential-health.js.map +1 -1
  10. package/dist/executors/quickjs.d.ts.map +1 -1
  11. package/dist/executors/quickjs.js +57 -5
  12. package/dist/executors/quickjs.js.map +1 -1
  13. package/dist/index.d.ts +2 -1
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js.map +1 -1
  16. package/dist/meta-tools.d.ts.map +1 -1
  17. package/dist/meta-tools.js +47 -11
  18. package/dist/meta-tools.js.map +1 -1
  19. package/dist/registry.d.ts +18 -22
  20. package/dist/registry.d.ts.map +1 -1
  21. package/dist/registry.js +33 -21
  22. package/dist/registry.js.map +1 -1
  23. package/dist/server.d.ts.map +1 -1
  24. package/dist/server.js +9 -6
  25. package/dist/server.js.map +1 -1
  26. package/dist/timeout.d.ts +9 -4
  27. package/dist/timeout.d.ts.map +1 -1
  28. package/dist/timeout.js +34 -4
  29. package/dist/timeout.js.map +1 -1
  30. package/dist/toolkits.d.ts +8 -0
  31. package/dist/toolkits.d.ts.map +1 -1
  32. package/dist/toolkits.js +3 -0
  33. package/dist/toolkits.js.map +1 -1
  34. package/dist/types.d.ts +2 -2
  35. package/dist/types.d.ts.map +1 -1
  36. package/dist/ui.d.ts +12 -1
  37. package/dist/ui.d.ts.map +1 -1
  38. package/dist/ui.js +187 -6
  39. package/dist/ui.js.map +1 -1
  40. package/dist/version.d.ts +1 -1
  41. package/dist/version.js +1 -1
  42. package/package.json +1 -1
  43. package/src/connectors/remote-mcp.ts +128 -83
  44. package/src/credential-health.ts +20 -18
  45. package/src/executors/quickjs.ts +65 -5
  46. package/src/index.ts +2 -1
  47. package/src/meta-tools.ts +75 -26
  48. package/src/registry.ts +48 -20
  49. package/src/server.ts +11 -8
  50. package/src/timeout.ts +41 -4
  51. package/src/toolkits.ts +11 -0
  52. package/src/types.ts +2 -2
  53. package/src/ui.ts +212 -11
  54. package/src/version.ts +1 -1
@@ -5,7 +5,9 @@ import type {
5
5
  FetchLike,
6
6
  Transport,
7
7
  } from "@modelcontextprotocol/sdk/shared/transport.js";
8
+ import { ListToolsResultSchema } from "@modelcontextprotocol/sdk/types.js";
8
9
  import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/cfworker";
10
+ import { z } from "zod";
9
11
  import { KvOAuthProvider } from "../auth/downstream-oauth.js";
10
12
  import { ConnectorCallError } from "../errors.js";
11
13
  import { CONNECTA_VERSION } from "../version.js";
@@ -115,27 +117,33 @@ const MAX_TOOLS = 100_000;
115
117
  * is a definite loop, two consecutive pages that add no new tools are a server
116
118
  * going nowhere, and MAX_TOOLS caps what any of it can accumulate. This exists
117
119
  * only so the loop is finite even if a downstream somehow satisfies all three
118
- * forever, because the caller's probe deadline abandons the *caller*, not the
119
- * loop. Set high enough that no honest server reaches it.
120
+ * forever on a path with no discovery deadline. Set high enough that no honest
121
+ * server reaches it.
120
122
  */
121
123
  const MAX_TOOL_PAGES = 10_000;
122
124
 
123
125
  /** One entry of the SDK's `tools/list` result, before it becomes a ToolDef. */
124
126
  type ListedTool = Awaited<ReturnType<Client["listTools"]>>["tools"][number];
125
127
 
128
+ /**
129
+ * Compatibility concession for hand-rolled servers that serialize
130
+ * end-of-pagination as `null`. Only the cursor is widened; every tool and every
131
+ * other result field still passes through the SDK's pinned schema.
132
+ */
133
+ const CompatibleListToolsResultSchema = ListToolsResultSchema.extend({
134
+ nextCursor: z.string().nullable().optional(),
135
+ });
136
+
126
137
  /**
127
138
  * Re-prime an SDK client's tool-metadata cache from the *full* walked catalog.
128
139
  *
129
- * `Client.listTools()` ends by calling its private `cacheToolMetadata`, which
130
- * **clears** the output-schema validators and the task-support sets before
131
- * repopulating them from the page it just received. Call it once per page —
132
- * which walking the chain necessarily does and the request-scoped client is
133
- * left holding metadata for the *last* page alone. `callTool` then finds no
134
- * validator for every earlier-page tool and silently skips both the "declared
135
- * an outputSchema but returned no structuredContent" check and the
136
- * structured-content validation, and finds no task requirement so a
137
- * required-task tool is dispatched as a plain `tools/call`. Enforcement would
138
- * depend on which page a tool happened to land on, which is not enforcement.
140
+ * The SDK's `Client.listTools()` caches one page at a time and **clears** the
141
+ * output-schema validators and task-support sets before each replacement.
142
+ * This walk uses `Client.request()` so it can make the narrow null-cursor
143
+ * compatibility concession above, then primes the metadata exactly once from
144
+ * the complete chain. Otherwise `callTool` would find no validator or task
145
+ * requirement for earlier-page tools and enforcement would depend on where a
146
+ * tool happened to land, which is not enforcement.
139
147
  *
140
148
  * So hand the whole aggregated list back deliberately, once, at the end. The
141
149
  * SDK types the method `private`, hence the cast; the SDK version is pinned
@@ -154,9 +162,9 @@ function primeToolMetadata(client: Client, tools: ListedTool[]): void {
154
162
  }
155
163
 
156
164
  /**
157
- * True for a result-parse failure caused by the page's `nextCursor` itself
158
- * in practice `nextCursor: null`, a very common JSON idiom for "no more pages"
159
- * that the MCP schema does not accept (the chain ends on an *absent* cursor).
165
+ * True for a result-parse failure caused by the page's `nextCursor` itself.
166
+ * `null` is accepted deliberately; other non-string values remain a named
167
+ * downstream nonconformance instead of surfacing as a raw validation dump.
160
168
  * Duck-typed rather than `instanceof ZodError`: the SDK may parse with its own
161
169
  * zod instance, and cross-instance `instanceof` is a coin flip.
162
170
  */
@@ -372,6 +380,9 @@ export function remoteMcp(id: string, opts: RemoteMcpOptions): Connector {
372
380
  // transport, response bodies, AbortSignals, or connection promise reachable
373
381
  // from the isolate singleton. Those are request-bound in Cloudflare Workers.
374
382
  const states = new WeakMap<object, ConnectionState>();
383
+ // Closing is terminal even after `states.delete`: a late or future lookup
384
+ // must not recreate an ownerless connection under the ended scope.
385
+ const closedScopes = new WeakSet<object>();
375
386
  const isOauth = opts.auth?.type === "oauth";
376
387
  const logger = opts.logger ?? console;
377
388
 
@@ -407,22 +418,33 @@ export function remoteMcp(id: string, opts: RemoteMcpOptions): Connector {
407
418
  new Error(`Connector "${id}" scope ended during connection.`);
408
419
 
409
420
  /**
410
- * One `tools/list` request. The only thing wrapped here is the diagnosis of a
411
- * `nextCursor` the MCP result schema refuses — overwhelmingly `null`, which
412
- * plenty of servers use to mean "no more pages" but the spec spells as an
413
- * *absent* cursor. The SDK surfaces that as a raw validation dump about a
414
- * field the operator never sees; say which server broke which rule instead.
415
- * Accepting `null` as end-of-chain outright is issue #99.
421
+ * One `tools/list` request. The SDK schema is retained wholesale except for
422
+ * accepting `null` as the common, unambiguous end-of-chain spelling. Other
423
+ * cursor shapes still get a useful connector-level diagnosis.
416
424
  */
417
- const listPage = async (client: Client, cursor: string | undefined) => {
425
+ const listPage = async (
426
+ client: Client,
427
+ cursor: string | undefined,
428
+ ctx: ConnectorContext,
429
+ ) => {
418
430
  try {
419
- return await client.listTools(
420
- cursor === undefined ? undefined : { cursor },
431
+ return await client.request(
432
+ {
433
+ method: "tools/list",
434
+ ...(cursor === undefined ? {} : { params: { cursor } }),
435
+ },
436
+ CompatibleListToolsResultSchema,
437
+ ctx.timeoutMs || ctx.signal
438
+ ? {
439
+ ...(ctx.timeoutMs ? { timeout: ctx.timeoutMs } : {}),
440
+ ...(ctx.signal ? { signal: ctx.signal } : {}),
441
+ }
442
+ : undefined,
421
443
  );
422
444
  } catch (err) {
423
445
  if (!isCursorShapeError(err)) throw err;
424
446
  throw new Error(
425
- `Connector "${id}" returned a tools/list page whose nextCursor is neither a string nor absent (a null cursor is the usual culprit) MCP ends pagination on an absent nextCursor, so this catalog cannot be walked.`,
447
+ `Connector "${id}" returned a tools/list page whose nextCursor is neither a string, null, nor absent — this catalog cannot be walked.`,
426
448
  { cause: err },
427
449
  );
428
450
  }
@@ -430,6 +452,7 @@ export function remoteMcp(id: string, opts: RemoteMcpOptions): Connector {
430
452
 
431
453
  const stateFor = (ctx: ConnectorContext): ConnectionState => {
432
454
  const scope = ctx.requestScope ?? ctx;
455
+ if (closedScopes.has(scope)) throw scopeEndedError();
433
456
  let state = states.get(scope);
434
457
  if (!state) {
435
458
  state = {
@@ -495,6 +518,14 @@ export function remoteMcp(id: string, opts: RemoteMcpOptions): Connector {
495
518
  ctx: ConnectorContext,
496
519
  state: ConnectionState,
497
520
  ): Promise<void> => {
521
+ // A 401 after connect is a verdict for the whole request scope, not merely
522
+ // for the one call that observed it. Do not let the still-cached client make
523
+ // a later status or call in the same scope report healthy.
524
+ if (state.authRequired) {
525
+ throw authRequiredError(
526
+ new UnauthorizedError("Downstream authorization is no longer valid."),
527
+ );
528
+ }
498
529
  // Cross-isolate force re-auth: another isolate bumped the KV generation and
499
530
  // wiped credentials. This request's cached client still speaks the old
500
531
  // token — drop it so the next connect runs against current state.
@@ -626,64 +657,76 @@ export function remoteMcp(id: string, opts: RemoteMcpOptions): Connector {
626
657
  /** Consecutive pages that advertised a successor but added nothing. */
627
658
  let barren = 0;
628
659
  let complete = false;
629
- for (let page = 0; page < MAX_TOOL_PAGES; page++) {
630
- // The scope can end between pages (probe timeout, teardown). Stop
631
- // rather than keep paging into a transport that is being closed.
632
- if (state.closed) throw scopeEndedError();
633
- // Page one sends no params at all, so a non-paginated server sees
634
- // exactly the request it saw before pagination existed.
635
- const res = await listPage(client, cursor);
636
- let added = 0;
637
- for (const t of res.tools) {
638
- // First page wins. An unstable cursor can serve the same tool on two
639
- // pages — a failure mode that did not exist while only page one was
640
- // read and a duplicate would inflate `toolCount`, double the tool's
641
- // `search_tools` row, and churn the registry's catalog-changed
642
- // comparison into a persistence write on every refresh.
643
- if (names.has(t.name)) continue;
644
- names.add(t.name);
645
- listed.push(t);
646
- added++;
647
- }
648
- // Pagination ends when `nextCursor` is ABSENT not when it is falsy.
649
- // An empty string is a legal cursor and means "keep going"; `if
650
- // (!next)` here would silently truncate that server's catalog.
651
- const next = res.nextCursor;
652
- if (typeof next !== "string") {
653
- complete = true;
654
- break;
655
- }
656
- // A page that adds nothing and still claims a successor made no
657
- // progress. Allow exactly one: the widespread idiom is to advertise a
658
- // cursor whenever a page came back full and then serve one empty page
659
- // to terminate, and that server is conformant. Two in a row is a
660
- // downstream going nowhere, and this kills a "fresh cursor forever, no
661
- // tools" adversary in a couple of round trips instead of thousands.
662
- if (added === 0 && ++barren > 1) {
663
- throw new Error(
664
- `Connector "${id}" returned two consecutive tools/list pages that added no tools and still advertised another — the catalog is not advancing.`,
665
- );
666
- }
667
- if (added > 0) barren = 0;
668
- // A cursor handed back a second time is not a slow server, it is a
669
- // loop. Fail now rather than walking it until a ceiling notices.
670
- if (spent.has(next)) {
671
- throw new Error(
672
- `Connector "${id}" handed back a tools/list cursor it had already issued the pagination chain loops.`,
673
- );
660
+ try {
661
+ for (let page = 0; page < MAX_TOOL_PAGES; page++) {
662
+ // The scope can end between pages (probe timeout, teardown). Stop
663
+ // rather than keep paging into a transport that is being closed.
664
+ if (state.closed) throw scopeEndedError();
665
+ // A discovery deadline uses the same signal for the whole chain.
666
+ // Check it before issuing each page as well as passing it to the
667
+ // in-flight SDK request, so expiry never starts one more round trip.
668
+ if (ctx.signal?.aborted) {
669
+ throw ctx.signal.reason instanceof Error
670
+ ? ctx.signal.reason
671
+ : new Error(`Connector "${id}" catalog deadline expired.`);
672
+ }
673
+ // Page one sends no params at all, so a non-paginated server sees
674
+ // exactly the request it saw before pagination existed.
675
+ const res = await listPage(client, cursor, ctx);
676
+ let added = 0;
677
+ for (const t of res.tools) {
678
+ // First page wins. An unstable cursor can serve the same tool on
679
+ // two pages a duplicate would inflate `toolCount`, double the
680
+ // `search_tools` row, and churn catalog persistence.
681
+ if (names.has(t.name)) continue;
682
+ names.add(t.name);
683
+ listed.push(t);
684
+ added++;
685
+ }
686
+ // Pagination ends when `nextCursor` is absent or null — never merely
687
+ // falsy. Empty string is present and means "keep going".
688
+ const next = res.nextCursor;
689
+ if (next === undefined || next === null) {
690
+ complete = true;
691
+ break;
692
+ }
693
+ // A page that adds nothing and still claims a successor made no
694
+ // progress. Allow exactly one: the widespread idiom is to advertise
695
+ // a cursor whenever a page came back full and then serve one empty
696
+ // page to terminate. Two in a row is a downstream going nowhere.
697
+ if (added === 0 && ++barren > 1) {
698
+ throw new Error(
699
+ `Connector "${id}" returned two consecutive tools/list pages that added no tools and still advertised another — the catalog is not advancing.`,
700
+ );
701
+ }
702
+ if (added > 0) barren = 0;
703
+ // A cursor handed back a second time is a loop, not a slow server.
704
+ if (spent.has(next)) {
705
+ throw new Error(
706
+ `Connector "${id}" handed back a tools/list cursor it had already issued — the pagination chain loops.`,
707
+ );
708
+ }
709
+ // Checked here rather than on arrival: this bounds what a *walk* may
710
+ // accumulate; a one-page server was always free to send its page.
711
+ if (listed.length > MAX_TOOLS) {
712
+ throw new Error(
713
+ `Connector "${id}" advertised further tools/list pages past ${listed.length} tools, over the ${MAX_TOOLS}-tool ceiling one catalog refresh will collect.`,
714
+ );
715
+ }
716
+ // Opaque by contract: handed straight back, never parsed, rewritten,
717
+ // or persisted.
718
+ spent.add(next);
719
+ cursor = next;
674
720
  }
675
- // Checked here rather than on arrival: this bounds what a *walk* may
676
- // accumulate, and a server that answers in one page was always free to
677
- // send whatever it sends.
678
- if (listed.length > MAX_TOOLS) {
679
- throw new Error(
680
- `Connector "${id}" advertised further tools/list pages past ${listed.length} tools, over the ${MAX_TOOLS}-tool ceiling one catalog refresh will collect.`,
681
- );
721
+ } catch (err) {
722
+ // A grant can be revoked after connect and after any earlier page.
723
+ // Classify that exactly like connect-time and call-time authorization
724
+ // failures, and latch it for the rest of this request scope.
725
+ if (err instanceof UnauthorizedError) {
726
+ state.authRequired = true;
727
+ throw authRequiredError(err);
682
728
  }
683
- // Opaque by contract: handed straight back, never parsed, rewritten,
684
- // or persisted.
685
- spent.add(next);
686
- cursor = next;
729
+ throw err;
687
730
  }
688
731
  // Fail the refresh outright. Returning what we have would publish a
689
732
  // partial catalog that looks complete; throwing lets the registry keep
@@ -734,11 +777,13 @@ export function remoteMcp(id: string, opts: RemoteMcpOptions): Connector {
734
777
 
735
778
  async closeScope(ctx) {
736
779
  const scope = ctx.requestScope ?? ctx;
780
+ // Tombstone before any lookup or await. This also makes close-before-use
781
+ // terminal rather than allowing the scope to spring into existence later.
782
+ closedScopes.add(scope);
737
783
  const state = states.get(scope);
738
784
  if (!state) return;
739
785
 
740
- // Delete before awaiting: a duplicate teardown is a no-op, and no later
741
- // lookup can reuse the state while its client is closing.
786
+ // Delete before awaiting: a duplicate teardown is a no-op.
742
787
  states.delete(scope);
743
788
  state.closed = true;
744
789
  const client = state.client;
@@ -17,6 +17,7 @@
17
17
 
18
18
  import {
19
19
  credentialTestRule,
20
+ STORED_CREDENTIAL_SHAPE_MISMATCH_ERROR,
20
21
  storedCredentialShape,
21
22
  } from "./credentials.js";
22
23
  import type { CredentialVault } from "./credentials.js";
@@ -333,8 +334,8 @@ export interface CredentialCheckOptions {
333
334
  /** Restrict the sweep to these connector ids. Default: every connector. */
334
335
  ids?: string[];
335
336
  /**
336
- * Internal scope identity supplied by an existing owner. When omitted, the
337
- * check creates and ends its own probe scope.
337
+ * @deprecated Ignored. Credential checks always create and close their own
338
+ * probe scope; no core path supplies an existing request scope.
338
339
  */
339
340
  requestScope?: object;
340
341
  }
@@ -586,12 +587,7 @@ export class CredentialHealthChecker {
586
587
  ...(await this.recordOrNothing(connectorId)),
587
588
  };
588
589
  }
589
- const run = this.runCheck(
590
- connector,
591
- baseUrl,
592
- opts.force ?? false,
593
- opts.requestScope,
594
- );
590
+ const run = this.runCheck(connector, baseUrl, opts.force ?? false);
595
591
  this.inFlight.set(connectorId, run);
596
592
  try {
597
593
  return await run;
@@ -611,7 +607,6 @@ export class CredentialHealthChecker {
611
607
  connector: Connector,
612
608
  baseUrl: string,
613
609
  force: boolean,
614
- requestScope?: object,
615
610
  ): Promise<CredentialCheckResult> {
616
611
  const connectorId = connector.id;
617
612
  const started = Date.now();
@@ -633,7 +628,7 @@ export class CredentialHealthChecker {
633
628
  }
634
629
  const shape = storedCredentialShape(connector.credential, values);
635
630
  if (shape.state === "mismatch") {
636
- // Drift is a persistent operator-error state, not an event: left
631
+ // Drift is a persistent operator-reconfiguration state, not an event:
637
632
  // outside the freshness gate it would spend a write on every sweep in
638
633
  // every isolate, forever, against exactly the deployments this feature
639
634
  // is meant to help (and on Cloudflare KV those writes are metered).
@@ -646,7 +641,7 @@ export class CredentialHealthChecker {
646
641
  const current = await this.store.get(connectorId);
647
642
  if (
648
643
  current &&
649
- current.state === "error" &&
644
+ current.state === "auth_required" &&
650
645
  current.message === shape.message &&
651
646
  Date.now() - Date.parse(current.checkedAt) < this.intervalMs
652
647
  ) {
@@ -654,7 +649,9 @@ export class CredentialHealthChecker {
654
649
  }
655
650
  }
656
651
  return this.settle(connectorId, started, generation, {
657
- state: "error",
652
+ // Unlike a failed check, this is a completed static classification:
653
+ // the current declaration cannot consume what the vault holds.
654
+ state: "auth_required",
658
655
  checkedAt: new Date().toISOString(),
659
656
  message: shape.message,
660
657
  });
@@ -671,8 +668,9 @@ export class CredentialHealthChecker {
671
668
  return { connectorId, skipped: "fresh", record: current };
672
669
  }
673
670
  }
674
- const ownsScope = requestScope === undefined;
675
- const scope = requestScope ?? {};
671
+ // Credential checks are always probe owners. No caller may lend them an
672
+ // ordinary request scope and thereby suppress the teardown below.
673
+ const scope = {};
676
674
  const ctx = this.deps.contextFor(connectorId, baseUrl, scope);
677
675
  try {
678
676
  if (credentialReadError) {
@@ -713,7 +711,7 @@ export class CredentialHealthChecker {
713
711
  });
714
712
  }
715
713
  } finally {
716
- if (ownsScope) await closeConnectorScope(connector, ctx);
714
+ await closeConnectorScope(connector, ctx);
717
715
  }
718
716
  }
719
717
 
@@ -797,9 +795,12 @@ export class CredentialHealthChecker {
797
795
  * blip. Error verdicts stay visible in `credentialCheck` (an operator wants
798
796
  * to know checks are failing) but the status keeps coming from observed real
799
797
  * calls, which is evidence.
800
- * 2. **A successful real call retires the verdict.** Traffic beats a background
801
- * probe, so a `lastSuccessAt` at or after `checkedAt` means the credential
802
- * demonstrably works whatever the check concluded. The next check re-decides.
798
+ * 2. **A successful real call retires the verdict, except static shape drift.**
799
+ * Traffic beats a background probe, so a `lastSuccessAt` at or after
800
+ * `checkedAt` normally means the credential demonstrably works. Stored-shape
801
+ * drift is different: a credential-independent tool can succeed without
802
+ * making a missing declared field appear, so only replacement/removal clears
803
+ * that verdict.
803
804
  *
804
805
  * `auth_required` deliberately outranks an observed real-call *failure*: both
805
806
  * say something is wrong, and only one of them carries the URL that fixes it.
@@ -810,6 +811,7 @@ export function credentialVerdictApplies(
810
811
  lastSuccessAt: string | undefined,
811
812
  ): boolean {
812
813
  if (!record || record.state !== "auth_required") return false;
814
+ if (record.message === STORED_CREDENTIAL_SHAPE_MISMATCH_ERROR) return true;
813
815
  if (!lastSuccessAt) return true;
814
816
  const success = Date.parse(lastSuccessAt);
815
817
  return Number.isNaN(success) || success < Date.parse(record.checkedAt);
@@ -40,11 +40,39 @@ const MAX_LOG_ENTRIES = 200;
40
40
  // keeps the worst case — 200 maxed-out entries — bounded well under a MiB.
41
41
  const MAX_LOG_ENTRY_CHARS = 8_000;
42
42
  const MAX_LOG_TOTAL_CHARS = 256_000;
43
+ // Keep one host result below the range where quickjs-emscripten@0.32.0 can
44
+ // nondeterministically fail during runtime disposal under concurrent load.
45
+ // This still lets guest code reduce data more than ten times larger than
46
+ // connecta's final response budget.
47
+ const MAX_HOST_RESULT_BYTES = 256 * 1024;
43
48
 
44
49
  function msg(err: unknown): string {
45
50
  return err instanceof Error ? err.message : String(err);
46
51
  }
47
52
 
53
+ function exceedsUtf8ByteLimit(value: string, limit: number): boolean {
54
+ let bytes = 0;
55
+ for (let index = 0; index < value.length; index += 1) {
56
+ const code = value.charCodeAt(index);
57
+ if (code <= 0x7f) bytes += 1;
58
+ else if (code <= 0x7ff) bytes += 2;
59
+ else if (
60
+ code >= 0xd800 &&
61
+ code <= 0xdbff &&
62
+ index + 1 < value.length &&
63
+ value.charCodeAt(index + 1) >= 0xdc00 &&
64
+ value.charCodeAt(index + 1) <= 0xdfff
65
+ ) {
66
+ bytes += 4;
67
+ index += 1;
68
+ } else {
69
+ bytes += 3;
70
+ }
71
+ if (bytes > limit) return true;
72
+ }
73
+ return false;
74
+ }
75
+
48
76
  /** Normalize model output into an async-arrow expression: strip markdown fences, wrap bare bodies. */
49
77
  export function normalizeCode(code: string): string {
50
78
  let c = code.trim();
@@ -118,6 +146,25 @@ function armWake(bridge: HostBridge): void {
118
146
  });
119
147
  }
120
148
 
149
+ function waitForHostOrDeadline(
150
+ waitForSettle: Promise<void>,
151
+ remainingMs: number,
152
+ ): Promise<boolean> {
153
+ return new Promise((resolve) => {
154
+ let done = false;
155
+ const timer = setTimeout(() => {
156
+ done = true;
157
+ resolve(false);
158
+ }, remainingMs);
159
+ void waitForSettle.then(() => {
160
+ if (done) return;
161
+ done = true;
162
+ clearTimeout(timer);
163
+ resolve(true);
164
+ });
165
+ });
166
+ }
167
+
121
168
  function installBridge(
122
169
  ctx: QuickJSContext,
123
170
  providers: ExecutorProvider[],
@@ -176,7 +223,20 @@ function installBridge(
176
223
  if (!f) throw new Error(`Unknown function ${ns}.${fn}`);
177
224
  const args = JSON.parse(argsJson) as unknown[];
178
225
  const value = await f(...args);
179
- return JSON.stringify({ ok: true, value });
226
+ let json: string;
227
+ try {
228
+ json = JSON.stringify({ ok: true, value });
229
+ } catch (err) {
230
+ throw new Error(
231
+ `Host result from ${ns}.${fn} could not be serialized: ${msg(err)}`,
232
+ );
233
+ }
234
+ if (exceedsUtf8ByteLimit(json, MAX_HOST_RESULT_BYTES)) {
235
+ throw new Error(
236
+ `Host result from ${ns}.${fn} exceeds the ${MAX_HOST_RESULT_BYTES}-byte serialized bridge limit.`,
237
+ );
238
+ }
239
+ return json;
180
240
  } catch (err) {
181
241
  return JSON.stringify({ ok: false, error: msg(err) });
182
242
  }
@@ -326,10 +386,10 @@ export function quickJsExecutor(
326
386
  if (remaining <= 0) {
327
387
  return { result: undefined, error: timeoutError };
328
388
  }
329
- const settled = await Promise.race([
330
- bridge.waitForSettle.then(() => true),
331
- new Promise<boolean>((r) => setTimeout(() => r(false), remaining)),
332
- ]);
389
+ const settled = await waitForHostOrDeadline(
390
+ bridge.waitForSettle,
391
+ remaining,
392
+ );
333
393
  if (settled) armWake(bridge);
334
394
  else {
335
395
  return { result: undefined, error: timeoutError };
package/src/index.ts CHANGED
@@ -82,7 +82,8 @@ export interface ConnectaDiscoveryConfig {
82
82
  * Deadline (ms) for each downstream probe/catalog call fanned out by
83
83
  * `list_connectors`, `search_tools`, and `describe_tools`. Defaults to
84
84
  * 30_000. A timed-out connector degrades independently; this does not apply
85
- * to tool calls or currently abort the underlying fetch.
85
+ * to tool calls. Catalog walks receive the same cancellation signal, which
86
+ * aborts an in-flight page where supported and prevents another from starting.
86
87
  */
87
88
  probeTimeoutMs?: number;
88
89
  }