@zackbart/connecta 0.7.6 → 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 (47) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/dist/activity.d.ts +17 -0
  3. package/dist/activity.d.ts.map +1 -1
  4. package/dist/activity.js.map +1 -1
  5. package/dist/auth/clerk.d.ts.map +1 -1
  6. package/dist/auth/clerk.js +101 -0
  7. package/dist/auth/clerk.js.map +1 -1
  8. package/dist/auth/downstream-oauth.d.ts +57 -20
  9. package/dist/auth/downstream-oauth.d.ts.map +1 -1
  10. package/dist/auth/downstream-oauth.js +275 -67
  11. package/dist/auth/downstream-oauth.js.map +1 -1
  12. package/dist/connectors/remote-mcp.d.ts.map +1 -1
  13. package/dist/connectors/remote-mcp.js +165 -103
  14. package/dist/connectors/remote-mcp.js.map +1 -1
  15. package/dist/index.d.ts +1 -1
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js.map +1 -1
  18. package/dist/registry.d.ts +12 -0
  19. package/dist/registry.d.ts.map +1 -1
  20. package/dist/registry.js +70 -15
  21. package/dist/registry.js.map +1 -1
  22. package/dist/server.d.ts.map +1 -1
  23. package/dist/server.js +238 -19
  24. package/dist/server.js.map +1 -1
  25. package/dist/storage/file.d.ts.map +1 -1
  26. package/dist/storage/file.js +8 -0
  27. package/dist/storage/file.js.map +1 -1
  28. package/dist/types.d.ts +21 -0
  29. package/dist/types.d.ts.map +1 -1
  30. package/dist/ui.d.ts +7 -1
  31. package/dist/ui.d.ts.map +1 -1
  32. package/dist/ui.js +118 -4
  33. package/dist/ui.js.map +1 -1
  34. package/dist/version.d.ts +1 -1
  35. package/dist/version.js +1 -1
  36. package/package.json +1 -1
  37. package/src/activity.ts +20 -0
  38. package/src/auth/clerk.ts +124 -0
  39. package/src/auth/downstream-oauth.ts +359 -68
  40. package/src/connectors/remote-mcp.ts +172 -104
  41. package/src/index.ts +3 -0
  42. package/src/registry.ts +79 -19
  43. package/src/server.ts +306 -16
  44. package/src/storage/file.ts +7 -0
  45. package/src/types.ts +23 -0
  46. package/src/ui.ts +124 -3
  47. package/src/version.ts +1 -1
