@zackbart/connecta 0.41.0 → 0.42.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 (148) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/bin/connecta.mjs +26 -17
  3. package/bin/meta-tool-names.d.mts +3 -1
  4. package/bin/meta-tool-names.mjs +4 -1
  5. package/bin/version.mjs +1 -1
  6. package/dist/activity.d.ts +2 -1
  7. package/dist/activity.js +5 -5
  8. package/dist/advertised-schema.js +15 -0
  9. package/dist/agent-routes.js +31 -5
  10. package/dist/auth/downstream-oauth/refresh-coordinator.js +4 -1
  11. package/dist/auth/downstream-oauth.js +3 -0
  12. package/dist/auth/static-oauth.js +26 -18
  13. package/dist/auth-elicitation.js +118 -61
  14. package/dist/authorization-handoff.js +36 -3
  15. package/dist/catalog-cache.js +292 -24
  16. package/dist/catalog-freshness.js +12 -0
  17. package/dist/catalog-service.js +373 -70
  18. package/dist/catalog.js +147 -58
  19. package/dist/config-defaults.d.ts +2 -1
  20. package/dist/config-defaults.js +4 -1
  21. package/dist/config.d.ts +43 -17
  22. package/dist/config.js +26 -10
  23. package/dist/connectors/remote-mcp/catalog.js +52 -28
  24. package/dist/connectors/remote-mcp/connection.js +62 -5
  25. package/dist/connectors/remote-mcp.js +10 -1
  26. package/dist/describe-config.d.ts +3 -2
  27. package/dist/discovery-health.js +26 -11
  28. package/dist/errors.d.ts +52 -3
  29. package/dist/errors.js +86 -3
  30. package/dist/execute.js +476 -200
  31. package/dist/executor-admission.js +2 -0
  32. package/dist/executor-contract.js +6 -0
  33. package/dist/executor-result.js +2 -2
  34. package/dist/executors/quickjs-child.js +13 -4
  35. package/dist/executors/quickjs-runtime.d.ts +2 -0
  36. package/dist/executors/quickjs-runtime.js +36 -19
  37. package/dist/executors/quickjs.js +23 -6
  38. package/dist/guest-functions.js +5 -4
  39. package/dist/guest-runtime.js +37 -1
  40. package/dist/guest-types.d.ts +18 -0
  41. package/dist/http/client.js +7 -2
  42. package/dist/http/errors.js +9 -4
  43. package/dist/http/fields.js +17 -5
  44. package/dist/http/hatch.js +9 -3
  45. package/dist/http/paging.js +1 -1
  46. package/dist/http/provider.js +2 -6
  47. package/dist/http/vendor.js +6 -1
  48. package/dist/index.d.ts +1 -1
  49. package/dist/index.js +5 -0
  50. package/dist/invocation.js +98 -34
  51. package/dist/meta-output.js +24 -18
  52. package/dist/meta-tool-names.d.ts +1 -1
  53. package/dist/meta-tool-names.js +1 -1
  54. package/dist/meta-tools.js +259 -60
  55. package/dist/native-recovery.js +4 -1
  56. package/dist/native-tools.js +166 -51
  57. package/dist/node.js +3 -1
  58. package/dist/oauth-handoff.js +53 -43
  59. package/dist/operation-activity.js +2 -1
  60. package/dist/operation-vocabulary.js +6 -0
  61. package/dist/operator-record.d.ts +1 -1
  62. package/dist/operator-record.js +1 -0
  63. package/dist/operator-ui/generated.js +2 -2
  64. package/dist/program-result.js +2 -0
  65. package/dist/program-source.js +1 -1
  66. package/dist/program-writes.js +112 -19
  67. package/dist/provider.d.ts +0 -13
  68. package/dist/provider.js +0 -3
  69. package/dist/providers/_shared/google/kit.js +2 -2
  70. package/dist/providers/breeze/api.js +2 -2
  71. package/dist/providers/breeze/skill.generated.js +1 -1
  72. package/dist/providers/ccb/api.js +2 -2
  73. package/dist/providers/ccb/skill.generated.js +1 -1
  74. package/dist/providers/cloudflare/api.js +2 -2
  75. package/dist/providers/cloudflare/index.js +2 -0
  76. package/dist/providers/cloudflare/skill.generated.js +1 -1
  77. package/dist/providers/docs/skill.generated.js +1 -1
  78. package/dist/providers/drive/skill.generated.js +1 -1
  79. package/dist/providers/forms/index.js +1 -0
  80. package/dist/providers/forms/skill.generated.js +1 -1
  81. package/dist/providers/github/api.js +98 -6
  82. package/dist/providers/github/index.js +7 -2
  83. package/dist/providers/github/lib/auth.d.ts +3 -1
  84. package/dist/providers/github/lib/auth.js +43 -12
  85. package/dist/providers/github/lib/discovery.js +11 -1
  86. package/dist/providers/github/lib/operations.js +8 -0
  87. package/dist/providers/github/safety.js +39 -0
  88. package/dist/providers/github/skill.generated.js +1 -1
  89. package/dist/providers/github/tools.js +12 -5
  90. package/dist/providers/gmail/index.js +1 -0
  91. package/dist/providers/gmail/skill.generated.js +1 -1
  92. package/dist/providers/granola/skill.generated.js +1 -1
  93. package/dist/providers/overflow/index.js +2 -0
  94. package/dist/providers/overflow/skill.generated.js +1 -1
  95. package/dist/providers/planning-center/index.js +11 -5
  96. package/dist/providers/planning-center/lib/constants.d.ts +1 -1
  97. package/dist/providers/planning-center/skill.generated.js +1 -1
  98. package/dist/providers/quickbooks/index.js +2 -5
  99. package/dist/providers/quickbooks/skill.generated.js +1 -1
  100. package/dist/providers/sheets/index.js +1 -0
  101. package/dist/providers/sheets/skill.generated.js +1 -1
  102. package/dist/providers/slides/index.js +2 -2
  103. package/dist/providers/slides/skill.generated.js +1 -1
  104. package/dist/providers/stripe/skill.generated.js +1 -1
  105. package/dist/providers/tithely/index.js +1 -0
  106. package/dist/providers/tithely/skill.generated.js +1 -1
  107. package/dist/providers/vercel/api.js +2 -2
  108. package/dist/providers/vercel/skill.generated.js +1 -1
  109. package/dist/registry.d.ts +36 -4
  110. package/dist/registry.js +127 -27
  111. package/dist/result-preview.js +210 -0
  112. package/dist/result-seal.js +168 -0
  113. package/dist/route-names.js +13 -4
  114. package/dist/routes/connect.js +21 -13
  115. package/dist/routes/mcp.js +55 -26
  116. package/dist/routes/shared.d.ts +6 -0
  117. package/dist/runtime/call.js +15 -10
  118. package/dist/runtime/request-scope.js +36 -1
  119. package/dist/runtime/run.js +4 -1
  120. package/dist/sent-secrets.d.ts +20 -0
  121. package/dist/sent-secrets.js +49 -1
  122. package/dist/skills.js +100 -33
  123. package/dist/storage/keys.js +9 -6
  124. package/dist/storage/sql-native.js +1 -1
  125. package/dist/tool-safety.d.ts +33 -0
  126. package/dist/tool-safety.js +51 -15
  127. package/dist/types.d.ts +31 -0
  128. package/dist/usage-guide.js +14 -13
  129. package/dist/validate.js +29 -10
  130. package/dist/version.d.ts +1 -1
  131. package/dist/version.js +1 -1
  132. package/dist/worker.js +28 -8
  133. package/documentation/architecture.md +104 -42
  134. package/documentation/auth.md +46 -19
  135. package/documentation/code-mode.md +378 -116
  136. package/documentation/deploying.md +10 -9
  137. package/documentation/hosted-mcp-migration.md +9 -3
  138. package/documentation/integrating.md +27 -5
  139. package/documentation/meta-tools.md +655 -153
  140. package/documentation/operating.md +5 -3
  141. package/documentation/providers.md +18 -6
  142. package/examples/worker/README.md +3 -3
  143. package/guides/how-it-works.md +2 -1
  144. package/guides/safety-and-trust.md +1 -3
  145. package/package.json +1 -1
  146. package/templates/node/AGENTS.md +3 -3
  147. package/templates/node/README.md +1 -1
  148. package/templates/node/package.json +1 -1
