@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/dist/registry.js CHANGED
@@ -1,13 +1,18 @@
1
1
  import { storedCredentialShape, } from "./credentials.js";
2
2
  import { ConnectorCallError } from "./errors.js";
3
3
  import { CredentialHealthChecker, } from "./credential-health.js";
4
+ import { ConnectorCallAdmissionController, } from "./call-admission.js";
5
+ import { fingerprintSerializedCatalog, snapshotCatalog, } from "./catalog-fingerprint.js";
6
+ import { MAX_CATALOG_CHUNK_BYTES, MAX_CATALOG_TOOLS, MAX_SERIALIZED_CATALOG_BYTES, } from "./catalog-limits.js";
7
+ import { mapSettledWithConcurrency } from "./concurrency.js";
4
8
  import { splitAddress } from "./toolkits.js";
5
9
  const ID_RE = /^[a-z0-9_-]+$/;
6
10
  const DEFAULT_TTL_SECONDS = 300;
7
11
  const DEFAULT_STALE_SECONDS = 3600;
8
- export const DEFAULT_MAX_RESULT_BYTES = 50_000;
12
+ const CATALOG_CHUNK_TTL_GRACE_SECONDS = 300;
13
+ const DEFAULT_MAX_RESULT_BYTES = 50_000;
9
14
  /** Independent final-envelope boundary for `batch_call`. */
10
- export const DEFAULT_MAX_BATCH_RESULT_BYTES = 100_000;
15
+ const DEFAULT_MAX_BATCH_RESULT_BYTES = 100_000;
11
16
  /**
12
17
  * Smallest accepted inline-result cap. One byte is pathological but harmless:
13
18
  * `alignEndToCharBoundary` widens a window narrower than the codepoint at the
@@ -43,6 +48,7 @@ export function resolveMaxResultBytes(value, inherited) {
43
48
  ? value
44
49
  : inherited;
45
50
  }
51
+ const CATALOG_CHUNK_IO_CONCURRENCY = 4;
46
52
  /**
47
53
  * Recent real-call outcomes per connector, as observed by ONE view.
48
54
  *
@@ -58,13 +64,14 @@ export class HealthLog {
58
64
  observations = new Map();
59
65
  recordSuccess(id, latencyMs) {
60
66
  const previous = this.observations.get(id);
61
- this.observations.set(id, {
67
+ const observation = {
62
68
  ...previous,
63
69
  lastSuccessAt: new Date().toISOString(),
64
70
  lastLatencyMs: latencyMs,
65
71
  consecutiveFailures: 0,
66
- lastError: undefined,
67
- });
72
+ };
73
+ delete observation.lastError;
74
+ this.observations.set(id, observation);
68
75
  }
69
76
  recordFailure(id, latencyMs, error) {
70
77
  const previous = this.observations.get(id);
@@ -91,6 +98,35 @@ function namespaced(storage, prefix) {
91
98
  function msg(err) {
92
99
  return err instanceof Error ? err.message : String(err);
93
100
  }
101
+ /** WHATWG TextEncoder byte length without allocating another full buffer. */
102
+ function utf8ByteLength(value) {
103
+ let bytes = 0;
104
+ for (let index = 0; index < value.length; index++) {
105
+ const code = value.charCodeAt(index);
106
+ if (code <= 0x7f) {
107
+ bytes++;
108
+ }
109
+ else if (code <= 0x7ff) {
110
+ bytes += 2;
111
+ }
112
+ else if (code >= 0xd800 &&
113
+ code <= 0xdbff &&
114
+ index + 1 < value.length) {
115
+ const next = value.charCodeAt(index + 1);
116
+ if (next >= 0xdc00 && next <= 0xdfff) {
117
+ bytes += 4;
118
+ index++;
119
+ }
120
+ else {
121
+ bytes += 3;
122
+ }
123
+ }
124
+ else {
125
+ bytes += 3;
126
+ }
127
+ }
128
+ return bytes;
129
+ }
94
130
  /**
95
131
  * Holds the connector set, resolves addresses, and caches per-connector tool
96
132
  * lists in memory with a TTL. Connector failures are isolated: a broken
@@ -99,12 +135,15 @@ function msg(err) {
99
135
  export class Registry {
100
136
  opts;
101
137
  connectors = new Map();
138
+ callAdmission = new Map();
102
139
  cache = new Map();
103
140
  invalidated = new Set();
104
141
  /** Per-connector epoch preventing a pre-invalidation refresh from publishing. */
