@zackbart/connecta 0.7.9 → 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 (180) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +67 -144
  3. package/dist/auth/bearer.d.ts +1 -1
  4. package/dist/auth/bearer.js +4 -4
  5. package/dist/auth/bearer.js.map +1 -1
  6. package/dist/auth/clerk.d.ts +1 -1
  7. package/dist/auth/clerk.js +1 -1
  8. package/dist/call-admission.d.ts.map +1 -1
  9. package/dist/call-admission.js +9 -3
  10. package/dist/call-admission.js.map +1 -1
  11. package/dist/catalog-fingerprint.d.ts +20 -0
  12. package/dist/catalog-fingerprint.d.ts.map +1 -0
  13. package/dist/catalog-fingerprint.js +34 -0
  14. package/dist/catalog-fingerprint.js.map +1 -0
  15. package/dist/catalog-limits.d.ts +10 -0
  16. package/dist/catalog-limits.d.ts.map +1 -0
  17. package/dist/catalog-limits.js +10 -0
  18. package/dist/catalog-limits.js.map +1 -0
  19. package/dist/catalog-service.d.ts +138 -0
  20. package/dist/catalog-service.d.ts.map +1 -0
  21. package/dist/catalog-service.js +407 -0
  22. package/dist/catalog-service.js.map +1 -0
  23. package/dist/catalog.js +2 -2
  24. package/dist/catalog.js.map +1 -1
  25. package/dist/concurrency.d.ts +4 -0
  26. package/dist/concurrency.d.ts.map +1 -0
  27. package/dist/concurrency.js +31 -0
  28. package/dist/concurrency.js.map +1 -0
  29. package/dist/connectors/api.d.ts.map +1 -1
  30. package/dist/connectors/api.js +22 -12
  31. package/dist/connectors/api.js.map +1 -1
  32. package/dist/connectors/remote-mcp.d.ts.map +1 -1
  33. package/dist/connectors/remote-mcp.js +44 -19
  34. package/dist/connectors/remote-mcp.js.map +1 -1
  35. package/dist/credential-health.d.ts +0 -22
  36. package/dist/credential-health.d.ts.map +1 -1
  37. package/dist/credential-health.js +14 -10
  38. package/dist/credential-health.js.map +1 -1
  39. package/dist/credentials.d.ts +3 -2
  40. package/dist/credentials.d.ts.map +1 -1
  41. package/dist/errors.d.ts +1 -1
  42. package/dist/errors.d.ts.map +1 -1
  43. package/dist/errors.js.map +1 -1
  44. package/dist/execute.d.ts +9 -8
  45. package/dist/execute.d.ts.map +1 -1
  46. package/dist/execute.js +144 -321
  47. package/dist/execute.js.map +1 -1
  48. package/dist/executor-result.d.ts +0 -3
  49. package/dist/executor-result.d.ts.map +1 -1
  50. package/dist/executor-result.js +2 -2
  51. package/dist/executor-result.js.map +1 -1
  52. package/dist/executors/quickjs-child.js +3 -3
  53. package/dist/executors/quickjs-child.js.map +1 -1
  54. package/dist/executors/quickjs-protocol.d.ts +4 -1
  55. package/dist/executors/quickjs-protocol.d.ts.map +1 -1
  56. package/dist/executors/quickjs-runtime.d.ts.map +1 -1
  57. package/dist/executors/quickjs-runtime.js +7 -2
  58. package/dist/executors/quickjs-runtime.js.map +1 -1
  59. package/dist/executors/quickjs.d.ts.map +1 -1
  60. package/dist/executors/quickjs.js +4 -1
  61. package/dist/executors/quickjs.js.map +1 -1
  62. package/dist/favicon.js +2 -2
  63. package/dist/favicon.js.map +1 -1
  64. package/dist/index.d.ts +5 -0
  65. package/dist/index.d.ts.map +1 -1
  66. package/dist/index.js +44 -17
  67. package/dist/index.js.map +1 -1
  68. package/dist/invocation.d.ts +96 -0
  69. package/dist/invocation.d.ts.map +1 -0
  70. package/dist/invocation.js +369 -0
  71. package/dist/invocation.js.map +1 -0
  72. package/dist/meta-tools.d.ts +8 -86
  73. package/dist/meta-tools.d.ts.map +1 -1
  74. package/dist/meta-tools.js +145 -583
  75. package/dist/meta-tools.js.map +1 -1
  76. package/dist/operator-ui/generated.d.ts +3 -0
  77. package/dist/operator-ui/generated.d.ts.map +1 -0
  78. package/dist/operator-ui/generated.js +5 -0
  79. package/dist/operator-ui/generated.js.map +1 -0
  80. package/dist/operator-ui/model.d.ts +94 -0
  81. package/dist/operator-ui/model.d.ts.map +1 -0
  82. package/dist/operator-ui/model.js +28 -0
  83. package/dist/operator-ui/model.js.map +1 -0
  84. package/dist/registry.d.ts +22 -6
  85. package/dist/registry.d.ts.map +1 -1
  86. package/dist/registry.js +328 -35
  87. package/dist/registry.js.map +1 -1
  88. package/dist/routes/activity.d.ts +3 -0
  89. package/dist/routes/activity.d.ts.map +1 -0
  90. package/dist/routes/activity.js +173 -0
  91. package/dist/routes/activity.js.map +1 -0
  92. package/dist/routes/credentials.d.ts +3 -0
  93. package/dist/routes/credentials.d.ts.map +1 -0
  94. package/dist/routes/credentials.js +181 -0
  95. package/dist/routes/credentials.js.map +1 -0
  96. package/dist/routes/mcp.d.ts +8 -0
  97. package/dist/routes/mcp.d.ts.map +1 -0
  98. package/dist/routes/mcp.js +383 -0
  99. package/dist/routes/mcp.js.map +1 -0
  100. package/dist/routes/oauth.d.ts +4 -0
  101. package/dist/routes/oauth.d.ts.map +1 -0
  102. package/dist/routes/oauth.js +268 -0
  103. package/dist/routes/oauth.js.map +1 -0
  104. package/dist/routes/shared.d.ts +97 -0
  105. package/dist/routes/shared.d.ts.map +1 -0
  106. package/dist/routes/shared.js +199 -0
  107. package/dist/routes/shared.js.map +1 -0
  108. package/dist/routes/ui.d.ts +3 -0
  109. package/dist/routes/ui.d.ts.map +1 -0
  110. package/dist/routes/ui.js +122 -0
  111. package/dist/routes/ui.js.map +1 -0
  112. package/dist/server.d.ts +9 -40
  113. package/dist/server.d.ts.map +1 -1
  114. package/dist/server.js +63 -1293
  115. package/dist/server.js.map +1 -1
  116. package/dist/skills.d.ts +0 -15
  117. package/dist/skills.d.ts.map +1 -1
  118. package/dist/skills.js +6 -5
  119. package/dist/skills.js.map +1 -1
  120. package/dist/storage/file.d.ts.map +1 -1
  121. package/dist/storage/file.js +3 -1
  122. package/dist/storage/file.js.map +1 -1
  123. package/dist/storage/memory.d.ts.map +1 -1
  124. package/dist/storage/memory.js +3 -1
  125. package/dist/storage/memory.js.map +1 -1
  126. package/dist/toolkits.d.ts +0 -22
  127. package/dist/toolkits.d.ts.map +1 -1
  128. package/dist/toolkits.js +2 -2
  129. package/dist/toolkits.js.map +1 -1
  130. package/dist/types.d.ts +9 -2
  131. package/dist/types.d.ts.map +1 -1
  132. package/dist/ui.d.ts +3 -93
  133. package/dist/ui.d.ts.map +1 -1
  134. package/dist/ui.js +26 -1393
  135. package/dist/ui.js.map +1 -1
  136. package/dist/version.d.ts +1 -1
  137. package/dist/version.js +1 -1
  138. package/package.json +15 -8
  139. package/src/auth/bearer.ts +4 -4
  140. package/src/auth/clerk.ts +1 -1
  141. package/src/call-admission.ts +8 -5
  142. package/src/catalog-fingerprint.ts +52 -0
  143. package/src/catalog-limits.ts +11 -0
  144. package/src/catalog-service.ts +601 -0
  145. package/src/catalog.ts +2 -2
  146. package/src/concurrency.ts +39 -0
  147. package/src/connectors/api.ts +22 -12
  148. package/src/connectors/remote-mcp.ts +52 -22
  149. package/src/credential-health.ts +13 -10
  150. package/src/credentials.ts +2 -2
  151. package/src/errors.ts +1 -1
  152. package/src/execute.ts +194 -409
  153. package/src/executor-result.ts +2 -2
  154. package/src/executors/quickjs-child.ts +8 -4
  155. package/src/executors/quickjs-protocol.ts +1 -1
  156. package/src/executors/quickjs-runtime.ts +5 -1
  157. package/src/executors/quickjs.ts +11 -8
  158. package/src/favicon.ts +2 -2
  159. package/src/index.ts +49 -17
  160. package/src/invocation.ts +527 -0
  161. package/src/meta-tools.ts +366 -906
  162. package/src/operator-ui/browser.css +413 -0
  163. package/src/operator-ui/browser.ts +1121 -0
  164. package/src/operator-ui/generated.ts +4 -0
  165. package/src/operator-ui/model.ts +127 -0
  166. package/src/registry.ts +434 -44
  167. package/src/routes/activity.ts +220 -0
  168. package/src/routes/credentials.ts +266 -0
  169. package/src/routes/mcp.ts +485 -0
  170. package/src/routes/oauth.ts +328 -0
  171. package/src/routes/shared.ts +330 -0
  172. package/src/routes/ui.ts +153 -0
  173. package/src/server.ts +66 -1703
  174. package/src/skills.ts +6 -5
  175. package/src/storage/file.ts +3 -1
  176. package/src/storage/memory.ts +3 -1
  177. package/src/toolkits.ts +2 -2
  178. package/src/types.ts +9 -2
  179. package/src/ui.ts +52 -1493
  180. package/src/version.ts +1 -1
