@zackbart/connecta 0.7.7 → 0.7.9

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 (59) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/dist/activity.d.ts +1 -1
  3. package/dist/activity.d.ts.map +1 -1
  4. package/dist/activity.js.map +1 -1
  5. package/dist/call-admission.d.ts +81 -0
  6. package/dist/call-admission.d.ts.map +1 -0
  7. package/dist/call-admission.js +339 -0
  8. package/dist/call-admission.js.map +1 -0
  9. package/dist/catalog.d.ts +5 -3
  10. package/dist/catalog.d.ts.map +1 -1
  11. package/dist/catalog.js +13 -4
  12. package/dist/catalog.js.map +1 -1
  13. package/dist/connectors/api.d.ts +3 -1
  14. package/dist/connectors/api.d.ts.map +1 -1
  15. package/dist/connectors/api.js +1 -0
  16. package/dist/connectors/api.js.map +1 -1
  17. package/dist/connectors/remote-mcp.d.ts +3 -1
  18. package/dist/connectors/remote-mcp.d.ts.map +1 -1
  19. package/dist/connectors/remote-mcp.js +1 -0
  20. package/dist/connectors/remote-mcp.js.map +1 -1
  21. package/dist/execute.d.ts.map +1 -1
  22. package/dist/execute.js +72 -30
  23. package/dist/execute.js.map +1 -1
  24. package/dist/index.d.ts +9 -2
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +2 -0
  27. package/dist/index.js.map +1 -1
  28. package/dist/meta-tools.d.ts +7 -4
  29. package/dist/meta-tools.d.ts.map +1 -1
  30. package/dist/meta-tools.js +212 -54
  31. package/dist/meta-tools.js.map +1 -1
  32. package/dist/registry.d.ts +37 -0
  33. package/dist/registry.d.ts.map +1 -1
  34. package/dist/registry.js +45 -2
  35. package/dist/registry.js.map +1 -1
  36. package/dist/server.d.ts.map +1 -1
  37. package/dist/server.js +5 -0
  38. package/dist/server.js.map +1 -1
  39. package/dist/types.d.ts +55 -0
  40. package/dist/types.d.ts.map +1 -1
  41. package/dist/ui.d.ts.map +1 -1
  42. package/dist/ui.js +8 -4
  43. package/dist/ui.js.map +1 -1
  44. package/dist/version.d.ts +1 -1
  45. package/dist/version.js +1 -1
  46. package/package.json +1 -1
  47. package/src/activity.ts +5 -1
  48. package/src/call-admission.ts +519 -0
  49. package/src/catalog.ts +24 -4
  50. package/src/connectors/api.ts +4 -0
  51. package/src/connectors/remote-mcp.ts +4 -0
  52. package/src/execute.ts +83 -35
  53. package/src/index.ts +14 -1
  54. package/src/meta-tools.ts +246 -70
  55. package/src/registry.ts +91 -1
  56. package/src/server.ts +5 -0
  57. package/src/types.ts +61 -0
  58. package/src/ui.ts +8 -4
  59. package/src/version.ts +1 -1
package/src/meta-tools.ts CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  messageLooksRetryable,
18
18
  type CallErrorDetails,
19
19
  } from "./errors.js";
