@zackbart/connecta 0.7.8 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/README.md +67 -144
  3. package/dist/activity.d.ts +1 -1
  4. package/dist/activity.d.ts.map +1 -1
  5. package/dist/activity.js.map +1 -1
  6. package/dist/auth/bearer.d.ts +1 -1
  7. package/dist/auth/bearer.js +4 -4
  8. package/dist/auth/bearer.js.map +1 -1
  9. package/dist/auth/clerk.d.ts +1 -1
  10. package/dist/auth/clerk.js +1 -1
  11. package/dist/call-admission.d.ts +81 -0
  12. package/dist/call-admission.d.ts.map +1 -0
  13. package/dist/call-admission.js +345 -0
  14. package/dist/call-admission.js.map +1 -0
  15. package/dist/catalog-fingerprint.d.ts +20 -0
  16. package/dist/catalog-fingerprint.d.ts.map +1 -0
  17. package/dist/catalog-fingerprint.js +34 -0
  18. package/dist/catalog-fingerprint.js.map +1 -0
  19. package/dist/catalog-limits.d.ts +10 -0
  20. package/dist/catalog-limits.d.ts.map +1 -0
  21. package/dist/catalog-limits.js +10 -0
  22. package/dist/catalog-limits.js.map +1 -0
  23. package/dist/catalog-service.d.ts +138 -0
  24. package/dist/catalog-service.d.ts.map +1 -0
  25. package/dist/catalog-service.js +407 -0
  26. package/dist/catalog-service.js.map +1 -0
  27. package/dist/catalog.js +2 -2
  28. package/dist/catalog.js.map +1 -1
  29. package/dist/concurrency.d.ts +4 -0
  30. package/dist/concurrency.d.ts.map +1 -0
  31. package/dist/concurrency.js +31 -0
  32. package/dist/concurrency.js.map +1 -0
  33. package/dist/connectors/api.d.ts +3 -1
  34. package/dist/connectors/api.d.ts.map +1 -1
  35. package/dist/connectors/api.js +22 -11
  36. package/dist/connectors/api.js.map +1 -1
  37. package/dist/connectors/remote-mcp.d.ts +3 -1
  38. package/dist/connectors/remote-mcp.d.ts.map +1 -1
  39. package/dist/connectors/remote-mcp.js +44 -18
  40. package/dist/connectors/remote-mcp.js.map +1 -1
  41. package/dist/credential-health.d.ts +0 -22
  42. package/dist/credential-health.d.ts.map +1 -1
  43. package/dist/credential-health.js +14 -10
  44. package/dist/credential-health.js.map +1 -1
  45. package/dist/credentials.d.ts +3 -2
  46. package/dist/credentials.d.ts.map +1 -1
  47. package/dist/errors.d.ts +1 -1
  48. package/dist/errors.d.ts.map +1 -1
  49. package/dist/errors.js.map +1 -1
  50. package/dist/execute.d.ts +9 -8
  51. package/dist/execute.d.ts.map +1 -1
  52. package/dist/execute.js +144 -291
  53. package/dist/execute.js.map +1 -1
  54. package/dist/executor-result.d.ts +0 -3
  55. package/dist/executor-result.d.ts.map +1 -1
  56. package/dist/executor-result.js +2 -2
  57. package/dist/executor-result.js.map +1 -1
  58. package/dist/executors/quickjs-child.js +3 -3
  59. package/dist/executors/quickjs-child.js.map +1 -1
  60. package/dist/executors/quickjs-protocol.d.ts +4 -1
  61. package/dist/executors/quickjs-protocol.d.ts.map +1 -1
  62. package/dist/executors/quickjs-runtime.d.ts.map +1 -1
  63. package/dist/executors/quickjs-runtime.js +7 -2
  64. package/dist/executors/quickjs-runtime.js.map +1 -1
  65. package/dist/executors/quickjs.d.ts.map +1 -1
  66. package/dist/executors/quickjs.js +4 -1
  67. package/dist/executors/quickjs.js.map +1 -1
  68. package/dist/favicon.js +2 -2
  69. package/dist/favicon.js.map +1 -1
  70. package/dist/index.d.ts +6 -1
  71. package/dist/index.d.ts.map +1 -1
  72. package/dist/index.js +45 -17
  73. package/dist/index.js.map +1 -1
  74. package/dist/invocation.d.ts +96 -0
  75. package/dist/invocation.d.ts.map +1 -0
  76. package/dist/invocation.js +369 -0
  77. package/dist/invocation.js.map +1 -0
  78. package/dist/meta-tools.d.ts +11 -86
  79. package/dist/meta-tools.d.ts.map +1 -1
  80. package/dist/meta-tools.js +145 -485
  81. package/dist/meta-tools.js.map +1 -1
  82. package/dist/operator-ui/generated.d.ts +3 -0
  83. package/dist/operator-ui/generated.d.ts.map +1 -0
  84. package/dist/operator-ui/generated.js +5 -0
  85. package/dist/operator-ui/generated.js.map +1 -0
  86. package/dist/operator-ui/model.d.ts +94 -0
  87. package/dist/operator-ui/model.d.ts.map +1 -0
  88. package/dist/operator-ui/model.js +28 -0
  89. package/dist/operator-ui/model.js.map +1 -0
  90. package/dist/registry.d.ts +47 -6
  91. package/dist/registry.d.ts.map +1 -1
  92. package/dist/registry.js +356 -35
  93. package/dist/registry.js.map +1 -1
  94. package/dist/routes/activity.d.ts +3 -0
  95. package/dist/routes/activity.d.ts.map +1 -0
  96. package/dist/routes/activity.js +173 -0
  97. package/dist/routes/activity.js.map +1 -0
  98. package/dist/routes/credentials.d.ts +3 -0
  99. package/dist/routes/credentials.d.ts.map +1 -0
  100. package/dist/routes/credentials.js +181 -0
  101. package/dist/routes/credentials.js.map +1 -0
  102. package/dist/routes/mcp.d.ts +8 -0
  103. package/dist/routes/mcp.d.ts.map +1 -0
  104. package/dist/routes/mcp.js +383 -0
  105. package/dist/routes/mcp.js.map +1 -0
  106. package/dist/routes/oauth.d.ts +4 -0
  107. package/dist/routes/oauth.d.ts.map +1 -0
  108. package/dist/routes/oauth.js +268 -0
  109. package/dist/routes/oauth.js.map +1 -0
  110. package/dist/routes/shared.d.ts +97 -0
  111. package/dist/routes/shared.d.ts.map +1 -0
  112. package/dist/routes/shared.js +199 -0
  113. package/dist/routes/shared.js.map +1 -0
  114. package/dist/routes/ui.d.ts +3 -0
  115. package/dist/routes/ui.d.ts.map +1 -0
  116. package/dist/routes/ui.js +122 -0
  117. package/dist/routes/ui.js.map +1 -0
  118. package/dist/server.d.ts +9 -40
  119. package/dist/server.d.ts.map +1 -1
  120. package/dist/server.js +67 -1292
  121. package/dist/server.js.map +1 -1
  122. package/dist/skills.d.ts +0 -15
  123. package/dist/skills.d.ts.map +1 -1
  124. package/dist/skills.js +6 -5
  125. package/dist/skills.js.map +1 -1
  126. package/dist/storage/file.d.ts.map +1 -1
  127. package/dist/storage/file.js +3 -1
  128. package/dist/storage/file.js.map +1 -1
  129. package/dist/storage/memory.d.ts.map +1 -1
  130. package/dist/storage/memory.js +3 -1
  131. package/dist/storage/memory.js.map +1 -1
  132. package/dist/toolkits.d.ts +0 -22
  133. package/dist/toolkits.d.ts.map +1 -1
  134. package/dist/toolkits.js +2 -2
  135. package/dist/toolkits.js.map +1 -1
  136. package/dist/types.d.ts +64 -2
  137. package/dist/types.d.ts.map +1 -1
  138. package/dist/ui.d.ts +3 -93
  139. package/dist/ui.d.ts.map +1 -1
  140. package/dist/ui.js +26 -1389
  141. package/dist/ui.js.map +1 -1
  142. package/dist/version.d.ts +1 -1
  143. package/dist/version.js +1 -1
  144. package/package.json +15 -8
  145. package/src/activity.ts +5 -1
  146. package/src/auth/bearer.ts +4 -4
  147. package/src/auth/clerk.ts +1 -1
  148. package/src/call-admission.ts +522 -0
  149. package/src/catalog-fingerprint.ts +52 -0
  150. package/src/catalog-limits.ts +11 -0
  151. package/src/catalog-service.ts +601 -0
  152. package/src/catalog.ts +2 -2
  153. package/src/concurrency.ts +39 -0
  154. package/src/connectors/api.ts +25 -11
  155. package/src/connectors/remote-mcp.ts +55 -21
  156. package/src/credential-health.ts +13 -10
  157. package/src/credentials.ts +2 -2
  158. package/src/errors.ts +1 -1
  159. package/src/execute.ts +194 -377
  160. package/src/executor-result.ts +2 -2
  161. package/src/executors/quickjs-child.ts +8 -4
  162. package/src/executors/quickjs-protocol.ts +1 -1
  163. package/src/executors/quickjs-runtime.ts +5 -1
  164. package/src/executors/quickjs.ts +11 -8
  165. package/src/favicon.ts +2 -2
  166. package/src/index.ts +54 -17
  167. package/src/invocation.ts +527 -0
  168. package/src/meta-tools.ts +369 -797
  169. package/src/operator-ui/browser.css +413 -0
  170. package/src/operator-ui/browser.ts +1121 -0
  171. package/src/operator-ui/generated.ts +4 -0
  172. package/src/operator-ui/model.ts +127 -0
  173. package/src/registry.ts +489 -44
  174. package/src/routes/activity.ts +220 -0
  175. package/src/routes/credentials.ts +266 -0
  176. package/src/routes/mcp.ts +485 -0
  177. package/src/routes/oauth.ts +328 -0
  178. package/src/routes/shared.ts +330 -0
  179. package/src/routes/ui.ts +153 -0
  180. package/src/server.ts +70 -1702
  181. package/src/skills.ts +6 -5
  182. package/src/storage/file.ts +3 -1
  183. package/src/storage/memory.ts +3 -1
  184. package/src/toolkits.ts +2 -2
  185. package/src/types.ts +70 -2
  186. package/src/ui.ts +52 -1489
  187. package/src/version.ts +1 -1