package/src/registry.ts CHANGED
@@ -23,15 +23,27 @@ import {
23
23
  type CallAdmissionPermit,
24
24
  type ConnectorCallAdmissionSnapshot,
25
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";
26
37
  import type { DeferredWork } from "./connector-scope.js";
27
38
  import { splitAddress, type Toolkit } from "./toolkits.js";
28
39
 
29
40
  const ID_RE = /^[a-z0-9_-]+$/;
30
41
  const DEFAULT_TTL_SECONDS = 300;
31
42
  const DEFAULT_STALE_SECONDS = 3600;
32
- export const DEFAULT_MAX_RESULT_BYTES = 50_000;
43
+ const CATALOG_CHUNK_TTL_GRACE_SECONDS = 300;
44
+ const DEFAULT_MAX_RESULT_BYTES = 50_000;
33
45
  /** Independent final-envelope boundary for `batch_call`. */
34
- export const DEFAULT_MAX_BATCH_RESULT_BYTES = 100_000;
46
+ const DEFAULT_MAX_BATCH_RESULT_BYTES = 100_000;
35
47
 
36
48
  /**
37
49
  * Smallest accepted inline-result cap. One byte is pathological but harmless:
@@ -76,17 +88,41 @@ export function resolveMaxResultBytes(
76
88
 
77
89
  interface CacheEntry {
78
90
  tools: ToolDef[];
91
+ fingerprint: string;
79
92
  exp: number; // epoch ms
80
93
  staleUntil: number;
81
94
  }
82
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
+
83
116
  interface PersistedCatalog {
84
117
  tools: ToolDef[];
118
+ fingerprint: string;
85
119
  fetchedAt: number;
86
120
  expiresAt: number;
87
121
  staleUntil: number;
88
122
  }
89
123
 
124
+ const CATALOG_CHUNK_IO_CONCURRENCY = 4;
125
+
90
126
  export interface HealthObservation {
91
127
  lastSuccessAt?: string;
92
128
  lastFailureAt?: string;
@@ -111,13 +147,14 @@ export class HealthLog {
111
147
 
112
148
  recordSuccess(id: string, latencyMs: number): void {
113
149
  const previous = this.observations.get(id);
114
- this.observations.set(id, {
150
+ const observation: HealthObservation = {
115
151
  ...previous,
116
152
  lastSuccessAt: new Date().toISOString(),
117
153
  lastLatencyMs: latencyMs,
118
154
  consecutiveFailures: 0,
119
- lastError: undefined,
120
- });
155
+ };
156
+ delete observation.lastError;
157
+ this.observations.set(id, observation);
121
158
  }
122
159
 
123
160
  recordFailure(id: string, latencyMs: number, error: unknown): void {
@@ -171,7 +208,35 @@ function msg(err: unknown): string {
171
208
  return err instanceof Error ? err.message : String(err);
172
209
  }
173
210
 
174
- 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<
175
240
  ConnectorContext,
176
241
  "signal" | "timeoutMs"
177
242
  >;
@@ -265,6 +330,11 @@ export class Registry implements RegistryView {
265
330
  private readonly catalogGenerations = new Map<string, number>();
266
331
  /** Serialize persisted catalog set/delete operations within this isolate. */
267
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
+ >();
268
338
  /** Deployment-wide observations — every call, whatever view made it. */
269
339
  private readonly health = new HealthLog();
270
340
  private readonly ttlMs: number;
@@ -325,7 +395,9 @@ export class Registry implements RegistryView {
325
395
  this.contextFor(id, baseUrl, requestScope),
326
396
  storage: opts.storage,
327
397
  logger: opts.logger,
328
- credentialVault: opts.credentialVault,
398
+ ...(opts.credentialVault !== undefined
399
+ ? { credentialVault: opts.credentialVault }
400
+ : {}),
329
401
  },
330
402
  opts.credentialHealth,
331
403
  );