20
+ import { isCallAdmissionError } from "./call-admission.js";
20
21
  import {
21
22
  isValidMaxResultBytes,
22
23
  MIN_MAX_RESULT_BYTES,
@@ -228,6 +229,24 @@ function errorDetails(code: string, message: string): ErrorDetails {
228
229
  return { code, message, retryable: messageLooksRetryable(message) };
229
230
  }
230
231
 
232
+ function callerCancelledDetails(): ErrorDetails {
233
+ return {
234
+ code: "cancelled",
235
+ message: "Tool call was cancelled by the caller.",
236
+ retryable: false,
237
+ };
238
+ }
239
+
240
+ function isCallerCancellation(
241
+ error: unknown,
242
+ signal: AbortSignal | undefined,
243
+ ): boolean {
244
+ return (
245
+ signal?.aborted === true ||
246
+ (isCallAdmissionError(error) && error.admissionKind === "cancelled")
247
+ );
248
+ }
249
+
231
250
  /** True if `b` is a UTF-8 continuation byte (0b10xxxxxx). */
232
251
  function isContinuationByte(b: number): boolean {
233
252
  return (b & 0xc0) === 0x80;
@@ -406,6 +425,15 @@ async function stashResult(
406
425
  };
407
426
  }
408
427
 
428
+ /** Keep an oversized batch's inline outcome summary at fixed string overhead. */
429
+ function batchSummaryString(value: string): string {
430
+ const bytes = enc.encode(value);
431
+ const maxBytes = 512;
432
+ if (bytes.length <= maxBytes) return value;
433
+ const end = alignEndToCharBoundary(bytes, 0, maxBytes, bytes.length);
434
+ return `${dec.decode(bytes.slice(0, end))}…`;
435
+ }
436
+
409
437
  /**
410
438
  * Return `text` as a single content block; if it exceeds `cap` bytes, stash the
411
439
  * full text and return the first `cap` bytes followed by a JSON truncation
@@ -575,10 +603,10 @@ export interface SkillArgs {
575
603
  * supplies a deadline for calls that don't carry one. (execute_code, the
576
604
  * optional tenth tool, is registered separately by registerExecuteTool.)
577
605
  *
578
- * The deployment-wide result-size cap is read off the registry view rather than
579
- * passed in: `ConnectaConfig.calls.maxResultBytes` and the per-connector
580
- * override are the only places a cap is set, so there is one answer to where a
581
- * deployment sets it (issue #44).
606
+ * Deployment-wide result-size caps are read off the registry view rather than
607
+ * passed in: `ConnectaConfig.calls.maxResultBytes`, its per-connector override,
608
+ * and the independent `calls.maxBatchResultBytes` final-envelope boundary each
609
+ * have one runtime source of truth.
582
610
  */
583
611
  export function createMetaTools(
584
612
  registry: RegistryView,
@@ -589,12 +617,15 @@ export function createMetaTools(
589
617
  /** Per-connector deadline for the list/search/describe probe fan-out. Default 30_000. */
590
618
  probeTimeoutMs?: number;
591
619
  activity?: ActivityRequestContext;
620
+ /** Inbound request cancellation shared by direct and batch child calls. */
621
+ requestSignal?: AbortSignal;
592
622
  /** Runtime continuation for the bounded tail of probe-owned teardown. */
593
623
  defer?: DeferredWork;
594
624
  } = {},
595
625
  ) {
596
626
  // Already normalized and warned about at registry construction.
597
627
  const globalCap = registry.maxResultBytes;
628
+ const batchCap = registry.maxBatchResultBytes;
598
629
  const defaultToolTimeoutMs = normalizeTimeoutMs(opts.defaultToolTimeoutMs);
599
630
  const probeTimeoutMs =
600
631
  normalizeTimeoutMs(opts.probeTimeoutMs) ?? DEFAULT_PROBE_TIMEOUT_MS;
@@ -621,6 +652,7 @@ export function createMetaTools(
621
652
  attempts: number;
622
653
  timing: {
623
654
  catalogMs: number;
655
+ admissionMs: number;
624
656
  connectorMs: number;
625
657
  backoffMs: number;
626
658
  resultProcessingMs: number;
@@ -638,12 +670,14 @@ export function createMetaTools(
638
670
  ): Promise<RunCallOutcome> {
639
671
  const started = Date.now();
640
672
  let catalogMs = 0;
673
+ let admissionMs = 0;
641
674
  let connectorMs = 0;
642
675
  let backoffMs = 0;
643
676
  let resultProcessingMs = 0;
644
677
  let attempts = 0;
645
678
  const timing = () => ({
646
679
  catalogMs,
680
+ admissionMs,
647
681
  connectorMs,
648
682
  backoffMs,
649
683
  resultProcessingMs,
@@ -651,7 +685,7 @@ export function createMetaTools(
651
685
  });
652
686
  const resolved = registry.resolveAddress(call.address);
653
687
  const record = (
654
- outcome: "success" | "error" | "timeout",
688
+ outcome: "success" | "error" | "timeout" | "cancelled",
655
689
  errorCode?: string,
656
690
  ) => {
657
691
  if (!resolved) return;
@@ -669,7 +703,14 @@ export function createMetaTools(
669
703
  const failed = (error: ErrorDetails): RunCallOutcome => {
670
704
  const durationMs = Date.now() - started;
671
705
  const diagnostics = timing();
672
- record(error.code === "timeout" ? "timeout" : "error", error.code);
706
+ record(
707
+ error.code === "timeout"
708
+ ? "timeout"
709
+ : error.code === "cancelled"
710
+ ? "cancelled"
711
+ : "error",
712
+ error.code,
713
+ );
673
714
  return {
674
715
  toolResult:
675
716
  call.resultMode === "value"
@@ -719,6 +760,9 @@ export function createMetaTools(
719
760
  ).find((tool) => tool.name === resolved.toolName);
720
761
  } catch (err) {
721
762
  catalogMs += Date.now() - catalogStarted;
763
+ if (opts.requestSignal?.aborted) {
764
+ return failed(callerCancelledDetails());
765
+ }
722
766
  // A connector whose catalog cannot be fetched is as unusable as one whose
723
767
  // execution fails, so it feeds health accounting the same way the
724
768
  // execution catch below does — otherwise a connector every call_tool
@@ -764,38 +808,76 @@ export function createMetaTools(
764
808
  let result: unknown;
765
809
  while (true) {
766
810
  attempts++;
767
- const controller = timeoutMs ? new AbortController() : undefined;
811
+ let permit: Awaited<ReturnType<RegistryView["admitCall"]>> | undefined;
812
+ const controller =
813
+ timeoutMs || opts.requestSignal ? new AbortController() : undefined;
814
+ const forwardAbort = () =>
815
+ controller?.abort(opts.requestSignal?.reason);
816
+ if (opts.requestSignal?.aborted) forwardAbort();
817
+ else {
818
+ opts.requestSignal?.addEventListener("abort", forwardAbort, {
819
+ once: true,
820
+ });
821
+ }
768
822
  let timer: ReturnType<typeof setTimeout> | undefined;
769
- const ctx = registry.contextFor(
770
- resolved.connector.id,
771
- baseUrl,
772
- requestScope,
773
- { signal: controller?.signal, timeoutMs },
774
- );
775
- const connectorStarted = Date.now();
823
+ let onAbort: (() => void) | undefined;
824
+ let attemptFailed = false;
825
+ let attemptError: unknown;
776
826
  try {
777
- const pending = resolved.connector.callTool(
778
- resolved.toolName,
779
- call.args ?? {},
780
- ctx,
827
+ const admissionStarted = Date.now();
828
+ try {
829
+ permit = await registry.admitCall(resolved.connector.id, {
830
+ toolName: resolved.toolName,
831
+ args: call.args ?? {},
832
+ signal: opts.requestSignal,
833
+ });
834
+ } finally {
835
+ admissionMs += Date.now() - admissionStarted;
836
+ }
837
+ const ctx = registry.contextFor(
838
+ resolved.connector.id,
839
+ baseUrl,
840
+ requestScope,
841
+ { signal: controller?.signal, timeoutMs },
781
842
  );
782
- result = timeoutMs
783
- ? await Promise.race([
784
- pending,
785
- new Promise<never>((_, reject) => {
786
- timer = setTimeout(() => {
787
- reject(
788
- new ConnectorCallError(
789
- "timeout",
790
- `Tool call timed out after ${timeoutMs}ms`,
791
- ),
792
- );
793
- controller?.abort();
794
- }, timeoutMs);
795
- }),
796
- ])
797
- : await pending;
798
- connectorMs += Date.now() - connectorStarted;
843
+ let rejectCancelled!: (reason: unknown) => void;
844
+ const cancelled = controller
845
+ ? new Promise<never>((_, reject) => {
846
+ rejectCancelled = reject;
847
+ })
848
+ : undefined;
849
+ onAbort = () => {
850
+ rejectCancelled(
851
+ controller?.signal.reason ??
852
+ new ConnectorCallError("timeout", "Tool call was cancelled"),
853
+ );
854
+ };
855
+ controller?.signal.addEventListener("abort", onAbort, { once: true });
856
+ if (controller?.signal.aborted) onAbort();
857
+ if (controller?.signal.aborted) await cancelled;
858
+ if (timeoutMs) {
859
+ timer = setTimeout(() => {
860
+ controller?.abort(
861
+ new ConnectorCallError(
862
+ "timeout",
863
+ `Tool call timed out after ${timeoutMs}ms`,
864
+ ),
865
+ );
866
+ }, timeoutMs);
867
+ }
868
+ const connectorStarted = Date.now();
869
+ try {
870
+ const pending = resolved.connector.callTool(
871
+ resolved.toolName,
872
+ call.args ?? {},
873
+ ctx,
874
+ );
875
+ result = cancelled
876
+ ? await Promise.race([pending, cancelled])
877
+ : await pending;
878
+ } finally {
879
+ connectorMs += Date.now() - connectorStarted;
880
+ }
799
881
  const mcpResult = result as {
800
882
  content?: TextContent[];
801
883
  isError?: boolean;
@@ -806,34 +888,72 @@ export function createMetaTools(
806
888
  "Downstream tool call failed",
807
889
  );
808
890
  }
809
- break;
810
891
  } catch (err) {
811
- // Includes connector setup, downstream execution, and timeout wait.
812
- connectorMs += Date.now() - connectorStarted;
813
- const details = classifyCallError(err);
814
- if (attempts <= maxRetries && retrySafe && details.retryable) {
892
+ attemptFailed = true;
893
+ attemptError = err;
894
+ } finally {
895
+ if (timer) clearTimeout(timer);
896
+ if (onAbort) {
897
+ controller?.signal.removeEventListener("abort", onAbort);
898
+ }
899
+ opts.requestSignal?.removeEventListener("abort", forwardAbort);
900
+ permit?.release();
901
+ }
902
+ if (attemptFailed) {
903
+ const callerCancelled = isCallerCancellation(
904
+ attemptError,
905
+ opts.requestSignal,
906
+ );
907
+ const details = callerCancelled
908
+ ? callerCancelledDetails()
909
+ : classifyCallError(attemptError);
910
+ if (
911
+ !callerCancelled &&
912
+ attempts <= maxRetries &&
913
+ retrySafe &&
914
+ details.retryable
915
+ ) {
815
916
  const wait = retryBackoffMs(attempts, details.retryAfterMs);
816
917
  if (wait !== undefined) {
817
918
  const backoffStarted = Date.now();
818
919
  if (wait > 0) {
819
- await new Promise((resolve) => setTimeout(resolve, wait));
920
+ const completed = await new Promise<boolean>((resolve) => {
921
+ let settled = false;
922
+ const finish = (value: boolean) => {
923
+ if (settled) return;
924
+ settled = true;
925
+ clearTimeout(timer);
926
+ opts.requestSignal?.removeEventListener("abort", cancel);
927
+ resolve(value);
928
+ };
929
+ const timer = setTimeout(() => finish(true), wait);
930
+ const cancel = () => finish(false);
931
+ opts.requestSignal?.addEventListener("abort", cancel, {
932
+ once: true,
933
+ });
934
+ if (opts.requestSignal?.aborted) cancel();
935
+ });
936
+ backoffMs += Date.now() - backoffStarted;
937
+ if (!completed) return failed(callerCancelledDetails());
938
+ } else {
939
+ backoffMs += Date.now() - backoffStarted;
820
940
  }
821
- backoffMs += Date.now() - backoffStarted;
822
941
  continue;
823
942
  }
824
943
  // The reported window is longer than the engine will park a
825
944
  // synchronous request for. Fall through to failure with
826
945
  // retryAfterMs reported verbatim so the agent can re-issue.
827
946
  }
828
- registry.recordFailure(
829
- resolved.connector.id,
830
- Date.now() - started,
831
- err,
832
- );
947
+ if (!callerCancelled && !isCallAdmissionError(attemptError)) {
948
+ registry.recordFailure(
949
+ resolved.connector.id,
950
+ Date.now() - started,
951
+ attemptError,
952
+ );
953
+ }
833
954
  return failed(details);
834
- } finally {
835
- if (timer) clearTimeout(timer);
836
955
  }
956
+ break;
837
957
  }
838
958
 
839
959
  registry.recordSuccess(resolved.connector.id, Date.now() - started);
@@ -1141,26 +1261,36 @@ export function createMetaTools(
1141
1261
  ),
1142
1262
  ),
1143
1263
  );
1144
- let orderBase = 0;
1145
- catalogs.forEach((catalog, connectorIndex) => {
1146
- const c = conns[connectorIndex];
1147
- if (catalog.status === "fulfilled") {
1148
- for (const ranked of rankTools(catalog.value, q)) {
1149
- matches.push({
1150
- connectorId: c.id,
1151
- connectorTitle: c.title,
1152
- connectorDescription: c.description,
1153
- ...(connectorGuide(c)
1154
- ? { connectorGuideSkill: connectorSkillName(c.id) }
1155
- : {}),
1156
- tool: ranked.tool,
1157
- score: ranked.score,
1158
- order: orderBase + ranked.order,
1159
- });
1264
+ let matchMode: "all" | "partial" = "all";
1265
+ const collectMatches = (mode: "all" | "partial") => {
1266
+ matches.length = 0;
1267
+ let orderBase = 0;
1268
+ catalogs.forEach((catalog, connectorIndex) => {
1269
+ const c = conns[connectorIndex];
1270
+ if (catalog.status === "fulfilled") {
1271
+ for (const ranked of rankTools(catalog.value, q, mode)) {
1272
+ matches.push({
1273
+ connectorId: c.id,
1274
+ connectorTitle: c.title,
1275
+ connectorDescription: c.description,
1276
+ ...(connectorGuide(c)
1277
+ ? { connectorGuideSkill: connectorSkillName(c.id) }
1278
+ : {}),
1279
+ tool: ranked.tool,
1280
+ score: ranked.score,
1281
+ order: orderBase + ranked.order,
1282
+ });
1283
+ }
1160
1284
  }
1161
- }
1162
- orderBase += catalog.status === "fulfilled" ? catalog.value.length : 1;
1163
- });
1285
+ orderBase +=
1286
+ catalog.status === "fulfilled" ? catalog.value.length : 1;
1287
+ });
1288
+ };
1289
+ collectMatches("all");
1290
+ if (q.trim() && matches.length === 0) {
1291
+ matchMode = "partial";
1292
+ collectMatches(matchMode);
1293
+ }
1164
1294
  matches.sort((a, b) => b.score - a.score || a.order - b.order);
1165
1295
  const page = matches.slice(offset, offset + limit);
1166
1296
  const groups: {
@@ -1235,6 +1365,9 @@ export function createMetaTools(
1235
1365
  limit,
1236
1366
  hasMore: nextOffset !== undefined,
1237
1367
  ...(nextOffset !== undefined ? { nextOffset } : {}),
1368
+ ...(matchMode === "partial" && matches.length > 0
1369
+ ? { matchMode }
1370
+ : {}),
1238
1371
  },
1239
1372
  "Request a smaller limit, omit fullDescriptions, or use compact schemas.",
1240
1373
  );
@@ -1457,9 +1590,50 @@ export function createMetaTools(
1457
1590
  : {}),
1458
1591
  };
1459
1592
  });
1460
- return jsonResult({
1593
+ const envelope = {
1461
1594
  results,
1462
1595
  durationMs: Date.now() - batchStarted,
1596
+ };
1597
+ const text = serializeResultText(envelope);
1598
+ const bytes = enc.encode(text);
1599
+ if (bytes.length <= batchCap) return jsonResult(envelope);
1600
+
1601
+ const notice = await stashResult(
1602
+ text,
1603
+ registry.resultsStorage(),
1604
+ bytes.length,
1605
+ );
1606
+ return jsonResult({
1607
+ results: results.map((result) => {
1608
+ const common = {
1609
+ address: batchSummaryString(result.address),
1610
+ ok: !("error" in result),
1611
+ ...("durationMs" in result
1612
+ ? { durationMs: result.durationMs }
1613
+ : {}),
1614
+ ...("attempts" in result ? { attempts: result.attempts } : {}),
1615
+ ...("timing" in result ? { timing: result.timing } : {}),
1616
+ };
1617
+ if (!("error" in result)) return common;
1618
+ const error = result.error ?? "Batch call failed";
1619
+ const details =
1620
+ result.errorDetails ??
1621
+ errorDetails("batch_call_failed", error);
1622
+ return {
1623
+ ...common,
1624
+ error: batchSummaryString(error),
1625
+ errorDetails: {
1626
+ code: batchSummaryString(details.code),
1627
+ message: batchSummaryString(details.message),
1628
+ retryable: details.retryable,
1629
+ ...(details.retryAfterMs !== undefined
1630
+ ? { retryAfterMs: details.retryAfterMs }
1631
+ : {}),
1632
+ },
1633
+ };
1634
+ }),
1635
+ durationMs: envelope.durationMs,
1636
+ ...notice,
1463
1637
  });
1464
1638
  },
1465
1639
 
@@ -1527,7 +1701,7 @@ const CALL_DESTRUCTIVE_DESC =
1527
1701
  const GET_RESULT_DESC =
1528
1702
  "Page a truncated result stashed by call_tool/batch_call. Input { id, offset?, maxBytes? } → { text, offset, nextOffset?, totalBytes } sliced by byte offset. maxBytes is a whole number of bytes >= 1 (omit for the deployment default) and offset a whole number of bytes >= 0; an offset inside a multi-byte character is moved back to that character's first byte and the offset served is returned. Unknown/expired id is an error.";
1529
1703
  const BATCH_DESC =
1530
- "Use for 2–10 independent tools explicitly annotated readOnlyHint: true. Calls run in parallel with shared request-scoped clients; use execute_code when available instead for dependencies or in-sandbox reduction. Unannotated, write-capable, and destructive tools are refused. Batch timeout, safe retry, result mode, and diagnostics defaults may be overridden per call.";
1704
+ "Use for 2–10 independent tools explicitly annotated readOnlyHint: true. Calls run in parallel with shared request-scoped clients; use execute_code when available instead for dependencies or in-sandbox reduction. Unannotated, write-capable, and destructive tools are refused. Batch timeout, safe retry, result mode, and diagnostics defaults may be overridden per call. An oversized final envelope returns ordered outcome summaries plus a get_result page handle.";
1531
1705
  const AUTHORIZE_DESC =
1532
1706
  "Use after a connector reports auth_required. Starts downstream OAuth and returns an authorizationUrl for the operator to open. force=true wipes stored credentials first and restarts consent.";
1533
1707
  const SKILLS_DESC =
@@ -1593,6 +1767,7 @@ export function registerMetaTools(
1593
1767
  defaultToolTimeoutMs?: number;
1594
1768
  probeTimeoutMs?: number;
1595
1769
  activity?: ActivityRequestContext;
1770
+ requestSignal?: AbortSignal;
1596
1771
  defer?: DeferredWork;
1597
1772
  },
1598
1773
  ): void {
@@ -1600,6 +1775,7 @@ export function registerMetaTools(
1600
1775
  defaultToolTimeoutMs: ctx.defaultToolTimeoutMs,
1601
1776
  probeTimeoutMs: ctx.probeTimeoutMs,
1602
1777
  activity: ctx.activity,
1778
+ requestSignal: ctx.requestSignal,
1603
1779
  defer: ctx.defer,
1604
1780
  });
1605
1781
 
package/src/registry.ts CHANGED
@@ -18,6 +18,11 @@ import {
18
18
  type CredentialHealthConfig,
19
19
  type CredentialHealthRecord,
20
20
  } from "./credential-health.js";
21
+ import {
22
+ ConnectorCallAdmissionController,
23
+ type CallAdmissionPermit,
24
+ type ConnectorCallAdmissionSnapshot,
25
+ } from "./call-admission.js";
21
26
  import type { DeferredWork } from "./connector-scope.js";
22
27
  import { splitAddress, type Toolkit } from "./toolkits.js";
23
28
 
@@ -25,6 +30,8 @@ const ID_RE = /^[a-z0-9_-]+$/;
25
30
  const DEFAULT_TTL_SECONDS = 300;
26
31
  const DEFAULT_STALE_SECONDS = 3600;
27
32
  export const DEFAULT_MAX_RESULT_BYTES = 50_000;
33
+ /** Independent final-envelope boundary for `batch_call`. */
34
+ export const DEFAULT_MAX_BATCH_RESULT_BYTES = 100_000;
28
35
 
29
36
  /**
30
37
  * Smallest accepted inline-result cap. One byte is pathological but harmless:
@@ -143,6 +150,11 @@ export interface RegistryOptions {
143
150
  * to the default 50_000.
144
151
  */
145
152
  maxResultBytes?: number;
153
+ /**
154
+ * Cap on the complete serialized batch_call envelope. Must be a whole number
155
+ * of bytes >= 1; anything else warns and falls back to 100_000.
156
+ */
157
+ maxBatchResultBytes?: number;
146
158
  /** Tuning for the credential liveness checks (issue #24). */
147
159
  credentialHealth?: CredentialHealthConfig;
148
160
  }
@@ -179,6 +191,8 @@ type ConnectorOperationOptions = Pick<
179
191
  export interface RegistryView {
180
192
  /** Deployment-wide result-size cap threaded to the meta-tools. */
181
193
  readonly maxResultBytes: number;
194
+ /** Independent cap for the complete serialized batch_call envelope. */
195
+ readonly maxBatchResultBytes: number;
182
196
  listConnectors(): Connector[];
183
197
  getConnector(id: string): Connector | undefined;
184
198
  resolveAddress(
@@ -203,6 +217,14 @@ export interface RegistryView {
203
217
  requestScope?: object,
204
218
  callOptions?: ConnectorOperationOptions,
205
219
  ): ConnectorContext;
220
+ /**
221
+ * Acquire the connector's shared downstream-call permit. Scoped views
222
+ * delegate to the base registry so every toolkit contends on the same pool.
223
+ */
224
+ admitCall(
225
+ id: string,
226
+ input: { toolName: string; args: unknown; signal?: AbortSignal },
227
+ ): Promise<CallAdmissionPermit>;
206
228
  resultsStorage(): KVStorage;
207
229
  recordSuccess(id: string, latencyMs: number): void;
208
230
  recordFailure(id: string, latencyMs: number, error: unknown): void;
@@ -233,6 +255,10 @@ export interface RegistryView {
233
255
  */
234
256
  export class Registry implements RegistryView {
235
257
  private readonly connectors = new Map<string, Connector>();
258
+ private readonly callAdmission = new Map<
259
+ string,
260
+ ConnectorCallAdmissionController
261
+ >();
236
262
  private readonly cache = new Map<string, CacheEntry>();
237
263
  private readonly invalidated = new Set<string>();
238
264
  /** Per-connector epoch preventing a pre-invalidation refresh from publishing. */
@@ -246,6 +272,8 @@ export class Registry implements RegistryView {
246
272
  private readonly persistToolCatalog: boolean;
247
273
  /** Result-size guard cap threaded to the meta-tools. */
248
274
  readonly maxResultBytes: number;
275
+ /** Final batch envelope cap threaded to the meta-tools. */
276
+ readonly maxBatchResultBytes: number;
249
277
  /** Proactive liveness checks over stored downstream credentials (issue #24). */
250
278
  private readonly credentialHealth: CredentialHealthChecker;
251
279
 
@@ -262,6 +290,10 @@ export class Registry implements RegistryView {
262
290
  opts.maxResultBytes,
263
291
  DEFAULT_MAX_RESULT_BYTES,
264
292
  );
293
+ this.maxBatchResultBytes = resolveMaxResultBytes(
294
+ opts.maxBatchResultBytes,
295
+ DEFAULT_MAX_BATCH_RESULT_BYTES,
296
+ );
265
297
  for (const c of connectors) {
266
298
  if (!ID_RE.test(c.id)) {
267
299
  throw new Error(
@@ -272,9 +304,19 @@ export class Registry implements RegistryView {
272
304
  throw new Error(`Duplicate connector id "${c.id}"`);
273
305
  }
274
306
  this.connectors.set(c.id, c);
307
+ if (c.callAdmission) {
308
+ this.callAdmission.set(
309
+ c.id,
310
+ new ConnectorCallAdmissionController(c.id, c.callAdmission),
311
+ );
312
+ }
275
313
  }
276
314
  this.checkConventions(opts.logger);
277
- this.checkResultCaps(opts.logger, opts.maxResultBytes);
315
+ this.checkResultCaps(
316
+ opts.logger,
317
+ opts.maxResultBytes,
318
+ opts.maxBatchResultBytes,
319
+ );
278
320
  this.credentialHealth = new CredentialHealthChecker(
279
321
  {
280
322
  listConnectors: () => this.listConnectors(),
@@ -300,6 +342,7 @@ export class Registry implements RegistryView {
300
342
  private checkResultCaps(
301
343
  logger: Logger,
302
344
  configured: number | undefined,
345
+ configuredBatch: number | undefined,
303
346
  ): void {
304
347
  if (configured !== undefined && !isValidMaxResultBytes(configured)) {
305
348
  logger.warn(
@@ -309,6 +352,17 @@ export class Registry implements RegistryView {
309
352
  `default ${DEFAULT_MAX_RESULT_BYTES} instead.`,
310
353
  );
311
354
  }
355
+ if (
356
+ configuredBatch !== undefined &&
357
+ !isValidMaxResultBytes(configuredBatch)
358
+ ) {
359
+ logger.warn(
360
+ `[connecta] calls.maxBatchResultBytes ${configuredBatch} is not a whole ` +
361
+ `number of bytes >= ${MIN_MAX_RESULT_BYTES}: it would leave the final ` +
362
+ "batch envelope unbounded or serve an unusable page. Using the " +
363
+ `default ${DEFAULT_MAX_BATCH_RESULT_BYTES} instead.`,
364
+ );
365
+ }
312
366
  for (const c of this.connectors.values()) {
313
367
  if (
314
368
  c.maxResultBytes !== undefined &&
@@ -399,6 +453,30 @@ export class Registry implements RegistryView {
399
453
  };
400
454
  }
401
455
 
456
+ admitCall(
457
+ id: string,
458
+ input: { toolName: string; args: unknown; signal?: AbortSignal },
459
+ ): Promise<CallAdmissionPermit> {
460
+ const admission = this.callAdmission.get(id);
461
+ if (admission) return admission.acquire(input);
462
+ return Promise.resolve({ waitMs: 0, release() {} });
463
+ }
464
+
465
+ /** Payload-free aggregate state for the open health endpoint. */
466
+ callAdmissionSnapshot(): Record<string, ConnectorCallAdmissionSnapshot> {
467
+ return Object.fromEntries(
468
+ [...this.callAdmission].map(([id, admission]) => [
469
+ id,
470
+ admission.snapshot(),
471
+ ]),
472
+ );
473
+ }
474
+
475
+ /** Reject queued/future downstream admission; active permits release safely. */
476
+ closeCallAdmission(): void {
477
+ for (const admission of this.callAdmission.values()) admission.close();
478
+ }
479
+
402
480
  /**
403
481
  * Storage namespaced to the meta-tool result store (`results:` prefix), kept
404
482
  * separate from any connector's `conn:<id>:` namespace. Backs get_result.
@@ -831,6 +909,10 @@ export class ScopedRegistry implements RegistryView {
831
909
  return this.base.maxResultBytes;
832
910
  }
833
911
 
912
+ get maxBatchResultBytes(): number {
913
+ return this.base.maxBatchResultBytes;
914
+ }
915
+
834
916
  /** In scope AND actually registered. */
835
917
  private visible(id: string): boolean {
836
918
  return (
@@ -911,6 +993,14 @@ export class ScopedRegistry implements RegistryView {
911
993
  return this.base.contextFor(id, baseUrl, requestScope, callOptions);
912
994
  }
913
995
 
996
+ admitCall(
997
+ id: string,
998
+ input: { toolName: string; args: unknown; signal?: AbortSignal },
999
+ ): Promise<CallAdmissionPermit> {
1000
+ if (!this.visible(id)) return Promise.reject(this.unknownConnector(id));
1001
+ return this.base.admitCall(id, input);
1002
+ }
1003
+
914
1004
  /**
915
1005
  * Stashed oversized results are bound to the scope that produced them: a
916
1006
  * scoped session cannot page a result it could not have produced, and an id
package/src/server.ts CHANGED
@@ -1273,6 +1273,7 @@ async function serveMcp(
1273
1273
  activity,
1274
1274
  defaultToolTimeoutMs: opts.defaultToolTimeoutMs,
1275
1275
  probeTimeoutMs: opts.probeTimeoutMs,
1276
+ requestSignal: request.signal,
1276
1277
  ...(runtimeContext
1277
1278
  ? { defer: runtimeContext.waitUntil.bind(runtimeContext) }
1278
1279
  : {}),
@@ -1577,6 +1578,10 @@ export function createFetchHandler(
1577
1578
  code: opts.executor
1578
1579
  ? (codeAdmission ?? { managedByExecutor: true })
1579
1580
  : null,
1581
+ downstreamCalls: {
1582
+ policy: "connector-partitioned-per-runtime",
1583
+ connectors: registry.callAdmissionSnapshot(),
1584
+ },
1580
1585
  reservedRoutes: [
1581
1586
  "/health",
1582
1587
  "/",