@@ -393,7 +393,7 @@ interface ConnectionState {
393
393
  connecting: Promise<void> | null;
394
394
  authRequired: boolean;
395
395
  provider: KvOAuthProvider | null;
396
- connectedGeneration: number | null;
396
+ connectedGeneration: string | null;
397
397
  /**
398
398
  * One-way latch: set by closeScope and never cleared, so neither a late
399
399
  * connect nor a `reset()` can cache a client into a scope that is already
@@ -451,6 +451,16 @@ export function remoteMcp(id: string, opts: RemoteMcpOptions): Connector {
451
451
  { cause },
452
452
  );
453
453
 
454
+ class OperatorDisconnectedError extends ConnectorCallError {
455
+ constructor() {
456
+ super(
457
+ "auth_required",
458
+ `Connector "${id}" was disconnected by an operator — explicitly start authorization to reconnect it.`,
459
+ );
460
+ }
461
+ }
462
+ const operatorDisconnectedError = () => new OperatorDisconnectedError();
463
+
454
464
  const scopeEndedError = () =>
455
465
  new Error(`Connector "${id}" scope ended during connection.`);
456
466
 
@@ -518,16 +528,23 @@ export function remoteMcp(id: string, opts: RemoteMcpOptions): Connector {
518
528
  return state.provider;
519
529
  };
520
530
 
531
+ const newProvider = (ctx: ConnectorContext): KvOAuthProvider =>
532
+ new KvOAuthProvider(
533
+ id,
534
+ ctx.storage,
535
+ `${ctx.baseUrl}/oauth/callback/${id}`,
536
+ );
537
+
521
538
  const buildTransport = (
522
539
  ctx: ConnectorContext,
523
- state: ConnectionState,
540
+ provider: KvOAuthProvider | null,
524
541
  ): Transport => {
525
542
  if (opts._transportFactory) return opts._transportFactory(ctx);
526
543
  const url = new URL(opts.url);
527
544
  const guardedFetch = redirectSafeFetch(id, opts.redirects);
528
545
  if (opts.auth?.type === "oauth") {
529
546
  return new StreamableHTTPClientTransport(url, {
530
- authProvider: getProvider(ctx, state),
547
+ authProvider: provider ?? newProvider(ctx),
531
548
  fetch: guardedFetch,
532
549
  });
533
550
  }
@@ -547,6 +564,7 @@ export function remoteMcp(id: string, opts: RemoteMcpOptions): Connector {
547
564
  state.transport = null;
548
565
  state.connecting = null;
549
566
  state.authRequired = false;
567
+ state.provider = null;
550
568
  state.connectedGeneration = null;
551
569
  // `closed` is deliberately not cleared — see ConnectionState.
552
570
  };
@@ -563,97 +581,160 @@ export function remoteMcp(id: string, opts: RemoteMcpOptions): Connector {
563
581
  new UnauthorizedError("Downstream authorization is no longer valid."),
564
582
  );
565
583
  }
584
+ // Read the OAuth epoch before trusting either a cached client or starting a
585
+ // transport. A disconnected epoch is a durable operator instruction, not
586
+ // merely the absence of credentials: passive status/tool probes must not
587
+ // turn it back into a pending consent flow.
588
+ let oauthGeneration: string | undefined;
589
+ if (isOauth && (state.client || state.connecting)) {
590
+ const provider = getProvider(ctx, state);
591
+ oauthGeneration = await provider.generation();
592
+ if (provider.isOperatorDisconnectedGeneration(oauthGeneration)) {
593
+ const connecting = state.connecting;
594
+ const client = state.client;
595
+ const transport = state.transport;
596
+ reset(state);
597
+ void connecting?.catch(() => {});
598
+ try {
599
+ if (client) await client.close();
600
+ else await transport?.close();
601
+ } catch {
602
+ // The disconnected epoch is authoritative even if local close fails.
603
+ }
604
+ throw operatorDisconnectedError();
605
+ }
606
+ }
566
607
  // Cross-isolate force re-auth: another isolate bumped the KV generation and
567
608
  // wiped credentials. This request's cached client still speaks the old
568
609
  // token — drop it so the next connect runs against current state.
569
- if (state.client && isOauth && state.connectedGeneration !== null) {
570
- const generation = await getProvider(ctx, state).generation();
610
+ if (state.client && oauthGeneration !== undefined && state.connectedGeneration !== null) {
571
611
  if (state.closed) throw scopeEndedError();
572
- if (generation !== state.connectedGeneration) {
612
+ if (oauthGeneration !== state.connectedGeneration) {
573
613
  reset(state);
574
614
  }
575
615
  }
576
616
  if (state.closed) throw scopeEndedError();
577
617
  if (state.client) return;
578
- state.connecting ??= (async () => {
579
- const provider = isOauth ? getProvider(ctx, state) : null;
580
- const genAtStart = provider ? await provider.generation() : 0;
581
- if (state.closed) throw scopeEndedError();
582
- // Stamp the provider so any saveTokens/saveClientInformation the SDK fires
583
- // during this connect (code exchange, DCR) or during a later refresh on
584
- // the resulting client — is dropped if a concurrent force bumps the
585
- // generation past this point, instead of re-persisting wiped credentials.
586
- provider?.captureGeneration(genAtStart);
587
- // The SDK defaults to AJV, which compiles every advertised outputSchema
588
- // with `new Function`. Cloudflare Workers prohibit dynamic code
589
- // generation, so a remote such as Stripe fails during tools/list unless
590
- // the SDK's edge-safe validator is selected explicitly.
591
- const c = new Client(
592
- { name: "connecta", version: CONNECTA_VERSION },
593
- { jsonSchemaValidator: new CfWorkerJsonSchemaValidator() },
594
- );
595
- const t = buildTransport(ctx, state);
596
- state.transport = t;
597
- try {
598
- await c.connect(t);
599
- // A probe deadline can end its scope while connect is still in flight.
600
- // The transport is closed immediately by closeScope; if connect wins
601
- // that race anyway, close the resulting client rather than resurrecting
602
- // a session in the detached state object.
603
- if (state.closed) {
618
+ if (!state.connecting) {
619
+ let attempt!: Promise<void>;
620
+ attempt = (async () => {
621
+ const ownsAttempt = () =>
622
+ state.connecting === attempt && !state.closed;
623
+ const abandon = async (owner: Client | Transport) => {
604
624
  try {
605
- await c.close();
625
+ await owner.close();
606
626
  } catch {
607
- // The scope has already been discarded either way.
627
+ // The attempt is detached either way.
608
628
  }
609
629
  throw scopeEndedError();
630
+ };
631
+ // Let the assignment immediately below this async IIFE publish
632
+ // `state.connecting = attempt` before ownership is checked. OAuth's
633
+ // generation read naturally yields; unauthenticated transports do not.
634
+ await Promise.resolve();
635
+ // A provider belongs to exactly one connect attempt. A force reset can
636
+ // abandon that attempt while its transport still holds the provider;
637
+ // the replacement must never mutate the abandoned provider's epoch.
638
+ const provider = isOauth ? newProvider(ctx) : null;
639
+ const genAtStart = provider ? await provider.generation() : "";
640
+ if (!ownsAttempt()) throw scopeEndedError();
641
+ if (provider?.isOperatorDisconnectedGeneration(genAtStart)) {
642
+ throw operatorDisconnectedError();
610
643
  }
611
- // A force re-auth that landed WHILE we were connecting wiped the creds
612
- // this client just bound to. Discard it rather than cache a connection
613
- // that resurrects the wiped-and-reauthorized connector from a stale
614
- // isolate. Surfaces as auth_required the connector genuinely needs
615
- // re-consent now.
616
- if (provider) {
617
- const generation = await provider.generation();
618
- // closeScope can land while the generation read is pending, after
619
- // connect succeeded but before this client is cached. Discard the
620
- // client on that side of the await too.
621
- if (state.closed) {
622
- try {
623
- await c.close();
624
- } catch {
625
- // The scope has already been discarded either way.
644
+ provider?.captureGeneration(genAtStart);
645
+ // The SDK defaults to AJV, which compiles every advertised outputSchema
646
+ // with `new Function`. Cloudflare Workers prohibit dynamic code
647
+ // generation, so a remote such as Stripe fails during tools/list unless
648
+ // the SDK's edge-safe validator is selected explicitly.
649
+ const c = new Client(
650
+ { name: "connecta", version: CONNECTA_VERSION },
651
+ { jsonSchemaValidator: new CfWorkerJsonSchemaValidator() },
652
+ );
653
+ const t = buildTransport(ctx, provider);
654
+ if (!ownsAttempt()) await abandon(t);
655
+ state.transport = t;
656
+ try {
657
+ await c.connect(t);
658
+ // A probe deadline can end its scope while connect is still in flight.
659
+ // The transport is closed immediately by closeScope; if connect wins
660
+ // that race anyway, close the resulting client rather than
661
+ // resurrecting a session in the detached state object.
662
+ if (!ownsAttempt()) await abandon(c);
663
+ // A force re-auth that landed WHILE we were connecting wiped the
664
+ // credentials this client just bound to. Discard it rather than
665
+ // cache a stale-isolate connection.
666
+ if (provider) {
667
+ const generation = await provider.generation();
668
+ // closeScope can land while the generation read is pending, after
669
+ // connect succeeded but before this client is cached. Discard the
670
+ // client on that side of the await too.
671
+ if (!ownsAttempt()) await abandon(c);
672
+ if (generation !== genAtStart) {
673
+ try {
674
+ await c.close();
675
+ } catch {
676
+ // discarding either way
677
+ }
678
+ throw new UnauthorizedError(
679
+ "Connector was re-authorized during connect; reconnect required.",
680
+ );
626
681
  }
627
- throw scopeEndedError();
628
682
  }
629
- if (generation !== genAtStart) {
630
- try {
631
- await c.close();
632
- } catch {
633
- // discarding either way
634
- }
635
- throw new UnauthorizedError(
636
- "Connector was re-authorized during connect; reconnect required.",
637
- );
683
+ if (!ownsAttempt()) await abandon(c);
684
+ state.client = c;
685
+ state.connectedGeneration = genAtStart;
686
+ state.authRequired = false;
687
+ } catch (err) {
688
+ // Only a real 401/UnauthorizedError means auth is the problem — a
689
+ // network error on an oauth connector must surface as "error", not
690
+ // "auth_required".
691
+ if (err instanceof UnauthorizedError && ownsAttempt()) {
692
+ state.authRequired = true;
638
693
  }
694
+ if (err instanceof UnauthorizedError) {
695
+ throw authRequiredError(err);
696
+ }
697
+ throw err;
698
+ } finally {
699
+ // Force reset may have abandoned this attempt and installed a new one
700
+ // in the same request scope. An old completion must not erase the new
701
+ // promise and allow a third concurrent connect.
702
+ if (state.connecting === attempt) state.connecting = null;
639
703
  }
640
- state.client = c;
641
- state.connectedGeneration = genAtStart;
642
- state.authRequired = false;
643
- } catch (err) {
644
- // Only a real 401/UnauthorizedError means auth is the problem — a
645
- // network error on an oauth connector must surface as "error", not
646
- // "auth_required".
647
- if (err instanceof UnauthorizedError) {
648
- state.authRequired = true;
649
- throw authRequiredError(err);
704
+ })();
705
+ state.connecting = attempt;
706
+ }
707
+ return state.connecting;
708
+ };
709
+
710
+ const disconnectAuthorization = async (
711
+ ctx: ConnectorContext,
712
+ state: ConnectionState,
713
+ operatorDisconnected = false,
714
+ ): Promise<void> => {
715
+ const provider = getProvider(ctx, state);
716
+ // Publish the replacement epoch before waiting on or closing any
717
+ // request-local transport. A hung connect therefore cannot delay the
718
+ // fence, and every late OAuth write stays in the older namespace.
719
+ const connecting = state.connecting;
720
+ try {
721
+ await provider.resetAuthorization(operatorDisconnected);
722
+ } finally {
723
+ // Consume the abandoned connect and close whichever half of the
724
+ // client/transport exists. Reset is unconditional because KV may already
725
+ // be fenced behind a newer epoch after a cleanup error.
726
+ void connecting?.catch(() => {});
727
+ try {
728
+ if (state.client) {
729
+ await state.client.close();
730
+ } else {
731
+ await state.transport?.close();
650
732
  }
651
- throw err;
652
- } finally {
653
- state.connecting = null;
733
+ } catch {
734
+ // best-effort; the state is discarded either way
654
735
  }
655
- })();
656
- return state.connecting;
736
+ reset(state);
737
+ }
657
738
  };
658
739
 
659
740
  const connector: Connector = {
@@ -760,7 +841,7 @@ export function remoteMcp(id: string, opts: RemoteMcpOptions): Connector {
760
841
  // Classify that exactly like connect-time and call-time authorization
761
842
  // failures, and latch it for the rest of this request scope.
762
843
  if (err instanceof UnauthorizedError) {
763
- state.authRequired = true;
844
+ if (state.client === client) state.authRequired = true;
764
845
  throw authRequiredError(err);
765
846
  }
766
847
  throw err;
@@ -788,8 +869,9 @@ export function remoteMcp(id: string, opts: RemoteMcpOptions): Connector {
788
869
  async callTool(name, args, ctx) {
789
870
  const state = stateFor(ctx);
790
871
  await ensureConnected(ctx, state);
872
+ const client = state.client!;
791
873
  try {
792
- return await state.client!.callTool(
874
+ return await client.callTool(
793
875
  {
794
876
  name,
795
877
  arguments: (args ?? {}) as Record<string, unknown>,
@@ -805,7 +887,7 @@ export function remoteMcp(id: string, opts: RemoteMcpOptions): Connector {
805
887
  } catch (err) {
806
888
  // A grant revoked after connect surfaces here, not in ensureConnected.
807
889
  if (err instanceof UnauthorizedError) {
808
- state.authRequired = true;
890
+ if (state.client === client) state.authRequired = true;
809
891
  throw authRequiredError(err);
810
892
  }
811
893
  throw err;
@@ -860,6 +942,9 @@ export function remoteMcp(id: string, opts: RemoteMcpOptions): Connector {
860
942
  message: "Authorization required — open the URL to connect.",
861
943
  };
862
944
  }
945
+ if (err instanceof OperatorDisconnectedError) {
946
+ return { state: "auth_required", message: err.message };
947
+ }
863
948
  return { state: "error", message: msg(err) };
864
949
  }
865
950
  },
@@ -867,12 +952,11 @@ export function remoteMcp(id: string, opts: RemoteMcpOptions): Connector {
867
952
  async finishAuth(code, ctx) {
868
953
  const state = stateFor(ctx);
869
954
  const provider = getProvider(ctx, state);
870
- // The provider here may carry no captured generation, so its token saves
871
- // fail open. That is safe only because generation advances solely via the
872
- // force path, which always wipes oauth:state so verifyState rejects any
873
- // pre-force callback before this runs. Keep those two facts coupled.
955
+ // verifyState ran on this request-scoped provider first and captured the
956
+ // pending flow's generation. If force reset races the exchange, any late
957
+ // token write remains tagged with that older generation and is unreadable.
874
958
  const t = (state.transport ??
875
- buildTransport(ctx, state)) as StreamableHTTPClientTransport;
959
+ buildTransport(ctx, provider)) as StreamableHTTPClientTransport;
876
960
  await t.finishAuth(code);
877
961
  await provider.clearPending();
878
962
  // Reset so the next use reconnects with the freshly stored tokens.
@@ -895,31 +979,15 @@ export function remoteMcp(id: string, opts: RemoteMcpOptions): Connector {
895
979
  return getProvider(ctx, state).verifyState(oauthState);
896
980
  };
897
981
 
982
+ connector.disconnectAuth = async (ctx) => {
983
+ await disconnectAuthorization(ctx, stateFor(ctx), true);
984
+ };
985
+
898
986
  connector.startAuth = async (ctx, startOpts) => {
899
987
  const state = stateFor(ctx);
900
988
  const p = getProvider(ctx, state);
901
- if (startOpts?.force) {
902
- // Wipe stored credentials and drop the live connection so the next
903
- // connect attempt runs the flow from scratch (DCR + PKCE + consent).
904
- // Fence the in-flight connect first: a late-completing attempt must not
905
- // resurrect the credentials we're about to wipe, nor leave `client` set
906
- // (which would make ensureConnected below report already-authorized and
907
- // silently defeat force).
908
- await state.connecting?.catch(() => {});
909
- try {
910
- await state.client?.close();
911
- } catch {
912
- // best-effort; the connection is being discarded either way
913
- }
914
- // Bump the shared generation FIRST so any other isolate — one mid-
915
- // connect, or on its next tool call — sees the advance and drops its
916
- // client instead of keeping the token we're about to revoke.
917
- await p.bumpGeneration();
918
- // Wipe KV before dropping in-memory state so nothing racing back in can
919
- // write tokens over a half-cleared slot.
920
- await p.invalidateCredentials("all");
921
- await p.clearPending();
922
- reset(state);
989
+ if (startOpts?.force || (await p.operatorDisconnected())) {
990
+ await disconnectAuthorization(ctx, state);
923
991
  } else {
924
992
  // A consent URL already outstanding? Re-issue it rather than re-running
925
993
  // the SDK flow, which would overwrite the PKCE verifier and invalidate
package/src/index.ts CHANGED
@@ -719,8 +719,11 @@ export type {
719
719
  ActivityCallSource,
720
720
  ActivityOutcome,
721
721
  ActivityPage,
722
+ ActivityReadActor,
723
+ ActivityReadEvent,
722
724
  ActivityReader,
723
725
  ActivityReadGate,
726
+ ActivityReadPage,
724
727
  ActivitySink,
725
728
  ActivityStore,
726
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
  }