105
142
  catalogGenerations = new Map();
106
143
  /** Serialize persisted catalog set/delete operations within this isolate. */
107
144
  catalogMutations = new Map();
145
+ /** Same-request cold loads share one promise without retaining the request. */
146
+ requestCatalogLoads = new WeakMap();
108
147
  /** Deployment-wide observations — every call, whatever view made it. */
109
148
  health = new HealthLog();
110
149
  ttlMs;
@@ -133,6 +172,9 @@ export class Registry {
133
172
  throw new Error(`Duplicate connector id "${c.id}"`);
134
173
  }
135
174
  this.connectors.set(c.id, c);
175
+ if (c.callAdmission) {
176
+ this.callAdmission.set(c.id, new ConnectorCallAdmissionController(c.id, c.callAdmission));
177
+ }
136
178
  }
137
179
  this.checkConventions(opts.logger);
138
180
  this.checkResultCaps(opts.logger, opts.maxResultBytes, opts.maxBatchResultBytes);
@@ -142,7 +184,9 @@ export class Registry {
142
184
  contextFor: (id, baseUrl, requestScope) => this.contextFor(id, baseUrl, requestScope),
143
185
  storage: opts.storage,
144
186
  logger: opts.logger,
145
- credentialVault: opts.credentialVault,
187
+ ...(opts.credentialVault !== undefined
188
+ ? { credentialVault: opts.credentialVault }
189
+ : {}),
146
190
  }, opts.credentialHealth);
147
191
  }