package/src/registry.ts CHANGED
@@ -18,15 +18,32 @@ 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";
26
+ import {
27
+ fingerprintSerializedCatalog,
28
+ snapshotCatalog,
29
+ type CatalogSnapshot,
30
+ } from "./catalog-fingerprint.js";
31
+ import {
32
+ MAX_CATALOG_CHUNK_BYTES,
33
+ MAX_CATALOG_TOOLS,
34
+ MAX_SERIALIZED_CATALOG_BYTES,
35
+ } from "./catalog-limits.js";
36
+ import { mapSettledWithConcurrency } from "./concurrency.js";
21
37
  import type { DeferredWork } from "./connector-scope.js";
22
38
  import { splitAddress, type Toolkit } from "./toolkits.js";
23
39
 
24
40
  const ID_RE = /^[a-z0-9_-]+$/;
25
41
  const DEFAULT_TTL_SECONDS = 300;
26
42
  const DEFAULT_STALE_SECONDS = 3600;
27
- export const DEFAULT_MAX_RESULT_BYTES = 50_000;
43
+ const CATALOG_CHUNK_TTL_GRACE_SECONDS = 300;
44
+ const DEFAULT_MAX_RESULT_BYTES = 50_000;
28
45
  /** Independent final-envelope boundary for `batch_call`. */