@@ -500,47 +572,304 @@ export class Registry implements RegistryView {
500
572
  return `catalog:${id}`;
501
573
  }
502
574
 
503
- 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> {
504
679
  if (!raw) return null;
680
+ let parsed: unknown;
505
681
  try {
506
- 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);
507
695
  if (
508
- !Array.isArray(value.tools) ||
509
- typeof value.fetchedAt !== "number" ||
510
- typeof value.expiresAt !== "number" ||
511
- typeof value.staleUntil !== "number" ||
512
- !value.tools.every(
513
- (tool) =>
514
- tool !== null &&
515
- typeof tool === "object" &&
516
- typeof (tool as ToolDef).name === "string",
517
- )
696
+ legacy.tools.length > MAX_CATALOG_TOOLS ||
697
+ snapshot.serializedBytes.byteLength > MAX_SERIALIZED_CATALOG_BYTES ||
698
+ (legacy.fingerprint !== undefined &&
699
+ legacy.fingerprint !== snapshot.fingerprint)
518
700
  ) {
701
+ this.opts.logger.warn(
702
+ `[connecta] connector "${id}" legacy catalog is oversized or has a fingerprint mismatch; ignoring persisted catalog.`,
703
+ );
519
704
  return null;
520
705
  }
521
- 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);
522
772
  } catch {
773
+ this.opts.logger.warn(
774
+ `[connecta] connector "${id}" catalog chunks are torn; ignoring persisted catalog.`,
775
+ );
523
776
  return null;
524
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
+ );
788
+ return null;
789
+ }
790
+ return {
791
+ tools,
792
+ fingerprint: stored.fingerprint,
793
+ fetchedAt: manifest.fetchedAt,
794
+ expiresAt: manifest.expiresAt,
795
+ staleUntil: manifest.staleUntil,
796
+ };
525
797
  }
