@zackbart/connecta 0.7.5 → 0.7.7

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 (72) hide show
  1. package/CHANGELOG.md +108 -0
  2. package/README.md +1 -0
  3. package/dist/activity.d.ts +17 -0
  4. package/dist/activity.d.ts.map +1 -1
  5. package/dist/activity.js.map +1 -1
  6. package/dist/auth/clerk.d.ts.map +1 -1
  7. package/dist/auth/clerk.js +101 -0
  8. package/dist/auth/clerk.js.map +1 -1
  9. package/dist/auth/downstream-oauth.d.ts +57 -20
  10. package/dist/auth/downstream-oauth.d.ts.map +1 -1
  11. package/dist/auth/downstream-oauth.js +275 -67
  12. package/dist/auth/downstream-oauth.js.map +1 -1
  13. package/dist/connectors/remote-mcp.d.ts.map +1 -1
  14. package/dist/connectors/remote-mcp.js +165 -103
  15. package/dist/connectors/remote-mcp.js.map +1 -1
  16. package/dist/execute.d.ts.map +1 -1
  17. package/dist/execute.js +11 -0
  18. package/dist/execute.js.map +1 -1
  19. package/dist/executor-admission.d.ts +18 -1
  20. package/dist/executor-admission.d.ts.map +1 -1
  21. package/dist/executor-admission.js +82 -3
  22. package/dist/executor-admission.js.map +1 -1
  23. package/dist/executors/quickjs-protocol.d.ts +1 -0
  24. package/dist/executors/quickjs-protocol.d.ts.map +1 -1
  25. package/dist/executors/quickjs-protocol.js +7 -4
  26. package/dist/executors/quickjs-protocol.js.map +1 -1
  27. package/dist/executors/quickjs-runtime.d.ts.map +1 -1
  28. package/dist/executors/quickjs-runtime.js +22 -9
  29. package/dist/executors/quickjs-runtime.js.map +1 -1
  30. package/dist/executors/quickjs.d.ts.map +1 -1
  31. package/dist/executors/quickjs.js +44 -10
  32. package/dist/executors/quickjs.js.map +1 -1
  33. package/dist/index.d.ts +31 -2
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +38 -1
  36. package/dist/index.js.map +1 -1
  37. package/dist/registry.d.ts +12 -0
  38. package/dist/registry.d.ts.map +1 -1
  39. package/dist/registry.js +70 -15
  40. package/dist/registry.js.map +1 -1
  41. package/dist/server.d.ts +3 -0
  42. package/dist/server.d.ts.map +1 -1
  43. package/dist/server.js +413 -35
  44. package/dist/server.js.map +1 -1
  45. package/dist/storage/file.d.ts.map +1 -1
  46. package/dist/storage/file.js +8 -0
  47. package/dist/storage/file.js.map +1 -1
  48. package/dist/types.d.ts +47 -0
  49. package/dist/types.d.ts.map +1 -1
  50. package/dist/ui.d.ts +7 -1
  51. package/dist/ui.d.ts.map +1 -1
  52. package/dist/ui.js +118 -4
  53. package/dist/ui.js.map +1 -1
  54. package/dist/version.d.ts +1 -1
  55. package/dist/version.js +1 -1
  56. package/package.json +2 -1
  57. package/src/activity.ts +20 -0
  58. package/src/auth/clerk.ts +124 -0
  59. package/src/auth/downstream-oauth.ts +359 -68
  60. package/src/connectors/remote-mcp.ts +172 -104
  61. package/src/execute.ts +11 -0
  62. package/src/executor-admission.ts +90 -3
  63. package/src/executors/quickjs-protocol.ts +7 -4
  64. package/src/executors/quickjs-runtime.ts +31 -9
  65. package/src/executors/quickjs.ts +61 -12
  66. package/src/index.ts +90 -1
  67. package/src/registry.ts +79 -19
  68. package/src/server.ts +523 -45
  69. package/src/storage/file.ts +7 -0
  70. package/src/types.ts +50 -0
  71. package/src/ui.ts +124 -3
  72. package/src/version.ts +1 -1