148
192
  /**
@@ -236,6 +280,24 @@ export class Registry {
236
280
  ...callOptions,
237
281
  };
238
282
  }
283
+ admitCall(id, input) {
284
+ const admission = this.callAdmission.get(id);
285
+ if (admission)
286
+ return admission.acquire(input);
287
+ return Promise.resolve({ waitMs: 0, release() { } });
288
+ }
289
+ /** Payload-free aggregate state for the open health endpoint. */
290
+ callAdmissionSnapshot() {
291
+ return Object.fromEntries([...this.callAdmission].map(([id, admission]) => [
292
+ id,
293
+ admission.snapshot(),
294
+ ]));
295
+ }
296
+ /** Reject queued/future downstream admission; active permits release safely. */
297
+ closeCallAdmission() {
298
+ for (const admission of this.callAdmission.values())
299
+ admission.close();
300
+ }
239
301
  /**
240
302
  * Storage namespaced to the meta-tool result store (`results:` prefix), kept
241
303
  * separate from any connector's `conn:<id>:` namespace. Backs get_result.
@@ -256,40 +318,236 @@ export class Registry {
256
318
  catalogKey(id) {
257
319
  return `catalog:${id}`;
258
320
  }
259
- validCatalog(raw) {
321
+ catalogChunkKey(id, revision, index) {
322
+ return `${this.catalogKey(id)}:chunk:${revision}:${index}`;
323
+ }
324
+ validLegacyCatalog(value) {
325
+ if (!value || typeof value !== "object")
326
+ return null;
327
+ const catalog = value;
328
+ if (!Array.isArray(catalog.tools) ||
329
+ typeof catalog.fetchedAt !== "number" ||
330
+ typeof catalog.expiresAt !== "number" ||
331
+ typeof catalog.staleUntil !== "number" ||
332
+ (catalog.fingerprint !== undefined &&
333
+ typeof catalog.fingerprint !== "string") ||
334
+ !this.validCatalogTools(catalog.tools)) {
335
+ return null;
336
+ }
337
+ return catalog;
338
+ }
339
+ validCatalogTools(value) {
340
+ return value.every((tool) => tool !== null &&
341
+ typeof tool === "object" &&
342
+ typeof tool.name === "string");
343
+ }
344
+ validCatalogManifest(value) {
345
+ if (!value || typeof value !== "object")
346
+ return null;
347
+ const manifest = value;
348
+ const maxChunks = Math.ceil(MAX_SERIALIZED_CATALOG_BYTES / MAX_CATALOG_CHUNK_BYTES) + 1;
349
+ if (manifest.version !== 2 ||
350
+ typeof manifest.revision !== "string" ||
351
+ !/^sha256:[0-9]{1,8}:[0-9a-f]{64}$/.test(manifest.revision) ||
352
+ !Number.isInteger(manifest.toolCount) ||
353
+ manifest.toolCount < 0 ||
354
+ manifest.toolCount > MAX_CATALOG_TOOLS ||
355
+ !Number.isInteger(manifest.byteCount) ||
356
+ manifest.byteCount < 2 ||
357
+ manifest.byteCount > MAX_SERIALIZED_CATALOG_BYTES ||
358
+ !manifest.revision.startsWith(`sha256:${manifest.byteCount}:`) ||
359
+ !Number.isInteger(manifest.chunkCount) ||
360
+ manifest.chunkCount < 1 ||
361
+ manifest.chunkCount > maxChunks ||
362
+ typeof manifest.fetchedAt !== "number" ||
363
+ typeof manifest.expiresAt !== "number" ||
364
+ typeof manifest.staleUntil !== "number") {
365
+ return null;
366
+ }
367
+ return manifest;
368
+ }
369
+ parseCatalogManifest(raw) {
260
370
  if (!raw)
261
371
  return null;
262
372
  try {
263
- const value = JSON.parse(raw);
264
- if (!Array.isArray(value.tools) ||
265
- typeof value.fetchedAt !== "number" ||
266
- typeof value.expiresAt !== "number" ||
267
- typeof value.staleUntil !== "number" ||
268
- !value.tools.every((tool) => tool !== null &&
269
- typeof tool === "object" &&
270
- typeof tool.name === "string")) {
373
+ return this.validCatalogManifest(JSON.parse(raw));
374
+ }
375
+ catch {
376
+ return null;
377
+ }
378
+ }
379
+ splitCatalogChunks(snapshot) {
380
+ const chunks = [];
381
+ const decoder = new TextDecoder("utf-8", {
382
+ fatal: true,
383
+ ignoreBOM: true,
384
+ });
385
+ let offset = 0;
386
+ while (offset < snapshot.serializedBytes.byteLength) {
387
+ let end = Math.min(offset + MAX_CATALOG_CHUNK_BYTES, snapshot.serializedBytes.byteLength);
388
+ // Move a boundary that landed inside a multibyte UTF-8 sequence back to
389
+ // the next character start. Every stored string then remains valid UTF-8.
390
+ while (end < snapshot.serializedBytes.byteLength &&
391
+ ((snapshot.serializedBytes[end] ?? 0) & 0xc0) === 0x80) {
392
+ end--;
393
+ }
394
+ chunks.push(decoder.decode(snapshot.serializedBytes.subarray(offset, end)));
395
+ offset = end;
396
+ }
397
+ return chunks;
398
+ }
399
+ async readCatalog(id, raw, now) {
400
+ if (!raw)
401
+ return null;
402
+ let parsed;
403
+ try {
404
+ parsed = JSON.parse(raw);
405
+ }
406
+ catch {
407
+ return null;
408
+ }
409
+ if (!parsed ||
410
+ typeof parsed !== "object" ||
411
+ parsed.version !== 2) {
412
+ const legacy = this.validLegacyCatalog(parsed);
413
+ if (!legacy || legacy.staleUntil <= now)
414
+ return null;
415
+ const snapshot = await snapshotCatalog(legacy.tools);
416
+ if (legacy.tools.length > MAX_CATALOG_TOOLS ||
417
+ snapshot.serializedBytes.byteLength > MAX_SERIALIZED_CATALOG_BYTES ||
418
+ (legacy.fingerprint !== undefined &&
419
+ legacy.fingerprint !== snapshot.fingerprint)) {
420
+ this.opts.logger.warn(`[connecta] connector "${id}" legacy catalog is oversized or has a fingerprint mismatch; ignoring persisted catalog.`);
421
+ return null;
422
+ }
423
+ return {
424
+ tools: legacy.tools,
425
+ fingerprint: snapshot.fingerprint,
426
+ fetchedAt: legacy.fetchedAt,
427
+ expiresAt: legacy.expiresAt,
428
+ staleUntil: legacy.staleUntil,
429
+ };
430
+ }
431
+ const manifest = this.validCatalogManifest(parsed);
432
+ if (!manifest) {
433
+ this.opts.logger.warn(`[connecta] connector "${id}" catalog manifest is invalid; ignoring persisted catalog.`);
434
+ return null;
435
+ }
436
+ if (manifest.staleUntil <= now)
437
+ return null;
438
+ const chunkReads = await mapSettledWithConcurrency(Array.from({ length: manifest.chunkCount }, (_, index) => index), CATALOG_CHUNK_IO_CONCURRENCY, (index) => this.opts.storage.get(this.catalogChunkKey(id, manifest.revision, index)));
439
+ const chunks = [];
440
+ let chunkBytes = 0;
441
+ for (const [index, result] of chunkReads.entries()) {
442
+ if (result.status === "rejected")
443
+ throw result.reason;
444
+ const chunk = result.value;
445
+ if (chunk === null) {
446
+ this.opts.logger.warn(`[connecta] connector "${id}" catalog chunk ${index + 1}/${manifest.chunkCount} is missing; ignoring persisted catalog.`);
447
+ return null;
448
+ }
449
+ const byteLength = utf8ByteLength(chunk);
450
+ chunkBytes += byteLength;
451
+ if (byteLength > MAX_CATALOG_CHUNK_BYTES ||
452
+ chunkBytes > manifest.byteCount) {
453
+ this.opts.logger.warn(`[connecta] connector "${id}" catalog chunk bounds do not match its manifest; ignoring persisted catalog.`);
271
454
  return null;
272
455
  }
273
- return value;
456
+ chunks.push(chunk);
457
+ }
458
+ const serializedTools = chunks.join("");
459
+ const stored = await fingerprintSerializedCatalog(serializedTools);
460
+ if (stored.byteLength !== manifest.byteCount ||
461
+ stored.fingerprint !== manifest.revision) {
462
+ this.opts.logger.warn(`[connecta] connector "${id}" catalog fingerprint mismatch; ignoring persisted catalog.`);
463
+ return null;
464
+ }
465
+ let tools;
466
+ try {
467
+ tools = JSON.parse(serializedTools);
274
468
  }
275
469
  catch {
470
+ this.opts.logger.warn(`[connecta] connector "${id}" catalog chunks are torn; ignoring persisted catalog.`);
276
471
  return null;
277
472
  }
473
+ if (!Array.isArray(tools) || !this.validCatalogTools(tools)) {
474
+ this.opts.logger.warn(`[connecta] connector "${id}" catalog chunks contain invalid tools; ignoring persisted catalog.`);
475
+ return null;
476
+ }
477
+ if (tools.length !== manifest.toolCount) {
478
+ this.opts.logger.warn(`[connecta] connector "${id}" catalog tool count does not match its manifest; ignoring persisted catalog.`);
479
+ return null;
480
+ }
481
+ return {
482
+ tools,
483
+ fingerprint: stored.fingerprint,
484
+ fetchedAt: manifest.fetchedAt,
485
+ expiresAt: manifest.expiresAt,
486
+ staleUntil: manifest.staleUntil,
487
+ };
278
488
  }
279
- async storeCatalog(id, tools) {
489
+ async storeCatalog(id, snapshot) {
280
490
  if (!this.persistToolCatalog)
281
491
  return;
282
492
  const fetchedAt = Date.now();
283
- const catalog = {
284
- tools,
493
+ const expiresAt = fetchedAt + this.ttlMs;
494
+ const staleUntil = expiresAt + this.staleMs;
495
+ const ttlSeconds = Math.max(60, Math.ceil((this.ttlMs + this.staleMs) / 1000));
496
+ const chunks = this.splitCatalogChunks(snapshot);
497
+ const chunkWrites = await mapSettledWithConcurrency(chunks, CATALOG_CHUNK_IO_CONCURRENCY, (chunk, index) => this.opts.storage.set(this.catalogChunkKey(id, snapshot.fingerprint, index), chunk, { ttlSeconds: ttlSeconds + CATALOG_CHUNK_TTL_GRACE_SECONDS }));
498
+ for (const result of chunkWrites) {
499
+ if (result.status === "rejected")
500
+ throw result.reason;
501
+ }
502
+ // The manifest is the only publication point. A failed/partial chunk write
503
+ // therefore leaves the previous manifest authoritative (or no catalog);
504
+ // unreachable chunks carry a bounded TTL and require no prefix scan.
505
+ const manifest = {
506
+ version: 2,
507
+ revision: snapshot.fingerprint,
508
+ toolCount: snapshot.tools.length,
509
+ byteCount: snapshot.serializedBytes.byteLength,
510
+ chunkCount: chunks.length,
285
511
  fetchedAt,
286
- expiresAt: fetchedAt + this.ttlMs,
287
- staleUntil: fetchedAt + this.ttlMs + this.staleMs,
512
+ expiresAt,
513
+ staleUntil,
288
514
  };
289
- await this.opts.storage.set(this.catalogKey(id), JSON.stringify(catalog), {
290
- ttlSeconds: Math.max(60, Math.ceil((this.ttlMs + this.staleMs) / 1000)),
515
+ await this.opts.storage.set(this.catalogKey(id), JSON.stringify(manifest), {
516
+ ttlSeconds,
291
517
  });
292
518
  }
519
+ async deleteCatalog(id) {
520
+ let raw = null;
521
+ let readError;
522
+ try {
523
+ raw = await this.opts.storage.get(this.catalogKey(id));
524
+ }
525
+ catch (err) {
526
+ readError = err;
527
+ }
528
+ const manifest = this.parseCatalogManifest(raw);
529
+ // The root is authoritative, so attempt its deletion even when the
530
+ // best-effort read needed for physical chunk cleanup failed.
531
+ await this.opts.storage.delete(this.catalogKey(id));
532
+ if (!manifest) {
533
+ if (readError)
534
+ throw readError;
535
+ return;
536
+ }
537
+ let firstError;
538
+ for (let index = 0; index < manifest.chunkCount; index++) {
539
+ try {
540
+ await this.opts.storage.delete(this.catalogChunkKey(id, manifest.revision, index));
541
+ }
542
+ catch (err) {
543
+ firstError ??= err;
544
+ }
545
+ }
546
+ if (readError)
547
+ throw readError;
548
+ if (firstError)
549
+ throw firstError;
550
+ }
293
551
  catalogGeneration(id) {
294
552
  return this.catalogGenerations.get(id) ?? 0;
295
553
  }
@@ -319,22 +577,40 @@ export class Registry {
319
577
  const connector = this.connectors.get(id);
320
578
  if (!connector)
321
579
  throw new Error(`Unknown connector "${id}"`);
580
+ if (connector.staticTools)
581
+ return connector.staticTools;
322
582
  const generation = this.catalogGeneration(id);
323
- const tools = connector.staticTools
324
- ? connector.staticTools
325
- : await connector.listTools(this.contextFor(id, baseUrl, requestScope, callOptions));
583
+ const tools = await connector.listTools(this.contextFor(id, baseUrl, requestScope, callOptions));
326
584
  // The caller that began this refresh may still use its result, but a
327
585
  // credential/OAuth change that landed while listTools was in flight means
328
586
  // the listing must not enter either shared cache layer.
329
587
  if (generation !== this.catalogGeneration(id))
330
588
  return tools;
331
- const now = Date.now();
589
+ if (tools.length > MAX_CATALOG_TOOLS) {
590
+ const message = `Connector "${id}" returned ${tools.length} tools, over the ` +
591
+ `${MAX_CATALOG_TOOLS}-tool catalog ceiling; refusing the complete catalog.`;
592
+ this.opts.logger.warn(`[connecta] ${message}`);
593
+ throw new Error(message);
594
+ }
332
595
  const previous = this.cache.get(id);
333
- const catalogChanged = !previous || JSON.stringify(previous.tools) !== JSON.stringify(tools);
334
- const shouldPersist = !connector.staticTools &&
335
- (catalogChanged || previous.exp <= now || this.invalidated.has(id));
596
+ const snapshot = await snapshotCatalog(tools);
597
+ if (generation !== this.catalogGeneration(id))
598
+ return tools;
599
+ if (snapshot.serializedBytes.byteLength > MAX_SERIALIZED_CATALOG_BYTES) {
600
+ const message = `Connector "${id}" returned a ${snapshot.serializedBytes.byteLength}-byte ` +
601
+ `serialized catalog, over the ${MAX_SERIALIZED_CATALOG_BYTES}-byte ceiling; ` +
602
+ "refusing the complete catalog.";
603
+ this.opts.logger.warn(`[connecta] ${message}`);
604
+ throw new Error(message);
605
+ }
606
+ const now = Date.now();
607
+ const catalogChanged = !previous || previous.fingerprint !== snapshot.fingerprint;
608
+ const shouldPersist = catalogChanged ||
609
+ (previous !== undefined && previous.exp <= now) ||
610
+ this.invalidated.has(id);
336
611
  this.cache.set(id, {
337
612
  tools,
613
+ fingerprint: snapshot.fingerprint,
338
614
  exp: now + this.ttlMs,
339
615
  staleUntil: now + this.ttlMs + this.staleMs,
340
616
  });
@@ -344,7 +620,7 @@ export class Registry {
344
620
  if (generation !== this.catalogGeneration(id))
345
621
  return;
346
622
  try {
347
- await this.storeCatalog(id, tools);
623
+ await this.storeCatalog(id, snapshot);
348
624
  }
349
625
  catch (err) {
350
626
  this.opts.logger.warn(`[connecta] connector "${id}" catalog persistence failed: ${msg(err)}`);
@@ -354,7 +630,7 @@ export class Registry {
354
630
  return tools;
355
631
  }
356
632
  /** Cached listTools with in-memory + persisted serializable catalog layers. */