29
- export const DEFAULT_MAX_BATCH_RESULT_BYTES = 100_000;
46
+ const DEFAULT_MAX_BATCH_RESULT_BYTES = 100_000;
30
47
 
31
48
  /**
32
49
  * Smallest accepted inline-result cap. One byte is pathological but harmless:
@@ -71,17 +88,41 @@ export function resolveMaxResultBytes(
71
88
 
72
89
  interface CacheEntry {
73
90
  tools: ToolDef[];
91
+ fingerprint: string;
74
92
  exp: number; // epoch ms
75
93
  staleUntil: number;
76
94
  }
77
95
 
96
+ interface LegacyPersistedCatalog {
97
+ tools: ToolDef[];
98
+ /** Optional only for catalogs written before fingerprints were introduced. */
99
+ fingerprint?: string;
100
+ fetchedAt: number;
101
+ expiresAt: number;
102
+ staleUntil: number;
103
+ }
104
+
105
+ interface PersistedCatalogManifest {
106
+ version: 2;
107
+ revision: string;
108
+ toolCount: number;
109
+ byteCount: number;
110
+ chunkCount: number;
111
+ fetchedAt: number;
112
+ expiresAt: number;
113
+ staleUntil: number;
114
+ }
115
+
78
116
  interface PersistedCatalog {
79
117
  tools: ToolDef[];
118
+ fingerprint: string;
80
119
  fetchedAt: number;
81
120
  expiresAt: number;
82
121
  staleUntil: number;
83
122
  }
84
123
 