@@ -14,6 +14,10 @@ import {
14
14
  type QuickJSDeferredPromise,
15
15
  } from "quickjs-emscripten";
16
16
  import type { ExecuteResult, ExecutorProvider } from "../types.js";
17
+ import {
18
+ MAX_QUICKJS_LOG_TRANSPORT_BYTES,
19
+ serializedBytes,
20
+ } from "./quickjs-protocol.js";
17
21
 
18
22
  export interface QuickJsRuntimeOptions {
19
23
  timeoutMs: number;
@@ -31,11 +35,16 @@ const MAX_LOG_ENTRIES = 200;
31
35
  // Cap each entry AND the cumulative buffer at capture time so untrusted guest
32
36
  // code can't retain unbounded host memory: a single `console.log("x".repeat(N))`
33
37
  // otherwise copies the whole N-char guest string into a host array we hold for
34
- // the entire execution. 8k chars/entry is generous for glue-code logging (the
35
- // join in execute.ts trims the assembled log to 4k anyway), and 256k total
36
- // keeps the worst case — 200 maxed-out entries — bounded well under a MiB.
38
+ // the entire execution. The separate transport-byte budget below accounts for
39
+ // JSON escaping and is what keeps the result envelope below its IPC ceiling.
37
40
  const MAX_LOG_ENTRY_CHARS = 8_000;
38
41
  const MAX_LOG_TOTAL_CHARS = 256_000;
42
+ const LOG_ENTRY_LIMIT_MARKER = `[log truncated after ${MAX_LOG_ENTRIES} entries]`;
43
+ const LOG_SIZE_LIMIT_MARKER = "[log truncated: size budget exceeded]";
44
+ const MAX_LOG_MARKER_TRANSPORT_BYTES = Math.max(
45
+ logTransportBytes(LOG_ENTRY_LIMIT_MARKER),
46
+ logTransportBytes(LOG_SIZE_LIMIT_MARKER),
47
+ );
39
48
  // Keep one host result below the range where quickjs-emscripten@0.32.0 can
40
49
  // nondeterministically fail during runtime disposal under concurrent load.
41
50
  // This still lets guest code reduce data more than ten times larger than
@@ -46,6 +55,12 @@ function msg(err: unknown): string {
46
55
  return err instanceof Error ? err.message : String(err);
47
56
  }
48
57
 
58
+ function logTransportBytes(entry: string): number {
59
+ // The log is encoded into ExecutionPayload, then that payloadJson string is
60
+ // encoded into ChildToParentMessage. Measure the units the IPC cap sees.
61
+ return serializedBytes(JSON.stringify(JSON.stringify(entry)));
62
+ }
63
+
49
64
  function exceedsUtf8ByteLimit(value: string, limit: number): boolean {
50
65
  let bytes = 0;
51
66
  for (let index = 0; index < value.length; index += 1) {
@@ -175,15 +190,16 @@ function installBridge(
175
190
  };
176
191
  armWake(bridge);
177
192
 
178
- // Running total of chars actually retained in `logs`; once the cumulative
179
- // budget is spent we push one marker and drop the rest, so a flood of large
180
- // entries can't grow the host array without bound.
193
+ // Keep both the in-memory character budget and the twice-JSON-encoded
194
+ // transport budget. Reserve enough byte budget for whichever truncation
195
+ // marker ends the stream.
181
196
  let logTotalChars = 0;
197
+ let logTotalTransportBytes = 0;
182
198
  let logBudgetSpent = false;
183
199
  const logFn = ctx.newFunction("__log", (h) => {
184
200
  if (logs.length >= MAX_LOG_ENTRIES) {
185
201
  if (logs.length === MAX_LOG_ENTRIES) {
186
- logs.push(`[log truncated after ${MAX_LOG_ENTRIES} entries]`);
202
+ logs.push(LOG_ENTRY_LIMIT_MARKER);
187
203
  }
188
204
  return;
189
205
  }
@@ -194,13 +210,19 @@ function installBridge(
194
210
  if (entry.length > MAX_LOG_ENTRY_CHARS) {
195
211
  entry = `${entry.slice(0, MAX_LOG_ENTRY_CHARS)}…[entry truncated]`;
196
212
  }
197
- if (logTotalChars + entry.length > MAX_LOG_TOTAL_CHARS) {
198
- logs.push("[log truncated: size budget exceeded]");
213
+ const entryTransportBytes = logTransportBytes(entry);
214
+ if (
215
+ logTotalChars + entry.length > MAX_LOG_TOTAL_CHARS ||
216
+ logTotalTransportBytes + entryTransportBytes >
217
+ MAX_QUICKJS_LOG_TRANSPORT_BYTES - MAX_LOG_MARKER_TRANSPORT_BYTES
218
+ ) {
219
+ logs.push(LOG_SIZE_LIMIT_MARKER);
199
220
  logBudgetSpent = true;
200
221
  return;
201
222
  }
202
223
  logs.push(entry);
203
224
  logTotalChars += entry.length;
225
+ logTotalTransportBytes += entryTransportBytes;
204
226
  });
205
227
  ctx.setProp(ctx.global, "__log", logFn);
206
228
  logFn.dispose();
@@ -2,7 +2,9 @@
2
2
  // child process: guest CPU, WASM aborts, and interpreter OOMs cannot block or
3
3
  // terminate the HTTP-serving process.
4
4
 
5
+ import { Buffer } from "node:buffer";
5
6
  import { fork, type ChildProcess } from "node:child_process";
7
+ import { existsSync } from "node:fs";
6
8
  import { fileURLToPath } from "node:url";
7
9
  import {
8
10
  AdmissionController,
@@ -10,6 +12,7 @@ import {
10
12
  } from "../executor-admission.js";
11
13
  import type {
12
14
  AdmittingExecutor,
15
+ AdmissionSnapshot,
13
16
  ExecuteResult,
14
17
  ExecutorLease,
15
18
  ExecutorProvider,
@@ -84,12 +87,33 @@ const DEFAULT_MAX_QUEUE_SIZE = 32;
84
87
  const DEFAULT_QUEUE_TIMEOUT_MS = 5_000;
85
88
  const CHILD_EXIT_GRACE_MS = 250;
86
89
  const CHILD_STARTUP_TIMEOUT_MS = 10_000;
90
+ const MAX_CHILD_STDERR_BYTES = 8 * 1024;
87
91
  const MAX_ERROR_CHARS = 4_000;
88
92
 
89
93
  function msg(err: unknown): string {
90
94
  return err instanceof Error ? err.message : String(err);
91
95
  }
92
96
 
97
+ function retainStderrTail(current: Buffer, chunk: Buffer | string): Buffer {
98
+ const incoming = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
99
+ if (incoming.length >= MAX_CHILD_STDERR_BYTES) {
100
+ return Buffer.from(
101
+ incoming.subarray(incoming.length - MAX_CHILD_STDERR_BYTES),
102
+ );
103
+ }
104
+ const combined = Buffer.concat([current, incoming]);
105
+ if (combined.length <= MAX_CHILD_STDERR_BYTES) return combined;
106
+ return Buffer.from(combined.subarray(combined.length - MAX_CHILD_STDERR_BYTES));
107
+ }
108
+
109
+ function childExitError(message: string, stderrTail: Buffer): Error {
110
+ if (stderrTail.length === 0) return new Error(message);
111
+ return new Error(
112
+ `${message}\nRecent child stderr (last ${stderrTail.length} bytes):\n` +
113
+ stderrTail.toString("utf8"),
114
+ );
115
+ }
116
+
93
117
  function positiveWhole(
94
118
  value: number | undefined,
95
119
  fallback: number,
@@ -256,6 +280,7 @@ class QuickJsChildPool implements AdmittingExecutor {
256
280
  let released = false;
257
281
  let executed = false;
258
282
  return {
283
+ waitMs: admission.waitMs,
259
284
  execute: async (code, providers) => {
260
285
  if (released) throw new Error("Executor lease was already released.");
261
286
  if (executed) throw new Error("Executor lease may execute only once.");
@@ -278,6 +303,10 @@ class QuickJsChildPool implements AdmittingExecutor {
278
303
  };
279
304
  }
280
305
 
306
+ admissionSnapshot(): AdmissionSnapshot {
307
+ return this.admission.snapshot();
308
+ }
309
+
281
310
  async execute(
282
311
  code: string,
283
312
  providers: ExecutorProvider[],
@@ -425,10 +454,26 @@ class QuickJsChildPool implements AdmittingExecutor {
425
454
  sourceMode ? "./quickjs-child.ts" : "./quickjs-child.js",
426
455
  import.meta.url,
427
456
  );
428
- const child = fork(fileURLToPath(childUrl), [], {
457
+ const childPath = fileURLToPath(childUrl);
458
+ if (!existsSync(childPath)) {
459
+ throw new Error(
460
+ `QuickJS child entry is missing at ${childPath}. ` +
461
+ "The @zackbart/connecta/quickjs subpath requires the package file " +
462
+ "layout on disk; externalize @zackbart/connecta (or at least " +
463
+ "@zackbart/connecta/quickjs) when bundling the server.",
464
+ );
465
+ }
466
+ const child = fork(childPath, [], {
429
467
  execArgv: sourceMode ? ["--import", "tsx"] : [],
430
- stdio: ["ignore", "ignore", "ignore", "ipc"],
468
+ stdio: ["ignore", "ignore", "pipe", "ipc"],
431
469
  });
470
+ let stderrTail: Buffer = Buffer.alloc(0);
471
+ child.stderr?.on("data", (chunk: Buffer | string) => {
472
+ stderrTail = retainStderrTail(stderrTail, chunk);
473
+ });
474
+ (
475
+ child.stderr as (NodeJS.ReadableStream & { unref?: () => void }) | null
476
+ )?.unref?.();
432
477
  slot.child = child;
433
478
  slot.ready = new Promise<void>((resolve, reject) => {
434
479
  slot.resolveReady = resolve;
@@ -453,26 +498,30 @@ class QuickJsChildPool implements AdmittingExecutor {
453
498
  this.rejectActive(slot, error);
454
499
  this.recycle(slot);
455
500
  });
456
- child.on("exit", (code, exitSignal) => {
501
+ // `close`, unlike `exit`, runs after the stdio streams have closed, so the
502
+ // diagnostic includes stderr bytes flushed immediately before a crash.
503
+ child.on("close", (code, exitSignal) => {
457
504
  const expected = slot.expectedExit === child;
458
505
  if (slot.expectedExit === child) slot.expectedExit = undefined;
506
+ const exitDescription = `${
507
+ exitSignal ? `signal ${exitSignal}` : `code ${String(code)}`
508
+ }`;
459
509
  this.rejectChildReady(
460
510
  slot,
461
511
  child,
462
- new Error(
463
- `QuickJS child exited before becoming ready` +
464
- ` (${exitSignal ? `signal ${exitSignal}` : `code ${String(code)}`}).`,
512
+ childExitError(
513
+ `QuickJS child exited before becoming ready (${exitDescription}).`,
514
+ stderrTail,
465
515
  ),
466
516
  );
467
517
  if (slot.child === child) slot.child = undefined;
468
518
  if (expected) return;
469
519
  this.recordCrash(slot);
470
- this.resolveActive(slot, {
471
- result: undefined,
472
- error:
473
- `QuickJS child exited unexpectedly` +
474
- ` (${exitSignal ? `signal ${exitSignal}` : `code ${String(code)}`}).`,
475
- });
520
+ const error = childExitError(
521
+ `QuickJS child exited unexpectedly (${exitDescription}).`,
522
+ stderrTail,
523
+ );
524
+ this.resolveActive(slot, { result: undefined, error: error.message });
476
525
  });
477
526
  child.unref();
478
527
  child.channel?.unref();
package/src/index.ts CHANGED
@@ -14,6 +14,11 @@ import {
14
14
  } from "./toolkits.js";
15
15
  import { memoryStorage } from "./storage/memory.js";
16
16
  import { CONNECTA_VERSION } from "./version.js";
17
+ import {
18
+ AdmissionController,
19
+ isAdmittingExecutor,
20
+ withExecutorAdmission,
21
+ } from "./executor-admission.js";
17
22
  import type { ActivityReadGate, ActivityStore } from "./activity.js";
18
23
  import type {
19
24
  CredentialCheckResult,
@@ -108,6 +113,35 @@ export interface ConnectaCallsConfig {
108
113
  maxResultBytes?: number;
109
114
  }
110
115
 
116
+ export interface AdmissionPoolConfig {
117
+ /** Simultaneous work admitted to this pool. */
118
+ concurrency?: number;
119
+ /** Callers allowed to wait behind active work. Set zero to fail fast. */
120
+ maxQueueSize?: number;
121
+ /** Maximum queue wait in milliseconds. */
122
+ queueTimeoutMs?: number;
123
+ /** Retry hint returned with overload failures, in milliseconds. */
124
+ retryAfterMs?: number;
125
+ }
126
+
127
+ /**
128
+ * Runtime-portable server-memory boundaries. `/health` and operator routes do
129
+ * not consume these permits, so they remain responsive during MCP saturation.
130
+ */
131
+ export interface ConnectaAdmissionConfig {
132
+ /**
133
+ * The `/mcp` request boundary. Defaults to 16 active, 32 queued, and a
134
+ * 5-second maximum wait.
135
+ */
136
+ requests?: AdmissionPoolConfig;
137
+ /**
138
+ * Fallback pool for an `executor` that does not implement its own `acquire`.
139
+ * Defaults to 2 active, 8 queued, and a 5-second maximum wait. Bounded
140
+ * executors (including `quickJsExecutor`) keep their own tighter pool.
141
+ */
142
+ code?: AdmissionPoolConfig;
143
+ }
144
+
111
145
  export interface ConnectaConfig {
112
146
  connectors: Connector[];
113
147
  /**
@@ -159,6 +193,8 @@ export interface ConnectaConfig {
159
193
  discovery?: ConnectaDiscoveryConfig;
160
194
  /** Deployment-wide call deadlines and result paging threshold. */
161
195
  calls?: ConnectaCallsConfig;
196
+ /** Bounded MCP and fallback code-mode admission. */
197
+ admission?: ConnectaAdmissionConfig;
162
198
  /** Optional browser UI and OAuth result-page labels. */
163
199
  branding?: ConnectaBranding;
164
200
  logger?: Logger;
@@ -222,6 +258,32 @@ export interface Connecta {
222
258
  close: () => Promise<void>;
223
259
  }
224
260
 
261
+ const REQUEST_ADMISSION_DEFAULTS = {
262
+ concurrency: 16,
263
+ maxQueueSize: 32,
264
+ queueTimeoutMs: 5_000,
265
+ retryAfterMs: 1_000,
266
+ } as const;
267
+
268
+ const CODE_ADMISSION_DEFAULTS = {
269
+ concurrency: 2,
270
+ maxQueueSize: 8,
271
+ queueTimeoutMs: 5_000,
272
+ retryAfterMs: 1_000,
273
+ } as const;
274
+
275
+ function admissionController(
276
+ options: AdmissionPoolConfig | undefined,
277
+ defaults: typeof REQUEST_ADMISSION_DEFAULTS | typeof CODE_ADMISSION_DEFAULTS,
278
+ ): AdmissionController {
279
+ return new AdmissionController({
280
+ concurrency: options?.concurrency ?? defaults.concurrency,
281
+ maxQueueSize: options?.maxQueueSize ?? defaults.maxQueueSize,
282
+ queueTimeoutMs: options?.queueTimeoutMs ?? defaults.queueTimeoutMs,
283
+ retryAfterMs: options?.retryAfterMs ?? defaults.retryAfterMs,
284
+ });
285
+ }
286
+
225
287
  function defaultLogger(): Logger {
226
288
  return {
227
289
  debug: (...a) => console.debug("[connecta]", ...a),
@@ -495,6 +557,26 @@ export function createConnecta(config: ConnectaConfig): Connecta {
495
557
  // with a 403 its client reports as a transport failure. Throw here instead.
496
558
  validateToolkitBindings(inboundAuth, toolkits);
497
559
  warnInsecureConfig(config, inboundAuth, toolkits, logger);
560
+ const requestAdmission = admissionController(
561
+ config.admission?.requests,
562
+ REQUEST_ADMISSION_DEFAULTS,
563
+ );
564
+ const configuredCodeAdmission = admissionController(
565
+ config.admission?.code,
566
+ CODE_ADMISSION_DEFAULTS,
567
+ );
568
+ let codeAdmission: AdmissionController | undefined;
569
+ let executor = config.executor;
570
+ if (executor && !isAdmittingExecutor(executor)) {
571
+ codeAdmission = configuredCodeAdmission;
572
+ executor = withExecutorAdmission(executor, codeAdmission);
573
+ } else if (executor && config.admission?.code) {
574
+ logger.warn(
575
+ "[connecta] admission.code is ignored because the configured executor " +
576
+ "implements acquire() and owns its admission pool; configure that " +
577
+ "executor's concurrency and queue options instead.",
578
+ );
579
+ }
498
580
  const handler = createFetchHandler({
499
581
  registry,
500
582
  auth: inboundAuth,
@@ -508,7 +590,8 @@ export function createConnecta(config: ConnectaConfig): Connecta {
508
590
  activity: config.activity?.store,
509
591
  activityReadGate: config.activity?.readGate,
510
592
  activityDeploymentId: config.activity?.deploymentId,
511
- executor: config.executor,
593
+ executor,
594
+ requestAdmission,
512
595
  defaultToolTimeoutMs: config.calls?.defaultTimeoutMs,
513
596
  probeTimeoutMs: config.discovery?.probeTimeoutMs,
514
597
  credentialVault,
@@ -555,6 +638,8 @@ export function createConnecta(config: ConnectaConfig): Connecta {
555
638
  },
556
639
  close: async () => {
557
640
  closePromise ??= Promise.resolve().then(async () => {
641
+ requestAdmission.close();
642
+ codeAdmission?.close();
558
643
  await config.executor?.close?.();
559
644
  });
560
645
  await closePromise;
@@ -614,6 +699,7 @@ export type {
614
699
  ConnectorStatus,
615
700
  CredentialTestResult,
616
701
  AdmittingExecutor,
702
+ AdmissionSnapshot,
617
703
  ExecuteResult,
618
704
  Executor,
619
705
  ExecutorLease,
@@ -633,8 +719,11 @@ export type {
633
719
  ActivityCallSource,
634
720
  ActivityOutcome,
635
721
  ActivityPage,
722
+ ActivityReadActor,
723
+ ActivityReadEvent,
636
724
  ActivityReader,
637
725
  ActivityReadGate,
726
+ ActivityReadPage,
638
727
  ActivitySink,
639
728
  ActivityStore,
640
729
  ToolCallActivityEvent,
package/src/registry.ts CHANGED
@@ -235,6 +235,10 @@ export class Registry implements RegistryView {
235
235
  private readonly connectors = new Map<string, Connector>();
236
236
  private readonly cache = new Map<string, CacheEntry>();
237
237
  private readonly invalidated = new Set<string>();
238
+ /** Per-connector epoch preventing a pre-invalidation refresh from publishing. */
239
+ private readonly catalogGenerations = new Map<string, number>();
240
+ /** Serialize persisted catalog set/delete operations within this isolate. */
241
+ private readonly catalogMutations = new Map<string, Promise<void>>();
238
242
  /** Deployment-wide observations — every call, whatever view made it. */
239
243
  private readonly health = new HealthLog();
240
244
  private readonly ttlMs: number;
@@ -459,6 +463,36 @@ export class Registry implements RegistryView {
459
463
  });
460
464
  }
461
465
 
466
+ private catalogGeneration(id: string): number {
467
+ return this.catalogGenerations.get(id) ?? 0;
468
+ }
469
+
470
+ private advanceCatalogGeneration(id: string): void {
471
+ this.catalogGenerations.set(id, this.catalogGeneration(id) + 1);
472
+ }
473
+
474
+ /**
475
+ * Keep this isolate's writes and invalidations ordered. Without the queue, an
476
+ * old refresh can finish its storage.set after a credential change deletes
477
+ * the catalog and resurrect the pre-change listing.
478
+ */
479
+ private enqueueCatalogMutation(
480
+ id: string,
481
+ operation: () => Promise<void>,
482
+ ): Promise<void> {
483
+ const previous = this.catalogMutations.get(id) ?? Promise.resolve();
484
+ const next = previous.catch(() => {}).then(operation);
485
+ this.catalogMutations.set(id, next);
486
+ void next
487
+ .finally(() => {
488
+ if (this.catalogMutations.get(id) === next) {
489
+ this.catalogMutations.delete(id);
490
+ }
491
+ })
492
+ .catch(() => {});
493
+ return next;
494
+ }
495
+
462
496
  /** Force a live listTools refresh and replace both catalog cache layers. */
463
497
  async refreshTools(
464
498
  id: string,
@@ -468,11 +502,16 @@ export class Registry implements RegistryView {
468
502
  ): Promise<ToolDef[]> {
469
503
  const connector = this.connectors.get(id);
470
504
  if (!connector) throw new Error(`Unknown connector "${id}"`);
505
+ const generation = this.catalogGeneration(id);
471
506
  const tools = connector.staticTools
472
507
  ? connector.staticTools
473
508
  : await connector.listTools(
474
509
  this.contextFor(id, baseUrl, requestScope, callOptions),
475
510
  );
511
+ // The caller that began this refresh may still use its result, but a
512
+ // credential/OAuth change that landed while listTools was in flight means
513
+ // the listing must not enter either shared cache layer.
514
+ if (generation !== this.catalogGeneration(id)) return tools;
476
515
  const now = Date.now();
477
516
  const previous = this.cache.get(id);
478
517
  const catalogChanged =
@@ -487,13 +526,16 @@ export class Registry implements RegistryView {
487
526
  });
488
527
  this.invalidated.delete(id);
489
528
  if (shouldPersist) {
490
- try {
491
- await this.storeCatalog(id, tools);
492
- } catch (err) {
493
- this.opts.logger.warn(
494
- `[connecta] connector "${id}" catalog persistence failed: ${msg(err)}`,
495
- );
496
- }
529
+ await this.enqueueCatalogMutation(id, async () => {
530
+ if (generation !== this.catalogGeneration(id)) return;
531
+ try {
532
+ await this.storeCatalog(id, tools);
533
+ } catch (err) {
534
+ this.opts.logger.warn(
535
+ `[connecta] connector "${id}" catalog persistence failed: ${msg(err)}`,
536
+ );
537
+ }
538
+ });
497
539
  }
498
540
  return tools;
499
541
  }
@@ -510,11 +552,13 @@ export class Registry implements RegistryView {
510
552
  if (connector.staticTools) return connector.staticTools;
511
553
 
512
554
  const now = Date.now();
555
+ const requestGeneration = this.catalogGeneration(id);
513
556
  const hit = this.cache.get(id);
514
557
  if (hit && hit.exp > now) return hit.tools;
515
558
 
516
559
  let stale = hit && hit.staleUntil > now ? hit.tools : undefined;
517
560
  if (this.persistToolCatalog && !this.invalidated.has(id)) {
561
+ const generation = this.catalogGeneration(id);
518
562
  let persisted: PersistedCatalog | null = null;
519
563
  try {
520
564
  persisted = this.validCatalog(
@@ -525,6 +569,10 @@ export class Registry implements RegistryView {
525
569
  `[connecta] connector "${id}" catalog read failed: ${msg(err)}`,
526
570
  );
527
571
  }
572
+ if (generation !== this.catalogGeneration(id)) {
573
+ persisted = null;
574
+ stale = undefined;
575
+ }
528
576
  if (persisted && persisted.staleUntil > now) {
529
577
  this.cache.set(id, {
530
578
  tools: persisted.tools,
@@ -539,7 +587,11 @@ export class Registry implements RegistryView {
539
587
  try {
540
588
  return await this.refreshTools(id, baseUrl, requestScope, callOptions);
541
589
  } catch (err) {
542
- if (stale) {
590
+ if (
591
+ stale &&
592
+ requestGeneration === this.catalogGeneration(id) &&
593
+ !this.invalidated.has(id)
594
+ ) {
543
595
  this.opts.logger.warn(
544
596
  `[connecta] connector "${id}" catalog refresh failed; serving stale catalog: ${msg(err)}`,
545
597
  );
@@ -675,29 +727,37 @@ export class Registry implements RegistryView {
675
727
 
676
728
  /** Drop a connector's cached tool list (e.g. after auth completes). */
677
729
  invalidate(id: string): void {
730
+ this.advanceCatalogGeneration(id);
678
731
  this.cache.delete(id);
679
732
  this.invalidated.add(id);
680
733
  if (this.persistToolCatalog) {
681
- void this.opts.storage.delete(this.catalogKey(id)).catch((err) => {
682
- this.opts.logger.warn(
683
- `[connecta] connector "${id}" catalog invalidation failed: ${msg(err)}`,
684
- );
734
+ void this.enqueueCatalogMutation(id, async () => {
735
+ try {
736
+ await this.opts.storage.delete(this.catalogKey(id));
737
+ } catch (err) {
738
+ this.opts.logger.warn(
739
+ `[connecta] connector "${id}" catalog invalidation failed: ${msg(err)}`,
740
+ );
741
+ }
685
742
  });
686
743
  }
687
744
  }
688
745
 
689
746
  /** Drop both in-memory and persisted tool catalogs. */
690
747
  async invalidateStored(id: string): Promise<void> {
748
+ this.advanceCatalogGeneration(id);
691
749
  this.cache.delete(id);
692
750
  this.invalidated.add(id);
693
751
  if (this.persistToolCatalog) {
694
- try {
695
- await this.opts.storage.delete(this.catalogKey(id));
696
- } catch (err) {
697
- this.opts.logger.warn(
698
- `[connecta] connector "${id}" catalog invalidation failed: ${msg(err)}`,
699
- );
700
- }
752
+ await this.enqueueCatalogMutation(id, async () => {
753
+ try {
754
+ await this.opts.storage.delete(this.catalogKey(id));
755
+ } catch (err) {
756
+ this.opts.logger.warn(
757
+ `[connecta] connector "${id}" catalog invalidation failed: ${msg(err)}`,
758
+ );
759
+ }
760
+ });
701
761
  }
702
762
  }
703
763
  }