357
- async getTools(id, baseUrl, requestScope, callOptions = {}) {
633
+ async loadTools(id, baseUrl, requestScope, callOptions = {}) {
358
634
  const connector = this.connectors.get(id);
359
635
  if (!connector)
360
636
  throw new Error(`Unknown connector "${id}"`);
@@ -370,7 +646,7 @@ export class Registry {
370
646
  const generation = this.catalogGeneration(id);
371
647
  let persisted = null;
372
648
  try {
373
- persisted = this.validCatalog(await this.opts.storage.get(this.catalogKey(id)));
649
+ persisted = await this.readCatalog(id, await this.opts.storage.get(this.catalogKey(id)), now);
374
650
  }
375
651
  catch (err) {
376
652
  this.opts.logger.warn(`[connecta] connector "${id}" catalog read failed: ${msg(err)}`);
@@ -382,6 +658,7 @@ export class Registry {
382
658
  if (persisted && persisted.staleUntil > now) {
383
659
  this.cache.set(id, {
384
660
  tools: persisted.tools,
661
+ fingerprint: persisted.fingerprint,
385
662
  exp: persisted.expiresAt,
386
663
  staleUntil: persisted.staleUntil,
387
664
  });
@@ -403,6 +680,45 @@ export class Registry {
403
680
  throw err;
404
681
  }
405
682
  }
683
+ /**
684
+ * Coalesce one connector's cold load inside one inbound request. The WeakMap
685
+ * neither roots the request scope nor lets its connector context escape into
686
+ * another request; settled entries are also removed eagerly.
687
+ *
688
+ * The first caller's `callOptions` govern the shared load: a later caller's
689
+ * signal or timeout neither cancels nor extends it, and an abort by the
690
+ * first caller rejects every coalesced caller. Within one request that is
691
+ * the deal being made — one fetch, one deadline.
692
+ */
693
+ async getTools(id, baseUrl, requestScope, callOptions = {}) {
694
+ const connector = this.connectors.get(id);
695
+ if (!connector)
696
+ throw new Error(`Unknown connector "${id}"`);
697
+ if (connector.staticTools)
698
+ return connector.staticTools;
699
+ if (!requestScope) {
700
+ return this.loadTools(id, baseUrl, requestScope, callOptions);
701
+ }
702
+ let loads = this.requestCatalogLoads.get(requestScope);
703
+ if (!loads) {
704
+ loads = new Map();
705
+ this.requestCatalogLoads.set(requestScope, loads);
706
+ }
707
+ const existing = loads.get(id);
708
+ if (existing)
709
+ return existing;
710
+ const loading = this.loadTools(id, baseUrl, requestScope, callOptions);
711
+ loads.set(id, loading);
712
+ try {
713
+ return await loading;
714
+ }
715
+ finally {
716
+ if (loads.get(id) === loading)
717
+ loads.delete(id);
718
+ if (loads.size === 0)
719
+ this.requestCatalogLoads.delete(requestScope);
720
+ }
721
+ }
406
722
  /** Return a cached catalog without performing storage or network I/O. */
407
723
  peekTools(id) {
408
724
  const connector = this.connectors.get(id);
@@ -512,7 +828,7 @@ export class Registry {
512
828
  if (this.persistToolCatalog) {
513
829
  void this.enqueueCatalogMutation(id, async () => {
514
830
  try {
515
- await this.opts.storage.delete(this.catalogKey(id));
831
+ await this.deleteCatalog(id);
516
832
  }
517
833
  catch (err) {
518
834
  this.opts.logger.warn(`[connecta] connector "${id}" catalog invalidation failed: ${msg(err)}`);
@@ -528,7 +844,7 @@ export class Registry {
528
844
  if (this.persistToolCatalog) {
529
845
  await this.enqueueCatalogMutation(id, async () => {
530
846
  try {
531
- await this.opts.storage.delete(this.catalogKey(id));
847
+ await this.deleteCatalog(id);
532
848
  }
533
849
  catch (err) {
534
850
  this.opts.logger.warn(`[connecta] connector "${id}" catalog invalidation failed: ${msg(err)}`);
@@ -655,6 +971,11 @@ export class ScopedRegistry {
655
971
  throw this.unknownConnector(id);
656
972
  return this.base.contextFor(id, baseUrl, requestScope, callOptions);
657
973
  }
974
+ admitCall(id, input) {
975
+ if (!this.visible(id))
976
+ return Promise.reject(this.unknownConnector(id));
977
+ return this.base.admitCall(id, input);
978
+ }
658
979
  /**
659
980
  * Stashed oversized results are bound to the scope that produced them: a
660
981
  * scoped session cannot page a result it could not have produced, and an id