124
+ const CATALOG_CHUNK_IO_CONCURRENCY = 4;
125
+
85
126
  export interface HealthObservation {
86
127
  lastSuccessAt?: string;
87
128
  lastFailureAt?: string;
@@ -106,13 +147,14 @@ export class HealthLog {
106
147
 
107
148
  recordSuccess(id: string, latencyMs: number): void {
108
149
  const previous = this.observations.get(id);
109
- this.observations.set(id, {
150
+ const observation: HealthObservation = {
110
151
  ...previous,
111
152
  lastSuccessAt: new Date().toISOString(),
112
153
  lastLatencyMs: latencyMs,
113
154
  consecutiveFailures: 0,
114
- lastError: undefined,
115
- });
155
+ };
156
+ delete observation.lastError;
157
+ this.observations.set(id, observation);
116
158
  }
117
159
 
118
160
  recordFailure(id: string, latencyMs: number, error: unknown): void {
@@ -166,7 +208,35 @@ function msg(err: unknown): string {
166
208
  return err instanceof Error ? err.message : String(err);
167
209
  }
168
210
 
169
- type ConnectorOperationOptions = Pick<
211
+ /** WHATWG TextEncoder byte length without allocating another full buffer. */
212
+ function utf8ByteLength(value: string): number {
213
+ let bytes = 0;
214
+ for (let index = 0; index < value.length; index++) {
215
+ const code = value.charCodeAt(index);
216
+ if (code <= 0x7f) {
217
+ bytes++;
218
+ } else if (code <= 0x7ff) {
219
+ bytes += 2;
220
+ } else if (
221
+ code >= 0xd800 &&
222
+ code <= 0xdbff &&
223
+ index + 1 < value.length
224
+ ) {
225
+ const next = value.charCodeAt(index + 1);
226
+ if (next >= 0xdc00 && next <= 0xdfff) {
227
+ bytes += 4;
228
+ index++;
229
+ } else {
230
+ bytes += 3;
231
+ }
232
+ } else {
233
+ bytes += 3;
234
+ }
235
+ }
236
+ return bytes;
237
+ }
238
+
239
+ export type ConnectorOperationOptions = Pick<
170
240
  ConnectorContext,
171
241
  "signal" | "timeoutMs"
172
242
  >;
@@ -212,6 +282,14 @@ export interface RegistryView {
212
282
  requestScope?: object,
213
283
  callOptions?: ConnectorOperationOptions,
214
284
  ): ConnectorContext;
285
+ /**
286
+ * Acquire the connector's shared downstream-call permit. Scoped views
287
+ * delegate to the base registry so every toolkit contends on the same pool.
288
+ */
289
+ admitCall(
290
+ id: string,
291
+ input: { toolName: string; args: unknown; signal?: AbortSignal },
292
+ ): Promise<CallAdmissionPermit>;
215
293
  resultsStorage(): KVStorage;
216
294
  recordSuccess(id: string, latencyMs: number): void;
217
295
  recordFailure(id: string, latencyMs: number, error: unknown): void;
@@ -242,12 +320,21 @@ export interface RegistryView {
242
320
  */
243
321
  export class Registry implements RegistryView {
244
322
  private readonly connectors = new Map<string, Connector>();
323
+ private readonly callAdmission = new Map<
324
+ string,
325
+ ConnectorCallAdmissionController
326
+ >();
245
327
  private readonly cache = new Map<string, CacheEntry>();
246
328
  private readonly invalidated = new Set<string>();
247
329
  /** Per-connector epoch preventing a pre-invalidation refresh from publishing. */
248
330
  private readonly catalogGenerations = new Map<string, number>();
249
331
  /** Serialize persisted catalog set/delete operations within this isolate. */
250
332
  private readonly catalogMutations = new Map<string, Promise<void>>();
333
+ /** Same-request cold loads share one promise without retaining the request. */
334
+ private readonly requestCatalogLoads = new WeakMap<
335
+ object,
336
+ Map<string, Promise<ToolDef[]>>
337
+ >();
251
338
  /** Deployment-wide observations — every call, whatever view made it. */
252
339
  private readonly health = new HealthLog();
253
340
  private readonly ttlMs: number;
@@ -287,6 +374,12 @@ export class Registry implements RegistryView {
287
374
  throw new Error(`Duplicate connector id "${c.id}"`);
288
375
  }
289
376
  this.connectors.set(c.id, c);
377
+ if (c.callAdmission) {
378
+ this.callAdmission.set(
379
+ c.id,
380
+ new ConnectorCallAdmissionController(c.id, c.callAdmission),
381
+ );
382
+ }
290
383
  }
291
384
  this.checkConventions(opts.logger);
292
385
  this.checkResultCaps(
@@ -302,7 +395,9 @@ export class Registry implements RegistryView {
302
395
  this.contextFor(id, baseUrl, requestScope),
303
396
  storage: opts.storage,
304
397
  logger: opts.logger,
305
- credentialVault: opts.credentialVault,
398
+ ...(opts.credentialVault !== undefined
399
+ ? { credentialVault: opts.credentialVault }
400
+ : {}),
306
401
  },
307
402
  opts.credentialHealth,
308
403
  );
@@ -430,6 +525,30 @@ export class Registry implements RegistryView {
430
525
  };
431
526
  }
432
527
 
528
+ admitCall(
529
+ id: string,
530
+ input: { toolName: string; args: unknown; signal?: AbortSignal },
531
+ ): Promise<CallAdmissionPermit> {
532
+ const admission = this.callAdmission.get(id);
533
+ if (admission) return admission.acquire(input);
534
+ return Promise.resolve({ waitMs: 0, release() {} });
535
+ }
536
+
537
+ /** Payload-free aggregate state for the open health endpoint. */
538
+ callAdmissionSnapshot(): Record<string, ConnectorCallAdmissionSnapshot> {
539
+ return Object.fromEntries(
540
+ [...this.callAdmission].map(([id, admission]) => [
541
+ id,
542
+ admission.snapshot(),
543
+ ]),
544
+ );
545
+ }
546
+
547
+ /** Reject queued/future downstream admission; active permits release safely. */
548
+ closeCallAdmission(): void {
549
+ for (const admission of this.callAdmission.values()) admission.close();
550
+ }
551
+
433
552
  /**
434
553
  * Storage namespaced to the meta-tool result store (`results:` prefix), kept
435
554
  * separate from any connector's `conn:<id>:` namespace. Backs get_result.
@@ -453,47 +572,304 @@ export class Registry implements RegistryView {
453
572
  return `catalog:${id}`;
454
573
  }
455
574
 
456
- private validCatalog(raw: string | null): PersistedCatalog | null {
575
+ private catalogChunkKey(id: string, revision: string, index: number): string {
576
+ return `${this.catalogKey(id)}:chunk:${revision}:${index}`;
577
+ }
578
+
579
+ private validLegacyCatalog(value: unknown): LegacyPersistedCatalog | null {
580
+ if (!value || typeof value !== "object") return null;
581
+ const catalog = value as Partial<LegacyPersistedCatalog>;
582
+ if (
583
+ !Array.isArray(catalog.tools) ||
584
+ typeof catalog.fetchedAt !== "number" ||
585
+ typeof catalog.expiresAt !== "number" ||
586
+ typeof catalog.staleUntil !== "number" ||
587
+ (catalog.fingerprint !== undefined &&
588
+ typeof catalog.fingerprint !== "string") ||
589
+ !this.validCatalogTools(catalog.tools)
590
+ ) {
591
+ return null;
592
+ }
593
+ return catalog as LegacyPersistedCatalog;
594
+ }
595
+
596
+ private validCatalogTools(value: unknown[]): value is ToolDef[] {
597
+ return value.every(
598
+ (tool) =>
599
+ tool !== null &&
600
+ typeof tool === "object" &&
601
+ typeof (tool as ToolDef).name === "string",
602
+ );
603
+ }
604
+
605
+ private validCatalogManifest(
606
+ value: unknown,
607
+ ): PersistedCatalogManifest | null {
608
+ if (!value || typeof value !== "object") return null;
609
+ const manifest = value as Partial<PersistedCatalogManifest>;
610
+ const maxChunks =
611
+ Math.ceil(MAX_SERIALIZED_CATALOG_BYTES / MAX_CATALOG_CHUNK_BYTES) + 1;
612
+ if (
613
+ manifest.version !== 2 ||
614
+ typeof manifest.revision !== "string" ||
615
+ !/^sha256:[0-9]{1,8}:[0-9a-f]{64}$/.test(manifest.revision) ||
616
+ !Number.isInteger(manifest.toolCount) ||
617
+ manifest.toolCount! < 0 ||
618
+ manifest.toolCount! > MAX_CATALOG_TOOLS ||
619
+ !Number.isInteger(manifest.byteCount) ||
620
+ manifest.byteCount! < 2 ||
621
+ manifest.byteCount! > MAX_SERIALIZED_CATALOG_BYTES ||
622
+ !manifest.revision.startsWith(`sha256:${manifest.byteCount}:`) ||
623
+ !Number.isInteger(manifest.chunkCount) ||
624
+ manifest.chunkCount! < 1 ||
625
+ manifest.chunkCount! > maxChunks ||
626
+ typeof manifest.fetchedAt !== "number" ||
627
+ typeof manifest.expiresAt !== "number" ||
628
+ typeof manifest.staleUntil !== "number"
629
+ ) {
630
+ return null;
631
+ }
632
+ return manifest as PersistedCatalogManifest;
633
+ }
634
+
635
+ private parseCatalogManifest(
636
+ raw: string | null,
637
+ ): PersistedCatalogManifest | null {
638
+ if (!raw) return null;
639
+ try {
640
+ return this.validCatalogManifest(JSON.parse(raw));
641
+ } catch {
642
+ return null;
643
+ }
644
+ }
645
+
646
+ private splitCatalogChunks(snapshot: CatalogSnapshot): string[] {
647
+ const chunks: string[] = [];
648
+ const decoder = new TextDecoder("utf-8", {
649
+ fatal: true,
650
+ ignoreBOM: true,
651
+ });
652
+ let offset = 0;
653
+ while (offset < snapshot.serializedBytes.byteLength) {
654
+ let end = Math.min(
655
+ offset + MAX_CATALOG_CHUNK_BYTES,
656
+ snapshot.serializedBytes.byteLength,
657
+ );
658
+ // Move a boundary that landed inside a multibyte UTF-8 sequence back to
659
+ // the next character start. Every stored string then remains valid UTF-8.
660
+ while (
661
+ end < snapshot.serializedBytes.byteLength &&
662
+ ((snapshot.serializedBytes[end] ?? 0) & 0xc0) === 0x80
663
+ ) {
664
+ end--;
665
+ }
666
+ chunks.push(
667
+ decoder.decode(snapshot.serializedBytes.subarray(offset, end)),
668
+ );
669
+ offset = end;
670
+ }
671
+ return chunks;
672
+ }
673
+
674
+ private async readCatalog(
675
+ id: string,
676
+ raw: string | null,
677
+ now: number,
678
+ ): Promise<PersistedCatalog | null> {
457
679
  if (!raw) return null;
680
+ let parsed: unknown;
458
681
  try {
459
- const value = JSON.parse(raw) as Partial<PersistedCatalog>;
682
+ parsed = JSON.parse(raw);
683
+ } catch {
684
+ return null;
685
+ }
686
+
687
+ if (
688
+ !parsed ||
689
+ typeof parsed !== "object" ||
690
+ (parsed as { version?: unknown }).version !== 2
691
+ ) {
692
+ const legacy = this.validLegacyCatalog(parsed);
693
+ if (!legacy || legacy.staleUntil <= now) return null;
694
+ const snapshot = await snapshotCatalog(legacy.tools);
460
695
  if (
461
- !Array.isArray(value.tools) ||
462
- typeof value.fetchedAt !== "number" ||
463
- typeof value.expiresAt !== "number" ||
464
- typeof value.staleUntil !== "number" ||
465
- !value.tools.every(
466
- (tool) =>
467
- tool !== null &&
468
- typeof tool === "object" &&
469
- typeof (tool as ToolDef).name === "string",
470
- )
696
+ legacy.tools.length > MAX_CATALOG_TOOLS ||
697
+ snapshot.serializedBytes.byteLength > MAX_SERIALIZED_CATALOG_BYTES ||
698
+ (legacy.fingerprint !== undefined &&
699
+ legacy.fingerprint !== snapshot.fingerprint)
471
700
  ) {
701
+ this.opts.logger.warn(
702
+ `[connecta] connector "${id}" legacy catalog is oversized or has a fingerprint mismatch; ignoring persisted catalog.`,
703
+ );
472
704
  return null;
473
705
  }
474
- return value as PersistedCatalog;
706
+ return {
707
+ tools: legacy.tools,
708
+ fingerprint: snapshot.fingerprint,
709
+ fetchedAt: legacy.fetchedAt,
710
+ expiresAt: legacy.expiresAt,
711
+ staleUntil: legacy.staleUntil,
712
+ };
713
+ }
714
+
715
+ const manifest = this.validCatalogManifest(parsed);
716
+ if (!manifest) {
717
+ this.opts.logger.warn(
718
+ `[connecta] connector "${id}" catalog manifest is invalid; ignoring persisted catalog.`,
719
+ );
720
+ return null;
721
+ }
722
+ if (manifest.staleUntil <= now) return null;
723
+
724
+ const chunkReads = await mapSettledWithConcurrency(
725
+ Array.from({ length: manifest.chunkCount }, (_, index) => index),
726
+ CATALOG_CHUNK_IO_CONCURRENCY,
727
+ (index) =>
728
+ this.opts.storage.get(
729
+ this.catalogChunkKey(id, manifest.revision, index),
730
+ ),
731
+ );
732
+ const chunks: string[] = [];
733
+ let chunkBytes = 0;
734
+ for (const [index, result] of chunkReads.entries()) {
735
+ if (result.status === "rejected") throw result.reason;
736
+ const chunk = result.value;
737
+ if (chunk === null) {
738
+ this.opts.logger.warn(
739
+ `[connecta] connector "${id}" catalog chunk ${index + 1}/${manifest.chunkCount} is missing; ignoring persisted catalog.`,
740
+ );
741
+ return null;
742
+ }
743
+ const byteLength = utf8ByteLength(chunk);
744
+ chunkBytes += byteLength;
745
+ if (
746
+ byteLength > MAX_CATALOG_CHUNK_BYTES ||
747
+ chunkBytes > manifest.byteCount
748
+ ) {
749
+ this.opts.logger.warn(
750
+ `[connecta] connector "${id}" catalog chunk bounds do not match its manifest; ignoring persisted catalog.`,
751
+ );
752
+ return null;
753
+ }
754
+ chunks.push(chunk);
755
+ }
756
+
757
+ const serializedTools = chunks.join("");
758
+ const stored = await fingerprintSerializedCatalog(serializedTools);
759
+ if (
760
+ stored.byteLength !== manifest.byteCount ||
761
+ stored.fingerprint !== manifest.revision
762
+ ) {
763
+ this.opts.logger.warn(
764
+ `[connecta] connector "${id}" catalog fingerprint mismatch; ignoring persisted catalog.`,
765
+ );
766
+ return null;
767
+ }
768
+
769
+ let tools: unknown;
770
+ try {
771
+ tools = JSON.parse(serializedTools);
475
772
  } catch {
773
+ this.opts.logger.warn(
774
+ `[connecta] connector "${id}" catalog chunks are torn; ignoring persisted catalog.`,
775
+ );
776
+ return null;
777
+ }
778
+ if (!Array.isArray(tools) || !this.validCatalogTools(tools)) {
779
+ this.opts.logger.warn(
780
+ `[connecta] connector "${id}" catalog chunks contain invalid tools; ignoring persisted catalog.`,
781
+ );
782
+ return null;
783
+ }
784
+ if (tools.length !== manifest.toolCount) {
785
+ this.opts.logger.warn(
786
+ `[connecta] connector "${id}" catalog tool count does not match its manifest; ignoring persisted catalog.`,
787
+ );
476
788
  return null;
477
789
  }
790
+ return {
791
+ tools,
792
+ fingerprint: stored.fingerprint,
793
+ fetchedAt: manifest.fetchedAt,
794
+ expiresAt: manifest.expiresAt,
795
+ staleUntil: manifest.staleUntil,
796
+ };
478
797
  }
479
798
 
480
- private async storeCatalog(id: string, tools: ToolDef[]): Promise<void> {
799
+ private async storeCatalog(
800
+ id: string,
801
+ snapshot: CatalogSnapshot,
802
+ ): Promise<void> {
481
803
  if (!this.persistToolCatalog) return;
482
804
  const fetchedAt = Date.now();
483
- const catalog: PersistedCatalog = {
484
- tools,
805
+ const expiresAt = fetchedAt + this.ttlMs;
806
+ const staleUntil = expiresAt + this.staleMs;
807
+ const ttlSeconds = Math.max(
808
+ 60,
809
+ Math.ceil((this.ttlMs + this.staleMs) / 1000),
810
+ );
811
+ const chunks = this.splitCatalogChunks(snapshot);
812
+ const chunkWrites = await mapSettledWithConcurrency(
813
+ chunks,
814
+ CATALOG_CHUNK_IO_CONCURRENCY,
815
+ (chunk, index) =>
816
+ this.opts.storage.set(
817
+ this.catalogChunkKey(id, snapshot.fingerprint, index),
818
+ chunk,
819
+ { ttlSeconds: ttlSeconds + CATALOG_CHUNK_TTL_GRACE_SECONDS },
820
+ ),
821
+ );
822
+ for (const result of chunkWrites) {
823
+ if (result.status === "rejected") throw result.reason;
824
+ }
825
+ // The manifest is the only publication point. A failed/partial chunk write
826
+ // therefore leaves the previous manifest authoritative (or no catalog);
827
+ // unreachable chunks carry a bounded TTL and require no prefix scan.
828
+ const manifest: PersistedCatalogManifest = {
829
+ version: 2,
830
+ revision: snapshot.fingerprint,
831
+ toolCount: snapshot.tools.length,
832
+ byteCount: snapshot.serializedBytes.byteLength,
833
+ chunkCount: chunks.length,
485
834
  fetchedAt,
486
- expiresAt: fetchedAt + this.ttlMs,
487
- staleUntil: fetchedAt + this.ttlMs + this.staleMs,
835
+ expiresAt,
836
+ staleUntil,
488
837
  };
489
- await this.opts.storage.set(this.catalogKey(id), JSON.stringify(catalog), {
490
- ttlSeconds: Math.max(
491
- 60,
492
- Math.ceil((this.ttlMs + this.staleMs) / 1000),
493
- ),
838
+ await this.opts.storage.set(this.catalogKey(id), JSON.stringify(manifest), {
839
+ ttlSeconds,
494
840
  });
495
841
  }
496
842
 
843
+ private async deleteCatalog(id: string): Promise<void> {
844
+ let raw: string | null = null;
845
+ let readError: unknown;
846
+ try {
847
+ raw = await this.opts.storage.get(this.catalogKey(id));
848
+ } catch (err) {
849
+ readError = err;
850
+ }
851
+ const manifest = this.parseCatalogManifest(raw);
852
+ // The root is authoritative, so attempt its deletion even when the
853
+ // best-effort read needed for physical chunk cleanup failed.
854
+ await this.opts.storage.delete(this.catalogKey(id));
855
+ if (!manifest) {
856
+ if (readError) throw readError;
857
+ return;
858
+ }
859
+ let firstError: unknown;
860
+ for (let index = 0; index < manifest.chunkCount; index++) {
861
+ try {
862
+ await this.opts.storage.delete(
863
+ this.catalogChunkKey(id, manifest.revision, index),
864
+ );
865
+ } catch (err) {
866
+ firstError ??= err;
867
+ }
868
+ }
869
+ if (readError) throw readError;
870
+ if (firstError) throw firstError;
871
+ }
872
+
497
873
  private catalogGeneration(id: string): number {
498
874
  return this.catalogGenerations.get(id) ?? 0;
499
875
  }
@@ -533,25 +909,43 @@ export class Registry implements RegistryView {
533
909
  ): Promise<ToolDef[]> {
534
910
  const connector = this.connectors.get(id);
535
911
  if (!connector) throw new Error(`Unknown connector "${id}"`);
912
+ if (connector.staticTools) return connector.staticTools;
536
913
  const generation = this.catalogGeneration(id);
537
- const tools = connector.staticTools
538
- ? connector.staticTools
539
- : await connector.listTools(
540
- this.contextFor(id, baseUrl, requestScope, callOptions),
541
- );
914
+ const tools = await connector.listTools(
915
+ this.contextFor(id, baseUrl, requestScope, callOptions),
916
+ );
542
917
  // The caller that began this refresh may still use its result, but a
543
918
  // credential/OAuth change that landed while listTools was in flight means
544
919
  // the listing must not enter either shared cache layer.
545
920
  if (generation !== this.catalogGeneration(id)) return tools;
546
- const now = Date.now();
921
+ if (tools.length > MAX_CATALOG_TOOLS) {
922
+ const message =
923
+ `Connector "${id}" returned ${tools.length} tools, over the ` +
924
+ `${MAX_CATALOG_TOOLS}-tool catalog ceiling; refusing the complete catalog.`;
925
+ this.opts.logger.warn(`[connecta] ${message}`);
926
+ throw new Error(message);
927
+ }
547
928
  const previous = this.cache.get(id);
929
+ const snapshot = await snapshotCatalog(tools);
930
+ if (generation !== this.catalogGeneration(id)) return tools;
931
+ if (snapshot.serializedBytes.byteLength > MAX_SERIALIZED_CATALOG_BYTES) {
932
+ const message =
933
+ `Connector "${id}" returned a ${snapshot.serializedBytes.byteLength}-byte ` +
934
+ `serialized catalog, over the ${MAX_SERIALIZED_CATALOG_BYTES}-byte ceiling; ` +
935
+ "refusing the complete catalog.";
936
+ this.opts.logger.warn(`[connecta] ${message}`);
937
+ throw new Error(message);
938
+ }
939
+ const now = Date.now();
548
940
  const catalogChanged =
549
- !previous || JSON.stringify(previous.tools) !== JSON.stringify(tools);
941
+ !previous || previous.fingerprint !== snapshot.fingerprint;
550
942
  const shouldPersist =
551
- !connector.staticTools &&
552
- (catalogChanged || previous.exp <= now || this.invalidated.has(id));
943
+ catalogChanged ||
944
+ (previous !== undefined && previous.exp <= now) ||
945
+ this.invalidated.has(id);
553
946
  this.cache.set(id, {
554
947
  tools,
948
+ fingerprint: snapshot.fingerprint,
555
949
  exp: now + this.ttlMs,
556
950
  staleUntil: now + this.ttlMs + this.staleMs,
557
951
  });
@@ -560,7 +954,7 @@ export class Registry implements RegistryView {
560
954
  await this.enqueueCatalogMutation(id, async () => {
561
955
  if (generation !== this.catalogGeneration(id)) return;
562
956
  try {
563
- await this.storeCatalog(id, tools);
957
+ await this.storeCatalog(id, snapshot);
564
958
  } catch (err) {
565
959
  this.opts.logger.warn(
566
960
  `[connecta] connector "${id}" catalog persistence failed: ${msg(err)}`,
@@ -572,7 +966,7 @@ export class Registry implements RegistryView {
572
966
  }
573
967
 
574
968
  /** Cached listTools with in-memory + persisted serializable catalog layers. */
575
- async getTools(
969
+ private async loadTools(
576
970
  id: string,
577
971
  baseUrl: string,
578
972
  requestScope?: object,
@@ -592,8 +986,10 @@ export class Registry implements RegistryView {
592
986
  const generation = this.catalogGeneration(id);
593
987
  let persisted: PersistedCatalog | null = null;
594
988
  try {
595
- persisted = this.validCatalog(
989
+ persisted = await this.readCatalog(
990
+ id,
596
991
  await this.opts.storage.get(this.catalogKey(id)),
992
+ now,
597
993
  );
598
994
  } catch (err) {
599
995
  this.opts.logger.warn(
@@ -607,6 +1003,7 @@ export class Registry implements RegistryView {
607
1003
  if (persisted && persisted.staleUntil > now) {
608
1004
  this.cache.set(id, {
609
1005
  tools: persisted.tools,
1006
+ fingerprint: persisted.fingerprint,
610
1007
  exp: persisted.expiresAt,
611
1008
  staleUntil: persisted.staleUntil,
612
1009
  });
@@ -632,6 +1029,46 @@ export class Registry implements RegistryView {
632
1029
  }
633
1030
  }
634
1031
 
1032
+ /**
1033
+ * Coalesce one connector's cold load inside one inbound request. The WeakMap
1034
+ * neither roots the request scope nor lets its connector context escape into
1035
+ * another request; settled entries are also removed eagerly.
1036
+ *
1037
+ * The first caller's `callOptions` govern the shared load: a later caller's
1038
+ * signal or timeout neither cancels nor extends it, and an abort by the
1039
+ * first caller rejects every coalesced caller. Within one request that is
1040
+ * the deal being made — one fetch, one deadline.
1041
+ */
1042
+ async getTools(
1043
+ id: string,
1044
+ baseUrl: string,
1045
+ requestScope?: object,
1046
+ callOptions: ConnectorOperationOptions = {},
1047
+ ): Promise<ToolDef[]> {
1048
+ const connector = this.connectors.get(id);
1049
+ if (!connector) throw new Error(`Unknown connector "${id}"`);
1050
+ if (connector.staticTools) return connector.staticTools;
1051
+ if (!requestScope) {
1052
+ return this.loadTools(id, baseUrl, requestScope, callOptions);
1053
+ }
1054
+
1055
+ let loads = this.requestCatalogLoads.get(requestScope);
1056
+ if (!loads) {
1057
+ loads = new Map();
1058
+ this.requestCatalogLoads.set(requestScope, loads);
1059
+ }
1060
+ const existing = loads.get(id);
1061
+ if (existing) return existing;
1062
+ const loading = this.loadTools(id, baseUrl, requestScope, callOptions);
1063
+ loads.set(id, loading);
1064
+ try {
1065
+ return await loading;
1066
+ } finally {
1067
+ if (loads.get(id) === loading) loads.delete(id);
1068
+ if (loads.size === 0) this.requestCatalogLoads.delete(requestScope);
1069
+ }
1070
+ }
1071
+
635
1072
  /** Return a cached catalog without performing storage or network I/O. */
636
1073
  peekTools(id: string): ToolDef[] | undefined {
637
1074
  const connector = this.connectors.get(id);
@@ -764,7 +1201,7 @@ export class Registry implements RegistryView {
764
1201
  if (this.persistToolCatalog) {
765
1202
  void this.enqueueCatalogMutation(id, async () => {
766
1203
  try {
767
- await this.opts.storage.delete(this.catalogKey(id));
1204
+ await this.deleteCatalog(id);
768
1205
  } catch (err) {
769
1206
  this.opts.logger.warn(
770
1207
  `[connecta] connector "${id}" catalog invalidation failed: ${msg(err)}`,
@@ -782,7 +1219,7 @@ export class Registry implements RegistryView {
782
1219
  if (this.persistToolCatalog) {
783
1220
  await this.enqueueCatalogMutation(id, async () => {
784
1221
  try {
785
- await this.opts.storage.delete(this.catalogKey(id));
1222
+ await this.deleteCatalog(id);
786
1223
  } catch (err) {
787
1224
  this.opts.logger.warn(
788
1225
  `[connecta] connector "${id}" catalog invalidation failed: ${msg(err)}`,
@@ -946,6 +1383,14 @@ export class ScopedRegistry implements RegistryView {
946
1383
  return this.base.contextFor(id, baseUrl, requestScope, callOptions);
947
1384
  }
948
1385
 
1386
+ admitCall(
1387
+ id: string,
1388
+ input: { toolName: string; args: unknown; signal?: AbortSignal },
1389
+ ): Promise<CallAdmissionPermit> {
1390
+ if (!this.visible(id)) return Promise.reject(this.unknownConnector(id));
1391
+ return this.base.admitCall(id, input);
1392
+ }
1393
+
949
1394
  /**
950
1395
  * Stashed oversized results are bound to the scope that produced them: a
951
1396
  * scoped session cannot page a result it could not have produced, and an id