@@ -238,6 +238,21 @@ export function observeCatalogExpiry(ctx, method, ttlMs, expiresAt, staleFallbac
238
238
  // failure for five minutes. Resources never use stale inventories. A fallback
239
239
  // carries its original expired deadline and cannot authorize auth recovery.
240
240
  const STALE_FALLBACK_MS = 300_000;
241
+ /**
242
+ * How long past its expiry a complete tool catalog stays listable when its
243
+ * connector does not answer discovery in time. The listing only: a call always
244
+ * resolves through STALE_FALLBACK_MS at most. One day matches the longest TTL a
245
+ * deployment can configure, so this retains nothing it could not already hold.
246
+ * A refusal retires it sooner, for that credential: see `fenceTools`.
247
+ */
248
+ const STALE_DISCOVERY_MS = 86_400_000;
249
+ /** A fence outlives every entry it could be keeping from reuse. */
250
+ const CATALOG_FENCE_TTL_SECONDS = (MAX_CACHE_TTL_MS + STALE_DISCOVERY_MS) / 1000;
251
+ /** What a refusal's fence is keyed by, beside the catalog: one credential's slot within one generation. */
252
+ const catalogFenceFingerprint = (sharing, partition, authPartition, credentialIdentity) => authCall(fingerprintSerializedCatalog, [
253
+ // A shared credential gets one answer whoever asks, so one fence covers all its callers.
254
+ JSON.stringify(["fence", sharing === "shared" ? authPartition : partition, credentialIdentity ?? null]),
255
+ ]);
241
256
  /** Catalog chunks one storage read fetches together. */
242
257
  const CHUNKS_PER_READ = 8;
243
258
  export function attachCatalogCache(ctx, value) {
@@ -475,7 +490,9 @@ const catalogLocation = authMethod((ctx, config, authPartition) => authFlow(func
475
490
  export const prefetchCatalog = authMethod((ctx, connectorId, config, authPartition, sharing, credentialIdentity, negotiationKey) => authFlow(function* () {
476
491
  const { partition, sharedPartition, configHash } = yield authCall(catalogLocation, [ctx, config, authPartition]);
477
492
  const slots = sharing === "shared" ? [partition, sharedPartition] : [partition];
478
- const suffixes = [];
493
+ const suffixes = [
494
+ (yield catalogFenceFingerprint(sharing, partition, authPartition, credentialIdentity)).fingerprint,
495
+ ];
479
496
  for (const slot of slots)
480
497
  suffixes.push((yield authCall(fingerprintSerializedCatalog, [
481
498
  JSON.stringify(["tools/list", slot, credentialIdentity ?? null]),
@@ -624,6 +641,58 @@ const catalogClientOptionsNative = /* @__PURE__ */ registerAuth(catalogClientOpt
624
641
  ])).fingerprint;
625
642
  return stopped() ? undefined : responseCacheKeys.entry(prefix, digest);
626
643
  }));
644
+ const fenceKey = cacheMethod((operation, generation) => authFlow(function* () {
645
+ const { fingerprint } = (yield catalogFenceFingerprint(sharing, partition, authPartition, operation.credentialIdentity));
646
+ return responseCacheKeys.entry(responseCacheKeys.namespace(connectorId, configHash, generation), fingerprint);
647
+ }));
648
+ // Whether an expired catalog may stand in now. Read past this request's
649
+ // memo: a refusal or invalidation in another isolate retires it at once.
650
+ const staleAllowed = cacheMethod((operation) => authFlow(function* () {
651
+ if (operation.stopped() || !(yield authCall(namespace, [operation])))
652
+ return false;
653
+ const key = (yield authCall(fenceKey, [operation, operation.generation.value]));
654
+ const fence = (yield authCall(operation.io, [
655
+ cacheMethod(() => reads.read(storage, key, authStorage(storage, "get", [key]), true)),
656
+ ]));
657
+ return fence === null && !operation.stopped();
658
+ }));
659
+ // Read before the listing dispatches: a refusal after this point writes a
660
+ // value this listing never saw. An older memoized read only lifts less.
661
+ const readListingFence = cacheMethod((operation) => authFlow(function* () {
662
+ const { listing, stopped, io } = operation;
663
+ if (!listing || listing.fence !== undefined || stopped())
664
+ return;
665
+ // A generation claimed later holds no fence this listing could know of.
666
+ if (!listing.generationStored) {
667
+ listing.fence = null;
668
+ return;
669
+ }
670
+ const key = (yield authCall(fenceKey, [operation, listing.generation.value]));
671
+ listing.fence = (yield authCall(io, [
672
+ cacheMethod(() => reads.read(storage, key, authStorage(storage, "get", [key]))),
673
+ ]));
674
+ }));
675
+ // A complete listing's answer lifts the fence captured before it was
676
+ // asked, and only that one: a refusal that landed while it ran wrote
677
+ // another, which stays. With nothing captured it lifts nothing, so the
678
+ // next listing that reads the fence first does it instead.
679
+ const liftFence = cacheMethod((operation) => authFlow(function* () {
680
+ const { listing, stopped, io } = operation;
681
+ if (!listing?.fence || stopped())
682
+ return;
683
+ try {
684
+ const key = (yield authCall(fenceKey, [operation, operation.generation.value]));
685
+ const lifted = (yield authCall(io, [
686
+ cacheMethod(() => authStorage(storage, "compareAndSet", [key, listing.fence, null])),
687
+ ]));
688
+ if (lifted)
689
+ listing.fence = null;
690
+ }
691
+ catch (error) {
692
+ if (!stopped())
693
+ logFailure(ctx.logger, "catalog fence failed", failureRecord({ connector: connectorId }, error));
694
+ }
695
+ }));
627
696
  const manifest = (raw) => {
628
697
  if (!raw)
629
698
  return undefined;
@@ -721,13 +790,18 @@ const catalogClientOptionsNative = /* @__PURE__ */ registerAuth(catalogClientOpt
721
790
  ]));
722
791
  roots.push(responseCacheKeys.entry(responseCacheKeys.namespace(connectorId, configHash, operation.generation.value), fingerprint));
723
792
  }
724
- yield reads.prefetch(roots.map((key) => ({ storage, key })), (storage, keys) => authStorageMany(storage, keys));
793
+ const fence = (yield authCall(fenceKey, [operation, operation.generation.value]));
794
+ yield reads.prefetch([...roots, fence].map((key) => ({ storage, key })), (storage, keys) => authStorageMany(storage, keys));
795
+ (yield authCall(readListingFence, [operation]));
725
796
  const values = [];
726
797
  for (const root of roots)
727
798
  values.push(yield reads.read(storage, root, authStorage(storage, "get", [root])));
728
799
  listing.manifests ??= new Map();
729
800
  roots.forEach((root, i) => listing.manifests.set(root, values[i] ?? null));
730
801
  }
802
+ // A refused credential is listed anew, whatever its catalog's age.
803
+ if (listing?.fence && key.method === "tools/list")
804
+ return undefined;
731
805
  const m = manifest(listing?.manifests?.has(root)
732
806
  ? listing.manifests.get(root)
733
807
  : (yield authCall(io, [
@@ -736,7 +810,7 @@ const catalogClientOptionsNative = /* @__PURE__ */ registerAuth(catalogClientOpt
736
810
  if (stopped() ||
737
811
  !m ||
738
812
  (m.expiresAt <= Date.now() &&
739
- (key.method !== "tools/list" || m.expiresAt + STALE_FALLBACK_MS <= Date.now())))
813
+ (key.method !== "tools/list" || m.expiresAt + STALE_DISCOVERY_MS <= Date.now())))
740
814
  return undefined;
741
815
  // Up to CHUNKS_PER_READ chunks per round trip, so one answer stays a
742
816
  // few megabytes; the generation fence below still decides whether
@@ -814,6 +888,8 @@ const catalogClientOptionsNative = /* @__PURE__ */ registerAuth(catalogClientOpt
814
888
  (yield authCall(io, [
815
889
  cacheMethod(() => authStorage(storage, "delete", [root])),
816
890
  ]));
891
+ if (method === "tools/list")
892
+ (yield authCall(liftFence, [operation]));
817
893
  return 0;
818
894
  }
819
895
  if (!Array.isArray(catalogItems(method, result)) ||
@@ -833,7 +909,7 @@ const catalogClientOptionsNative = /* @__PURE__ */ registerAuth(catalogClientOpt
833
909
  const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
834
910
  let offset = 0;
835
911
  let chunkCount = 0;
836
- const ttlSeconds = () => Math.max(0.001, (expiresAt + (method === "tools/list" ? STALE_FALLBACK_MS : 0) - Date.now()) / 1000);
912
+ const ttlSeconds = () => Math.max(0.001, (expiresAt + (method === "tools/list" ? STALE_DISCOVERY_MS : 0) - Date.now()) / 1000);
837
913
  while (offset < bytes.length) {
838
914
  if (stopped() || expiresAt <= Date.now())
839
915
  return 0;
@@ -885,6 +961,27 @@ const catalogClientOptionsNative = /* @__PURE__ */ registerAuth(catalogClientOpt
885
961
  return 0;
886
962
  if (published) {
887
963
  lastStamp = stamp;
964
+ // Tool chunks outlive their manifest by the stale limit, so a
965
+ // superseded revision is removed now rather than retained for
966
+ // a day. A reader holding it misses and lists again; a failed
967
+ // removal leaves the chunks to their TTL.
968
+ const superseded = manifest(previous);
969
+ if (superseded && superseded.revision !== revision) {
970
+ try {
971
+ for (let index = 0; index < superseded.chunkCount && !stopped(); index++)
972
+ (yield authCall(io, [
973
+ cacheMethod(() => authStorage(storage, "delete", [
974
+ responseCacheKeys.chunk(root, superseded.revision, index),
975
+ ])),
976
+ ]));
977
+ }
978
+ catch (error) {
979
+ if (!stopped())
980
+ logFailure(ctx.logger, "catalog chunk cleanup failed", failureRecord({ connector: connectorId }, error));
981
+ }
982
+ }
983
+ if (method === "tools/list")
984
+ (yield authCall(liftFence, [operation]));
888
985
  return stamp;
889
986
  }
890
987
  }
@@ -930,7 +1027,7 @@ const catalogClientOptionsNative = /* @__PURE__ */ registerAuth(catalogClientOpt
930
1027
  // The queue retains only a Promise turn. Awaiting it in this caller's
931
1028
  // request preserves workerd I/O ownership; the listing program stays native.
932
1029
  let tail = Promise.resolve();
933
- const withListing = cacheMethod((listingCtx, read) => authFlow(function* () {
1030
+ const withListing = cacheMethod((listingCtx, read, bound) => authFlow(function* () {
934
1031
  const previous = tail;
935
1032
  let release;
936
1033
  tail = new Promise((resolve) => {
@@ -951,9 +1048,17 @@ const catalogClientOptionsNative = /* @__PURE__ */ registerAuth(catalogClientOpt
951
1048
  staleTools = undefined;
952
1049
  const operation = capture();
953
1050
  try {
954
- if (resolveCredentialIdentity)
1051
+ if (bound && "credentialIdentity" in bound)
1052
+ listing.credentialIdentity = bound.credentialIdentity;
1053
+ else if (resolveCredentialIdentity)
955
1054
  listing.credentialIdentity = yield authInvoke(operation, "io", [resolveCredentialIdentity]);
956
- if (!operation.stopped()) {
1055
+ if (bound?.fence !== undefined)
1056
+ listing.fence = bound.fence;
1057
+ if (bound?.generationStored !== undefined) {
1058
+ listing.generationStored = bound.generationStored;
1059
+ listing.generation.value = bound.generation;
1060
+ }
1061
+ else if (!operation.stopped()) {
957
1062
  try {
958
1063
  // Memoized: it locates the listing's entries, which the
959
1064
  // fresh fence in get accepts or refuses.
@@ -989,6 +1094,24 @@ const catalogClientOptionsNative = /* @__PURE__ */ registerAuth(catalogClientOpt
989
1094
  void previous.then(release);
990
1095
  }
991
1096
  }));
1097
+ // Capture what an attempt is about to run under. `pinned` names a
1098
+ // credential the caller has already read for it.
1099
+ const bindListing = cacheMethod((listingCtx, pinned, readFence = false) => authCall(withListing, [
1100
+ listingCtx,
1101
+ cacheMethod(() => authFlow(function* () {
1102
+ const operation = capture();
1103
+ if (readFence)
1104
+ (yield authCall(readListingFence, [operation]));
1105
+ const listing = operation.listing;
1106
+ return {
1107
+ credentialIdentity: listing.credentialIdentity,
1108
+ generation: listing.generation.value,
1109
+ generationStored: listing.generationStored ?? false,
1110
+ fence: listing.fence,
1111
+ };
1112
+ })),
1113
+ pinned,
1114
+ ]));
992
1115
  // A complete, unexpired tools/list entry, read before any connection
993
1116
  // exists. This is the SDK's own two-probe read: the private slot
994
1117
  // `[serverIdentity, cachePartition]`, then a shared connector's public
@@ -1017,12 +1140,101 @@ const catalogClientOptionsNative = /* @__PURE__ */ registerAuth(catalogClientOpt
1017
1140
  return Array.isArray(result.tools) ? result : undefined;
1018
1141
  })),
1019
1142
  ]));
1143
+ // The newest complete tools/list entry this caller could be served, fresh
1144
+ // or within STALE_DISCOVERY_MS of expiry, with its original times. Reads
1145
+ // only, through the same address, fingerprint, generation and credential
1146
+ // fences as a listing, so another owner's entry, an invalidated generation
1147
+ // or a rotated credential is a miss. Never a source for call resolution.
1148
+ const peekTools = cacheMethod((listingCtx, serverIdentity = "") => authCall(withListing, [
1149
+ listingCtx,
1150
+ cacheMethod(() => authFlow(function* () {
1151
+ if (!active?.generationStored)
1152
+ return undefined;
1153
+ const read = (slot) => authInvoke(store, "get", [
1154
+ { method: "tools/list", params: "", partition: JSON.stringify([serverIdentity, slot]) },
1155
+ ]);
1156
+ let entry = (yield read(partition));
1157
+ let stale = staleTools;
1158
+ if (entry === undefined && sharing === "shared") {
1159
+ staleTools = undefined;
1160
+ const shared = (yield read(""));
1161
+ entry = shared?.scope === "public" ? shared : undefined;
1162
+ // Set by the read above, which control flow analysis cannot see.
1163
+ const sharedStale = staleTools;
1164
+ if (sharedStale?.manifest.scope === "public" &&
1165
+ (!stale || stale.manifest.fetchedAt < sharedStale.manifest.fetchedAt))
1166
+ stale = sharedStale;
1167
+ }
1168
+ if (entry?.expiresAt !== undefined && entry.expiresAt > Date.now()) {
1169
+ const result = JSON.parse(entry.value);
1170
+ if (!Array.isArray(result.tools))
1171
+ return undefined;
1172
+ return {
1173
+ result,
1174
+ fetchedAt: catalogFetchedAt(listingCtx) ?? Date.now(),
1175
+ expiresAt: entry.expiresAt,
1176
+ };
1177
+ }
1178
+ const operation = capture();
1179
+ if (!stale ||
1180
+ !(yield authCall(staleAllowed, [operation])) ||
1181
+ stale.manifest.expiresAt + STALE_DISCOVERY_MS <= Date.now())
1182
+ return undefined;
1183
+ return { result: stale.result, fetchedAt: stale.manifest.fetchedAt, expiresAt: stale.manifest.expiresAt };
1184
+ })),
1185
+ ]));
1186
+ // The downstream refused this credential, which says more than a TTL can:
1187
+ // nothing cached for it may stand in again until a listing succeeds. One
1188
+ // key beside the catalog, durable across requests and isolates, in the
1189
+ // namespace of the generation the refused attempt ran under, so a change
1190
+ // of credential or a disconnect discards it with the entries it guards. An
1191
+ // attempt that found no generation fences the one claimed since, if any:
1192
+ // that is where a listing beside it published. Nothing is stored while no
1193
+ // generation is, since then nothing is cached to retire. A failed write is
1194
+ // logged and never masks the refusal.
1195
+ const writeFence = cacheMethod((operation) => authFlow(function* () {
1196
+ const { listing, stopped, io } = operation;
1197
+ if (!listing || stopped())
1198
+ return;
1199
+ try {
1200
+ const generation = listing.generationStored
1201
+ ? listing.generation.value
1202
+ : (yield authCall(io, [cacheMethod(() => generationRead(false))]));
1203
+ if (!generation || stopped())
1204
+ return;
1205
+ const key = (yield authCall(fenceKey, [operation, generation]));
1206
+ (yield authCall(io, [
1207
+ cacheMethod(() => authStorage(storage, "set", [
1208
+ key,
1209
+ crypto.randomUUID(),
1210
+ { ttlSeconds: CATALOG_FENCE_TTL_SECONDS },
1211
+ ])),
1212
+ ]));
1213
+ }
1214
+ catch (error) {
1215
+ if (!stopped())
1216
+ logFailure(ctx.logger, "catalog fence failed", failureRecord({ connector: connectorId }, error));
1217
+ }
1218
+ }));
1219
+ // The refusal of the listing now running, as that listing was bound.
1220
+ const fenceListing = cacheMethod(() => authCall(writeFence, [capture()]));
1221
+ // The refusal of an attempt bound earlier, outside any listing.
1222
+ const fenceTools = cacheMethod((listingCtx, bound) => authCall(withListing, [listingCtx, cacheMethod(() => authCall(writeFence, [capture()])), bound]));
1223
+ // For a listing that never reads the cache before it dispatches.
1224
+ const captureFence = cacheMethod(() => authFlow(function* () {
1225
+ const operation = capture();
1226
+ try {
1227
+ (yield authCall(readListingFence, [operation]));
1228
+ }
1229
+ catch (error) {
1230
+ if (!operation.stopped())
1231
+ logFailure(ctx.logger, "catalog fence failed", failureRecord({ connector: connectorId }, error));
1232
+ }
1233
+ }));
1020
1234
  const fallbackTools = cacheMethod(() => authFlow(function* () {
1021
1235
  const operation = capture();
1022
1236
  if (!staleTools ||
1023
- operation.stopped() ||
1024
- !(yield authCall(namespace, [operation])) ||
1025
- operation.stopped() ||
1237
+ !(yield authCall(staleAllowed, [operation])) ||
1026
1238
  staleTools.manifest.expiresAt + STALE_FALLBACK_MS <= Date.now())
1027
1239
  return;
1028
1240
  const { result, manifest } = staleTools;
@@ -1035,6 +1247,11 @@ const catalogClientOptionsNative = /* @__PURE__ */ registerAuth(catalogClientOpt
1035
1247
  completeCatalogRefresh,
1036
1248
  fallbackTools,
1037
1249
  cachedTools,
1250
+ peekTools,
1251
+ bindListing,
1252
+ captureFence,
1253
+ fenceListing,
1254
+ fenceTools,
1038
1255
  responseCacheStore: store,
1039
1256
  cachePartition: partition,
1040
1257
  defaultCacheTtlMs: policy?.defaultTtlMs ?? CONFIG_DEFAULTS.discovery.catalogTtlSeconds * 1000,
@@ -1046,36 +1263,54 @@ function isCatalogMethod(method) {
1046
1263
  return method === "tools/list" || method === "resources/list" || method === "resources/templates/list";
1047
1264
  }
1048
1265
  function customCatalogCache(ctx, connectorId) {
1049
- return runEdge(authProgram(customCatalogCacheNative, [
1050
- ctx,
1051
- connectorId,
1052
- ]));
1266
+ return runEdge(authProgram(customCatalogCacheNative, [ctx, connectorId]));
1053
1267
  }
1054
- const customCatalogCacheNative = /* @__PURE__ */ registerAuth(customCatalogCache, (ctx, connectorId) => authFlow(function* () {
1268
+ const customCatalogAuth = authMethod((ctx) => authFlow(function* () {
1055
1269
  const credential = (yield authValue(() => ctx.credential?.getAll()));
1056
1270
  // Credential values enter the stored partition only through a keyed digest.
1057
- const auth = credential == null
1271
+ return credential == null
1058
1272
  ? (yield authCall(fingerprintSerializedCatalog, ["null"])).fingerprint
1059
1273
  : (yield authCall(secretDigest, [ctx, JSON.stringify(credential)]));
1274
+ }));
1275
+ const customCatalogCacheNative = /* @__PURE__ */ registerAuth(customCatalogCache, (ctx, connectorId) => authFlow(function* () {
1276
+ const auth = (yield authCall(customCatalogAuth, [ctx]));
1060
1277
  const cache = (yield authCall(catalogClientOptions, [ctx, connectorId, "custom-connector", auth]));
1061
1278
  const key = {
1062
1279
  method: "tools/list",
1063
1280
  partition: JSON.stringify(["custom-connector", cache.cachePartition]),
1064
1281
  };
1065
- return { cache, key };
1282
+ return { cache, key, auth };
1283
+ }));
1284
+ /**
1285
+ * Before a custom listing dispatches: only an authenticated owner may retain a
1286
+ * complete private fallback, and unscoped/operator reads start no cache I/O.
1287
+ * Its answer is recorded against this ticket, never a later read of the store.
1288
+ */
1289
+ export function bindCustomCatalogListing(ctx, connectorId) {
1290
+ return runEdge(authProgram(bindCustomCatalogListingNative, [ctx, connectorId]));
1291
+ }
1292
+ const bindCustomCatalogListingNative = /* @__PURE__ */ registerAuth(bindCustomCatalogListing, (ctx, connectorId) => authFlow(function* () {
1293
+ if (!catalogTtlMs(ctx, undefined) || !callerOf(ctx)?.authenticated)
1294
+ return;
1295
+ const custom = (yield authCall(customCatalogCache, [ctx, connectorId]));
1296
+ const bound = (yield authInvoke(custom.cache, "bindListing", [ctx, undefined, true]));
1297
+ return { ...custom, bound };
1066
1298
  }));
1067
- /** Custom listings always fetch. Only an authenticated owner may retain a
1068
- * complete private fallback; unscoped/operator reads start no cache I/O. */
1069
- export function storeCustomCatalogFallback(ctx, connectorId, tools) {
1070
- return runEdge(authProgram(storeCustomCatalogFallbackNative, [ctx, connectorId, tools]));
1299
+ /** Custom listings always fetch; the ticket says whether and where one is retained. */
1300
+ export function storeCustomCatalogFallback(ctx, tools, listing) {
1301
+ return runEdge(authProgram(storeCustomCatalogFallbackNative, [ctx, tools, listing]));
1071
1302
  }
1072
- const storeCustomCatalogFallbackNative = /* @__PURE__ */ registerAuth(storeCustomCatalogFallback, (ctx, connectorId, tools) => authFlow(function* () {
1303
+ const storeCustomCatalogFallbackNative = /* @__PURE__ */ registerAuth(storeCustomCatalogFallback, (ctx, tools, listing) => authFlow(function* () {
1073
1304
  const ttlMs = catalogTtlMs(ctx, undefined);
1074
1305
  observeCatalogFetch(ctx, Date.now());
1075
1306
  observeCatalogExpiry(ctx, "tools/list", ttlMs);
1076
- if (!ttlMs || !callerOf(ctx)?.authenticated)
1307
+ if (!ttlMs || !listing)
1077
1308
  return;
1078
- const { cache, key } = (yield authCall(customCatalogCache, [ctx, connectorId]));
1309
+ // The connector read its own credential, so a change while it ran leaves
1310
+ // this answer belonging to neither: it is retained for neither.
1311
+ if ((yield authCall(customCatalogAuth, [ctx])) !== listing.auth)
1312
+ return;
1313
+ const { cache, key, bound } = listing;
1079
1314
  (yield authInvoke(cache, "withListing", [
1080
1315
  ctx,
1081
1316
  authMethod(() => authFlow(function* () {
@@ -1096,8 +1331,41 @@ const storeCustomCatalogFallbackNative = /* @__PURE__ */ registerAuth(storeCusto
1096
1331
  },
1097
1332
  ]));
1098
1333
  })),
1334
+ bound,
1099
1335
  ]));
1100
1336
  }));
1337
+ /** A custom connector's retained catalog, for discovery only. Same owner rule as its fallback. */
1338
+ export function customCatalogPeek(ctx, connectorId) {
1339
+ return runEdge(authProgram(customCatalogPeekNative, [ctx, connectorId]));
1340
+ }
1341
+ const customCatalogPeekNative = /* @__PURE__ */ registerAuth(customCatalogPeek, (ctx, connectorId) => authFlow(function* () {
1342
+ if (!callerOf(ctx)?.authenticated)
1343
+ return;
1344
+ const { cache } = (yield authCall(customCatalogCache, [ctx, connectorId]));
1345
+ const entry = (yield authInvoke(cache, "peekTools", [ctx, "custom-connector"]));
1346
+ return entry
1347
+ ? { tools: entry.result.tools, fetchedAt: entry.fetchedAt, expiresAt: entry.expiresAt }
1348
+ : undefined;
1349
+ }));
1350
+ /**
1351
+ * A custom connector's refusal. It reads its own credential, by any route, so
1352
+ * only one unchanged since the ticket identifies what was refused: that
1353
+ * catalog stands in for nothing until a listing succeeds. A change, or a failed
1354
+ * read, leaves it unknown, so every catalog retained for this connector goes.
1355
+ */
1356
+ export function fenceCustomCatalog(ctx, connectorId, listing) {
1357
+ return runEdge(authProgram(fenceCustomCatalogNative, [ctx, connectorId, listing]));
1358
+ }
1359
+ const fenceCustomCatalogNative = /* @__PURE__ */ registerAuth(fenceCustomCatalog, (ctx, connectorId, listing) => authFlow(function* () {
1360
+ const current = (yield authCall(customCatalogAuth, [ctx]).pipe(Effect.catch(() => Effect.succeed(undefined))));
1361
+ if (current === listing.auth)
1362
+ (yield authInvoke(listing.cache, "fenceTools", [ctx, listing.bound]));
1363
+ else
1364
+ (yield authCall(invalidateCatalogCache, [
1365
+ serviceFor(ctx, CatalogPolicy)?.storage ?? ctx.storage,
1366
+ connectorId,
1367
+ ]));
1368
+ }));
1101
1369
  export function customCatalogFallback(ctx, connectorId) {
1102
1370
  return runEdge(authProgram(customCatalogFallbackNative, [ctx, connectorId]));
1103
1371
  }
@@ -6,8 +6,20 @@ export function markCatalogFreshness(tools, freshUntil) {
6
6
  return tools;
7
7
  }
8
8
  export function carryCatalogFreshness(source, copy) {
9
+ const stale = staleCatalogs.get(source);
10
+ if (stale)
11
+ staleCatalogs.set(copy, stale);
9
12
  return markCatalogFreshness(copy, deadlines.get(source) ?? 0);
10
13
  }
14
+ const staleCatalogs = new WeakMap();
15
+ /** Host-only, beside freshness: a stale listing is never fresh and never grants anything. */
16
+ export function markCatalogStale(tools, staleness) {
17
+ staleCatalogs.set(tools, staleness);
18
+ return tools;
19
+ }
20
+ export function catalogStaleness(tools) {
21
+ return staleCatalogs.get(tools);
22
+ }
11
23
  export function catalogIsFresh(tools) {
12
24
  return (deadlines.get(tools) ?? 0) > Date.now();
13
25
  }