526
798
 
527
- private async storeCatalog(id: string, tools: ToolDef[]): Promise<void> {
799
+ private async storeCatalog(
800
+ id: string,
801
+ snapshot: CatalogSnapshot,
802
+ ): Promise<void> {
528
803
  if (!this.persistToolCatalog) return;
529
804
  const fetchedAt = Date.now();
530
- const catalog: PersistedCatalog = {
531
- 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,
532
834
  fetchedAt,
533
- expiresAt: fetchedAt + this.ttlMs,
534
- staleUntil: fetchedAt + this.ttlMs + this.staleMs,
835
+ expiresAt,
836
+ staleUntil,
535
837
  };
536
- await this.opts.storage.set(this.catalogKey(id), JSON.stringify(catalog), {
537
- ttlSeconds: Math.max(
538
- 60,
539
- Math.ceil((this.ttlMs + this.staleMs) / 1000),
540
- ),
838
+ await this.opts.storage.set(this.catalogKey(id), JSON.stringify(manifest), {
839
+ ttlSeconds,
541
840
  });
542
841
  }
543
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
+
544
873
  private catalogGeneration(id: string): number {
545
874
  return this.catalogGenerations.get(id) ?? 0;
546
875
  }
@@ -580,25 +909,43 @@ export class Registry implements RegistryView {
580
909
  ): Promise<ToolDef[]> {
581
910
  const connector = this.connectors.get(id);
582
911
  if (!connector) throw new Error(`Unknown connector "${id}"`);
912
+ if (connector.staticTools) return connector.staticTools;
583
913
  const generation = this.catalogGeneration(id);
584
- const tools = connector.staticTools
585
- ? connector.staticTools
586
- : await connector.listTools(
587
- this.contextFor(id, baseUrl, requestScope, callOptions),
588
- );
914
+ const tools = await connector.listTools(
915
+ this.contextFor(id, baseUrl, requestScope, callOptions),
916
+ );
589
917
  // The caller that began this refresh may still use its result, but a
590
918
  // credential/OAuth change that landed while listTools was in flight means
591
919
  // the listing must not enter either shared cache layer.
592
920
  if (generation !== this.catalogGeneration(id)) return tools;
593
- 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
+ }
594
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();
595
940
  const catalogChanged =
596
- !previous || JSON.stringify(previous.tools) !== JSON.stringify(tools);
941
+ !previous || previous.fingerprint !== snapshot.fingerprint;
597
942
  const shouldPersist =
598
- !connector.staticTools &&
599
- (catalogChanged || previous.exp <= now || this.invalidated.has(id));
943
+ catalogChanged ||
944
+ (previous !== undefined && previous.exp <= now) ||
945
+ this.invalidated.has(id);
600
946
  this.cache.set(id, {
601
947
  tools,
948
+ fingerprint: snapshot.fingerprint,
602
949
  exp: now + this.ttlMs,
603
950
  staleUntil: now + this.ttlMs + this.staleMs,
604
951
  });
@@ -607,7 +954,7 @@ export class Registry implements RegistryView {
607
954
  await this.enqueueCatalogMutation(id, async () => {
608
955
  if (generation !== this.catalogGeneration(id)) return;
609
956
  try {
610
- await this.storeCatalog(id, tools);
957
+ await this.storeCatalog(id, snapshot);
611
958
  } catch (err) {
612
959
  this.opts.logger.warn(
613
960
  `[connecta] connector "${id}" catalog persistence failed: ${msg(err)}`,
@@ -619,7 +966,7 @@ export class Registry implements RegistryView {
619
966
  }
620
967
 
621
968
  /** Cached listTools with in-memory + persisted serializable catalog layers. */
622
- async getTools(
969
+ private async loadTools(
623
970
  id: string,
624
971
  baseUrl: string,
625
972
  requestScope?: object,
@@ -639,8 +986,10 @@ export class Registry implements RegistryView {
639
986
  const generation = this.catalogGeneration(id);
640
987
  let persisted: PersistedCatalog | null = null;
641
988
  try {
642
- persisted = this.validCatalog(
989
+ persisted = await this.readCatalog(
990
+ id,
643
991
  await this.opts.storage.get(this.catalogKey(id)),
992
+ now,
644
993
  );
645
994
  } catch (err) {
646
995
  this.opts.logger.warn(
@@ -654,6 +1003,7 @@ export class Registry implements RegistryView {
654
1003
  if (persisted && persisted.staleUntil > now) {
655
1004
  this.cache.set(id, {
656
1005
  tools: persisted.tools,
1006
+ fingerprint: persisted.fingerprint,
657
1007
  exp: persisted.expiresAt,
658
1008
  staleUntil: persisted.staleUntil,
659
1009
  });
@@ -679,6 +1029,46 @@ export class Registry implements RegistryView {
679
1029
  }
680
1030
  }
681
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
+
682
1072
  /** Return a cached catalog without performing storage or network I/O. */
683
1073
  peekTools(id: string): ToolDef[] | undefined {
684
1074
  const connector = this.connectors.get(id);
@@ -811,7 +1201,7 @@ export class Registry implements RegistryView {
811
1201
  if (this.persistToolCatalog) {
812
1202
  void this.enqueueCatalogMutation(id, async () => {
813
1203
  try {
814
- await this.opts.storage.delete(this.catalogKey(id));
1204
+ await this.deleteCatalog(id);
815
1205
  } catch (err) {
816
1206
  this.opts.logger.warn(
817
1207
  `[connecta] connector "${id}" catalog invalidation failed: ${msg(err)}`,
@@ -829,7 +1219,7 @@ export class Registry implements RegistryView {
829
1219
  if (this.persistToolCatalog) {
830
1220
  await this.enqueueCatalogMutation(id, async () => {
831
1221
  try {
832
- await this.opts.storage.delete(this.catalogKey(id));
1222
+ await this.deleteCatalog(id);
833
1223
  } catch (err) {
834
1224
  this.opts.logger.warn(
835
1225
  `[connecta] connector "${id}" catalog invalidation failed: ${msg(err)}`,