@zackbart/connecta 0.38.0 → 0.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/README.md +3 -2
  3. package/bin/connecta.mjs +4 -8
  4. package/bin/version.mjs +1 -1
  5. package/dist/access-tokens.js +6 -4
  6. package/dist/agent-routes.js +231 -0
  7. package/dist/auth/downstream-oauth/consent.js +519 -0
  8. package/dist/auth/downstream-oauth/grant-store.js +439 -0
  9. package/dist/auth/downstream-oauth/refresh-coordinator.js +562 -0
  10. package/dist/auth/downstream-oauth/refresh-records.js +447 -0
  11. package/dist/auth/downstream-oauth/sdk-hooks.js +242 -0
  12. package/dist/auth/downstream-oauth.js +66 -2382
  13. package/dist/auth/native.js +37 -0
  14. package/dist/auth/oauth-grant.js +13 -0
  15. package/dist/auth/static-oauth.js +153 -131
  16. package/dist/auth-elicitation.js +7 -1
  17. package/dist/authorization-handoff.js +11 -5
  18. package/dist/catalog-cache.js +219 -59
  19. package/dist/catalog-policy.js +18 -0
  20. package/dist/catalog-service.js +284 -47
  21. package/dist/catalog.js +18 -2
  22. package/dist/config.d.ts +16 -11
  23. package/dist/config.js +9 -6
  24. package/dist/connector-caller.d.ts +1 -1
  25. package/dist/connector-health.js +59 -0
  26. package/dist/connector-status.js +117 -0
  27. package/dist/connectors/api-connector.d.ts +7 -2
  28. package/dist/connectors/api-connector.js +7 -4
  29. package/dist/connectors/negotiation-cache.js +22 -4
  30. package/dist/connectors/remote-mcp/call.js +164 -0
  31. package/dist/connectors/remote-mcp/catalog.js +278 -0
  32. package/dist/connectors/remote-mcp/config.js +263 -0
  33. package/dist/connectors/remote-mcp/connection.js +682 -0
  34. package/dist/connectors/remote-mcp/errors.js +488 -0
  35. package/dist/connectors/remote-mcp/oauth.js +473 -0
  36. package/dist/connectors/remote-mcp/resources.js +473 -0
  37. package/dist/connectors/remote-mcp/runtime.js +1 -0
  38. package/dist/connectors/remote-mcp/transport.js +555 -0
  39. package/dist/connectors/remote-mcp.js +62 -2863
  40. package/dist/credential-contract.d.ts +23 -0
  41. package/dist/credential-reads.js +17 -0
  42. package/dist/credential-verification.js +53 -0
  43. package/dist/credentials.d.ts +7 -0
  44. package/dist/credentials.js +60 -25
  45. package/dist/describe-config.d.ts +12 -2
  46. package/dist/describe-config.js +2 -0
  47. package/dist/discovery-health.js +199 -0
  48. package/dist/downstream-input.js +2 -0
  49. package/dist/errors.d.ts +55 -17
  50. package/dist/errors.js +95 -41
  51. package/dist/execute.js +152 -114
  52. package/dist/executor-result.js +3 -1
  53. package/dist/guest-functions.js +83 -0
  54. package/dist/guest-runtime.js +60 -19
  55. package/dist/guest-types.d.ts +19 -5
  56. package/dist/http/argument-problems.js +35 -0
  57. package/dist/http/errors.js +5 -4
  58. package/dist/http/fields.js +192 -13
  59. package/dist/http/hatch-response.js +73 -0
  60. package/dist/http/hatch.js +88 -23
  61. package/dist/http/oauth.js +3 -2
  62. package/dist/http/provider.js +32 -4
  63. package/dist/http/safety.js +8 -0
  64. package/dist/http/tools.js +44 -23
  65. package/dist/index.d.ts +1 -1
  66. package/dist/index.js +9 -2
  67. package/dist/invocation.js +26 -8
  68. package/dist/membership.d.ts +13 -5
  69. package/dist/membership.js +34 -2
  70. package/dist/meta-tools.js +54 -31
  71. package/dist/native-recovery.js +4 -16
  72. package/dist/native-tools.js +5 -2
  73. package/dist/oauth-sealing.js +10 -0
  74. package/dist/operation-vocabulary.js +135 -13
  75. package/dist/operator-record.d.ts +2 -2
  76. package/dist/operator-record.js +1 -0
  77. package/dist/operator-ui/contract.d.ts +10 -0
  78. package/dist/operator-ui/generated.js +2 -2
  79. package/dist/operator-ui/model.d.ts +22 -1
  80. package/dist/provider-call-facts.js +6 -2
  81. package/dist/providers/_shared/rest/operation-index.js +48 -50
  82. package/dist/providers/_shared/rest/value-safety.js +80 -0
  83. package/dist/providers/breeze/api.js +7 -1
  84. package/dist/providers/breeze/skill.generated.js +1 -1
  85. package/dist/providers/breeze/tools.js +4 -3
  86. package/dist/providers/ccb/api.js +2 -1
  87. package/dist/providers/ccb/index.js +5 -0
  88. package/dist/providers/ccb/skill.generated.js +1 -1
  89. package/dist/providers/cloudflare/api.js +8 -1
  90. package/dist/providers/cloudflare/skill.generated.js +1 -1
  91. package/dist/providers/docs/lib/schemas.js +24 -22
  92. package/dist/providers/docs/safety.js +2 -0
  93. package/dist/providers/docs/skill.generated.js +1 -1
  94. package/dist/providers/docs/tools.js +2 -4
  95. package/dist/providers/drive/api.js +1 -0
  96. package/dist/providers/drive/lib/contracts.js +8 -5
  97. package/dist/providers/drive/skill.generated.js +1 -1
  98. package/dist/providers/drive/tools.js +11 -16
  99. package/dist/providers/forms/api.js +1 -0
  100. package/dist/providers/forms/skill.generated.js +1 -1
  101. package/dist/providers/forms/tools.js +10 -7
  102. package/dist/providers/github/api.js +1 -0
  103. package/dist/providers/github/lib/discovery.js +1 -1
  104. package/dist/providers/github/skill.generated.js +1 -1
  105. package/dist/providers/github/tools.js +61 -17
  106. package/dist/providers/gmail/lib/schemas.js +60 -50
  107. package/dist/providers/gmail/skill.generated.js +1 -1
  108. package/dist/providers/gmail/tools.js +7 -5
  109. package/dist/providers/granola/index.js +2 -0
  110. package/dist/providers/granola/skill.generated.js +1 -1
  111. package/dist/providers/overflow/api.js +7 -1
  112. package/dist/providers/overflow/skill.generated.js +1 -1
  113. package/dist/providers/planning-center/api.js +3 -1
  114. package/dist/providers/planning-center/lib/constants.d.ts +1 -1
  115. package/dist/providers/planning-center/skill.generated.js +1 -1
  116. package/dist/providers/quickbooks/safety.js +80 -39
  117. package/dist/providers/quickbooks/skill.generated.js +1 -1
  118. package/dist/providers/sheets/api.js +1 -0
  119. package/dist/providers/sheets/skill.generated.js +1 -1
  120. package/dist/providers/sheets/tools.js +13 -14
  121. package/dist/providers/slides/skill.generated.js +1 -1
  122. package/dist/providers/slides/tools.js +15 -13
  123. package/dist/providers/stripe/skill.generated.js +1 -1
  124. package/dist/providers/tithely/api.js +3 -1
  125. package/dist/providers/tithely/skill.generated.js +1 -1
  126. package/dist/providers/vercel/api.js +1 -1
  127. package/dist/providers/vercel/skill.generated.js +1 -1
  128. package/dist/providers/vercel/tools.js +4 -3
  129. package/dist/registry.d.ts +10 -4
  130. package/dist/registry.js +41 -13
  131. package/dist/routes/credentials.js +129 -60
  132. package/dist/routes/dispatch.js +5 -1
  133. package/dist/routes/mcp.js +30 -14
  134. package/dist/routes/operator.js +2 -2
  135. package/dist/routes/shared.d.ts +6 -1
  136. package/dist/routes/shared.js +7 -1
  137. package/dist/routes/ui-config.js +2 -2
  138. package/dist/routes/ui-data.js +126 -20
  139. package/dist/routes/ui.js +16 -0
  140. package/dist/runtime/call.js +7 -2
  141. package/dist/runtime/native-storage.js +12 -0
  142. package/dist/runtime/request-reads.js +207 -0
  143. package/dist/runtime/request-scope.js +16 -0
  144. package/dist/runtime/storage.js +12 -0
  145. package/dist/skills.js +37 -20
  146. package/dist/storage/keys.js +53 -3
  147. package/dist/storage/memory.js +8 -2
  148. package/dist/storage/sql-native.js +53 -1
  149. package/dist/storage/sql.js +12 -2
  150. package/dist/storage-digest.js +32 -0
  151. package/dist/timeout.js +8 -0
  152. package/dist/types.d.ts +33 -0
  153. package/dist/usage-guide.js +16 -35
  154. package/dist/validate.d.ts +3 -0
  155. package/dist/validate.js +220 -158
  156. package/dist/version.d.ts +1 -1
  157. package/dist/version.js +1 -1
  158. package/dist/write-outcome.js +0 -3
  159. package/documentation/README.md +13 -13
  160. package/documentation/architecture.md +385 -20
  161. package/documentation/auth.md +233 -64
  162. package/documentation/code-mode.md +122 -39
  163. package/documentation/deploying.md +6 -3
  164. package/documentation/integrating.md +16 -2
  165. package/documentation/meta-tools.md +240 -49
  166. package/documentation/migration-0.33.md +5 -1
  167. package/documentation/migration-0.34.md +3 -1
  168. package/documentation/operator-tests.md +1 -1
  169. package/documentation/operator-ui.md +105 -12
  170. package/documentation/pool-membership-upgrade.md +44 -0
  171. package/documentation/providers.md +105 -0
  172. package/examples/worker/AGENTS.md +10 -10
  173. package/examples/worker/README.md +4 -3
  174. package/examples/worker/src/connecta.config.ts +1 -0
  175. package/guides/connecting-agents.md +2 -1
  176. package/guides/how-it-works.md +4 -2
  177. package/package.json +1 -1
  178. package/templates/node/AGENTS.md +13 -12
  179. package/templates/node/package.json +1 -1
  180. package/templates/node/src/connecta.config.ts +1 -0
@@ -1,19 +1,43 @@
1
1
  import { detach, runEdge } from "./runtime/run.js";
2
2
  import { Deferred, Effect } from "effect";
3
- import { authFlow, authCall, authInvoke, authValue, authMethod, authProgram, registerAuth, authStorage, authMap, authAwait, } from "./auth/native.js";
3
+ import { authFlow, authCall, authInvoke, authValue, authMethod, authProgram, registerAuth, authStorage, authStorageMany, authMap, authAwait, } from "./auth/native.js";
4
4
  import { MAX_CACHE_TTL_MS, specTypeSchemas } from "@modelcontextprotocol/client";
5
5
  import { advanceCatalogRevision, catalogRevision } from "./catalog-revision.js";
6
6
  import { callerOf } from "./connector-caller.js";
7
7
  import { CONFIG_DEFAULTS } from "./config-defaults.js";
8
8
  import { Context } from "effect";
9
- import { RequestScope } from "./runtime/request-scope.js";
9
+ import { ownerReads, RequestScope } from "./runtime/request-scope.js";
10
10
  import { serviceFor, setViewService, viewServices } from "./runtime/call.js";
11
11
  import { ConnectorCallError } from "./errors.js";
12
12
  import { fingerprintSerializedCatalog } from "./catalog-fingerprint.js";
13
13
  import { MAX_CATALOG_CHUNK_BYTES, MAX_CATALOG_TOOLS, MAX_SERIALIZED_CATALOG_BYTES } from "./catalog-limits.js";
14
14
  import { redactCatalog, sentSecretsForRequest } from "./sent-secrets.js";
15
15
  import { failureRecord, logFailure } from "./operator-record.js";
16
+ import { secretDigest } from "./storage-digest.js";
16
17
  import { responseCacheKeys } from "./storage/keys.js";
18
+ import { CatalogPolicy } from "./catalog-policy.js";
19
+ /**
20
+ * An unchanged refresh leaves a baseline younger than this in place instead of
21
+ * rewriting it, so a baseline outlives every refresh by at least the baseline
22
+ * TTL minus this interval.
23
+ */
24
+ const BASELINE_REWRITE_AGE_MS = 60 * 60 * 1000;
25
+ const refreshHash = (v) => typeof v === "string" && /^sha256:[0-9]+:[0-9a-f]{64}$/.test(v);
26
+ function refreshManifest(raw) {
27
+ if (!raw)
28
+ return;
29
+ try {
30
+ const value = JSON.parse(raw);
31
+ if (refreshHash(value.digest) &&
32
+ refreshHash(value.fingerprint) &&
33
+ /^[0-9a-f-]{36}$/.test(value.revision) &&
34
+ Number.isInteger(value.chunkCount) &&
35
+ value.chunkCount >= 1 &&
36
+ value.chunkCount <= Math.ceil(MAX_SERIALIZED_CATALOG_BYTES / MAX_CATALOG_CHUNK_BYTES))
37
+ return value;
38
+ }
39
+ catch { }
40
+ }
17
41
  /** Publish one accepted catalog refresh, with its partitioned drift baseline. */
18
42
  export function observeCompletedCatalogRefresh(ctx, refresh) {
19
43
  return runEdge(authProgram(observeCompletedCatalogRefreshNative, [ctx, refresh]));
@@ -41,18 +65,10 @@ function refreshBaseline(storage, key, raw, io) {
41
65
  return runEdge(authProgram(refreshBaselineNative, [storage, key, raw, io]));
42
66
  }
43
67
  const refreshBaselineNative = /* @__PURE__ */ registerAuth(refreshBaseline, (storage, key, raw, io) => authFlow(function* () {
44
- if (!raw)
68
+ const value = refreshManifest(raw);
69
+ if (!value)
45
70
  return;
46
71
  try {
47
- const value = JSON.parse(raw);
48
- const hash = (v) => typeof v === "string" && /^sha256:[0-9]+:[0-9a-f]{64}$/.test(v);
49
- if (!hash(value.digest) ||
50
- !hash(value.fingerprint) ||
51
- !/^[0-9a-f-]{36}$/.test(value.revision) ||
52
- !Number.isInteger(value.chunkCount) ||
53
- value.chunkCount < 1 ||
54
- value.chunkCount > Math.ceil(MAX_SERIALIZED_CATALOG_BYTES / MAX_CATALOG_CHUNK_BYTES))
55
- return;
56
72
  const chunks = [];
57
73
  for (let index = 0; index < value.chunkCount; index++) {
58
74
  const chunk = (yield authCall(io, [
@@ -68,7 +84,7 @@ const refreshBaselineNative = /* @__PURE__ */ registerAuth(refreshBaseline, (sto
68
84
  const tools = JSON.parse(serialized);
69
85
  if (Array.isArray(tools) &&
70
86
  tools.length <= MAX_CATALOG_TOOLS &&
71
- tools.every((tool) => tool && hash(tool.name) && hash(tool.fact)))
87
+ tools.every((tool) => tool && refreshHash(tool.name) && refreshHash(tool.fact)))
72
88
  return { digest: value.digest, tools };
73
89
  }
74
90
  catch { }
@@ -86,6 +102,18 @@ function acceptCatalogRefresh(storage, key, connectorId, digest, tools, privateC
86
102
  ]));
87
103
  }
88
104
  const acceptCatalogRefreshNative = /* @__PURE__ */ registerAuth(acceptCatalogRefresh, (storage, key, connectorId, digest, tools, privateCatalog, current, io) => authFlow(function* () {
105
+ // An identical catalog cannot drift, and its baseline, written within
106
+ // the last hour, needs no rewrite: such a refresh writes nothing. The
107
+ // stored chunks always outlive their manifest, since a manifest is only
108
+ // ever written with fresh chunks.
109
+ const stored = (yield authCall(io, [
110
+ authMethod(() => authStorage(storage, "get", [key])),
111
+ ]));
112
+ const baseline = refreshManifest(stored);
113
+ if (baseline?.digest === digest &&
114
+ baseline.writtenAt !== undefined &&
115
+ Date.now() - baseline.writtenAt < BASELINE_REWRITE_AGE_MS)
116
+ return;
89
117
  const next = (yield authCall(catalogFingerprints, [tools]));
90
118
  const serialized = JSON.stringify(next);
91
119
  const revision = crypto.randomUUID();
@@ -103,13 +131,17 @@ const acceptCatalogRefreshNative = /* @__PURE__ */ registerAuth(acceptCatalogRef
103
131
  ])),
104
132
  ]));
105
133
  }
106
- const manifest = { digest, revision, chunkCount, fingerprint };
134
+ const manifest = { digest, revision, chunkCount, fingerprint, writtenAt: Date.now() };
107
135
  for (let attempt = 0; attempt < 16; attempt++) {
108
136
  if (!(yield authCall(current, [])))
109
137
  return;
110
- const raw = (yield authCall(io, [
111
- authMethod(() => authStorage(storage, "get", [key])),
112
- ]));
138
+ // The first attempt publishes against the manifest read above; a lost
139
+ // compare-and-set reads it again.
140
+ const raw = attempt === 0
141
+ ? stored
142
+ : (yield authCall(io, [
143
+ authMethod(() => authStorage(storage, "get", [key])),
144
+ ]));
113
145
  const previous = (yield authCall(refreshBaseline, [storage, key, raw, io]));
114
146
  if (!(yield authCall(current, [])))
115
147
  return;
@@ -204,8 +236,8 @@ export function observeCatalogExpiry(ctx, method, ttlMs, expiresAt, staleFallbac
204
236
  // failure for five minutes. Resources never use stale inventories. A fallback
205
237
  // carries its original expired deadline and cannot authorize auth recovery.
206
238
  const STALE_FALLBACK_MS = 300_000;
207
- class CatalogPolicy extends Context.Service()("connecta/operation/CatalogPolicy") {
208
- }
239
+ /** Catalog chunks one storage read fetches together. */
240
+ const CHUNKS_PER_READ = 8;
209
241
  export function attachCatalogCache(ctx, value) {
210
242
  setViewService(ctx, CatalogPolicy, value);
211
243
  }
@@ -380,8 +412,42 @@ const invalidateCatalogCacheNative = /* @__PURE__ */ registerAuth(invalidateCata
380
412
  advanceCatalogRevision(storage);
381
413
  }
382
414
  }));
415
+ // Only detached JSON facts cross requests: no context, storage, promises or
416
+ // callbacks. FIFO bounds retained partitions, JSON/key text (UTF-16 worst
417
+ // case) and structural slots. No WeakRef or timers required.
418
+ const catalogStores = new WeakMap();
419
+ const validatedCatalogs = new Map();
420
+ let validatedBytes = 0;
421
+ const VALIDATED_BYTES = 8 * 1024 * 1024;
422
+ function rememberValidated(key, value, bytes) {
423
+ bytes = bytes * 2 + key.length * 2;
424
+ if (bytes > VALIDATED_BYTES || validatedCatalogs.has(key))
425
+ return;
426
+ const pending = [value];
427
+ while (pending.length) {
428
+ const item = pending.pop();
429
+ if (!item || typeof item !== "object" || Object.isFrozen(item))
430
+ continue;
431
+ Object.freeze(item);
432
+ for (const child of Object.values(item)) {
433
+ // Charge structural overhead too: tiny JSON objects/arrays can occupy
434
+ // much more heap than their serialized bytes alone suggest.
435
+ bytes += 64;
436
+ if (bytes > VALIDATED_BYTES)
437
+ return;
438
+ pending.push(child);
439
+ }
440
+ }
441
+ while (validatedCatalogs.size >= 4 || validatedBytes + bytes > VALIDATED_BYTES) {
442
+ const oldest = validatedCatalogs.keys().next().value;
443
+ validatedBytes -= validatedCatalogs.get(oldest).bytes;
444
+ validatedCatalogs.delete(oldest);
445
+ }
446
+ validatedCatalogs.set(key, { value, bytes });
447
+ validatedBytes += bytes;
448
+ }
383
449
  /** Keys ignore self-reported serverInfo and bind the configured connector instead. */
384
- export function catalogClientOptions(ctx, connectorId, config, authPartition, connectionSignal, owner, sharing = "private", resolveCredentialIdentity) {
450
+ export function catalogClientOptions(ctx, connectorId, config, authPartition, connectionSignal, owner, sharing = "private", resolveCredentialIdentity, connectionless = false) {
385
451
  return runEdge(authProgram(catalogClientOptionsNative, [
386
452
  ctx,
387
453
  connectorId,
@@ -391,10 +457,20 @@ export function catalogClientOptions(ctx, connectorId, config, authPartition, co
391
457
  owner,
392
458
  sharing,
393
459
  resolveCredentialIdentity,
460
+ connectionless,
394
461
  ]));
395
462
  }
396
- const catalogClientOptionsNative = /* @__PURE__ */ registerAuth(catalogClientOptions, (ctx, connectorId, config, authPartition, connectionSignal, owner, sharing = "private", resolveCredentialIdentity) => authFlow(function* () {
463
+ const catalogClientOptionsNative = /* @__PURE__ */ registerAuth(catalogClientOptions, (ctx, connectorId, config, authPartition, connectionSignal, owner, sharing = "private", resolveCredentialIdentity, connectionless = false) => authFlow(function* () {
397
464
  const request = Context.get(viewServices(ctx), RequestScope);
465
+ // Generation reads that only locate an entry are memoized for this
466
+ // request; every acceptance or publication check reads it fresh. A
467
+ // generation is never reused once replaced, so a stale one only
468
+ // addresses an entry that the fresh check then refuses.
469
+ const reads = ownerReads(request);
470
+ const generationRead = (memoized) => {
471
+ const key = responseCacheKeys.generation(connectorId);
472
+ return reads.read(storage, key, authStorage(storage, "get", [key]), !memoized);
473
+ };
398
474
  // SDK callbacks start at their own Promise edge. Reprovide only their
399
475
  // canonical request owner, never the initiating call's live services.
400
476
  const cacheMethod = (program) => authMethod((...args) => program(...args).pipe(Effect.provideService(RequestScope, request)));
@@ -411,6 +487,9 @@ const catalogClientOptionsNative = /* @__PURE__ */ registerAuth(catalogClientOpt
411
487
  authPartition,
412
488
  ]);
413
489
  const storage = policy?.storage ?? ctx.storage;
490
+ let storeIdentity = catalogStores.get(storage);
491
+ if (!storeIdentity)
492
+ catalogStores.set(storage, (storeIdentity = crypto.randomUUID()));
414
493
  const configHash = (yield authCall(fingerprintSerializedCatalog, [
415
494
  JSON.stringify([
416
495
  config,
@@ -443,18 +522,21 @@ const catalogClientOptionsNative = /* @__PURE__ */ registerAuth(catalogClientOpt
443
522
  };
444
523
  let lastStamp = 0;
445
524
  let staleTools;
446
- const currentGeneration = cacheMethod(({ stopped, io, generation }) => authFlow(function* () {
525
+ const currentGeneration = cacheMethod(({ stopped, io, generation }, memoized = false) => authFlow(function* () {
447
526
  const key = responseCacheKeys.generation(connectorId);
448
527
  for (let attempt = 0; attempt < 16; attempt++) {
449
528
  if (stopped())
450
529
  return undefined;
451
- const current = (yield authCall(io, [
452
- cacheMethod(() => authStorage(storage, "get", [key])),
453
- ]));
530
+ // A retry after a lost claim always reads fresh.
531
+ const current = (yield authCall(io, [cacheMethod(() => generationRead(memoized && attempt === 0))]));
454
532
  if (stopped())
455
533
  return undefined;
456
534
  if (current)
457
535
  return current;
536
+ // A connectionless reader never claims or recreates a generation: a
537
+ // missing one is a miss at every check, never a reused namespace.
538
+ if (connectionless)
539
+ return undefined;
458
540
  const next = generation.value ?? crypto.randomUUID();
459
541
  const claimed = (yield authCall(io, [
460
542
  cacheMethod(() => authStorage(storage, "compareAndSet", [
@@ -471,12 +553,13 @@ const catalogClientOptionsNative = /* @__PURE__ */ registerAuth(catalogClientOpt
471
553
  }
472
554
  throw new Error("Catalog cache generation is busy.");
473
555
  }));
474
- const namespace = cacheMethod((operation) => authFlow(function* () {
556
+ const namespace = cacheMethod((operation, memoized = false) => authFlow(function* () {
475
557
  const { stopped, generation, io } = operation;
476
- if (resolveCredentialIdentity &&
558
+ if (!memoized &&
559
+ resolveCredentialIdentity &&
477
560
  (yield authCall(io, [resolveCredentialIdentity])) !== operation.credentialIdentity)
478
561
  return undefined;
479
- const current = (yield authCall(currentGeneration, [operation]));
562
+ const current = (yield authCall(currentGeneration, [operation, memoized]));
480
563
  if (!current || stopped())
481
564
  return undefined;
482
565
  generation.value ??= current;
@@ -499,7 +582,10 @@ const catalogClientOptionsNative = /* @__PURE__ */ registerAuth(catalogClientOpt
499
582
  pair.length !== 2 ||
500
583
  (pair[1] !== partition && (pair[1] !== "" || sharing !== "shared")))
501
584
  return undefined;
502
- const prefix = (yield authCall(namespace, [operation]));
585
+ // Locate with the listing's pinned identity and generation. Neither
586
+ // authorizes acceptance: get's post-chunk fence and set's publication
587
+ // re-read both, so rotation or disconnect during I/O refuses the entry.
588
+ const prefix = (yield authCall(namespace, [operation, true]));
503
589
  if (!prefix || stopped())
504
590
  return undefined;
505
591
  const digest = (yield authCall(fingerprintSerializedCatalog, [
@@ -593,24 +679,47 @@ const catalogClientOptionsNative = /* @__PURE__ */ registerAuth(catalogClientOpt
593
679
  const root = (yield authCall(address, [key, operation]));
594
680
  if (!root || stopped())
595
681
  return undefined;
596
- const m = manifest((yield authCall(io, [
597
- cacheMethod(() => authStorage(storage, "get", [root])),
598
- ])));
682
+ // The SDK probes private, then public. Derive both addresses from
683
+ // the already-bound locating generation and identity, and fetch
684
+ // them in one statement. The post-chunk namespace check stays fresh.
685
+ const listing = operation.listing;
686
+ if (listing && !listing.manifests?.has(root)) {
687
+ const slots = sharing === "shared" ? [partition, sharedPartition] : [partition];
688
+ const roots = [];
689
+ for (const slot of slots) {
690
+ const { fingerprint } = (yield authCall(fingerprintSerializedCatalog, [
691
+ JSON.stringify([key.method, slot, operation.credentialIdentity ?? null]),
692
+ ]));
693
+ roots.push(responseCacheKeys.entry(responseCacheKeys.namespace(connectorId, configHash, operation.generation.value), fingerprint));
694
+ }
695
+ const values = (yield authCall(io, [cacheMethod(() => authStorageMany(storage, roots))]));
696
+ listing.manifests ??= new Map();
697
+ roots.forEach((root, i) => listing.manifests.set(root, values[i] ?? null));
698
+ }
699
+ const m = manifest(listing?.manifests?.has(root)
700
+ ? listing.manifests.get(root)
701
+ : (yield authCall(io, [
702
+ cacheMethod(() => authStorage(storage, "get", [root])),
703
+ ])));
599
704
  if (stopped() ||
600
705
  !m ||
601
706
  (m.expiresAt <= Date.now() &&
602
707
  (key.method !== "tools/list" || m.expiresAt + STALE_FALLBACK_MS <= Date.now())))
603
708
  return undefined;
709
+ // Up to CHUNKS_PER_READ chunks per round trip, so one answer stays a
710
+ // few megabytes; the generation fence below still decides whether
711
+ // what was read may be served.
604
712
  const chunks = [];
605
- for (let i = 0; i < m.chunkCount; i++) {
713
+ for (let first = 0; first < m.chunkCount; first += CHUNKS_PER_READ) {
606
714
  if (stopped())
607
715
  return undefined;
608
- const chunk = (yield authCall(io, [
609
- cacheMethod(() => authStorage(storage, "get", [responseCacheKeys.chunk(root, m.revision, i)])),
610
- ]));
611
- if (stopped() || chunk === null || new TextEncoder().encode(chunk).byteLength > MAX_CATALOG_CHUNK_BYTES)
612
- return undefined;
613
- chunks.push(chunk);
716
+ const keys = Array.from({ length: Math.min(CHUNKS_PER_READ, m.chunkCount - first) }, (_, i) => responseCacheKeys.chunk(root, m.revision, first + i));
717
+ const read = (yield authCall(io, [cacheMethod(() => authStorageMany(storage, keys))]));
718
+ for (const chunk of read) {
719
+ if (stopped() || chunk === null || new TextEncoder().encode(chunk).byteLength > MAX_CATALOG_CHUNK_BYTES)
720
+ return undefined;
721
+ chunks.push(chunk);
722
+ }
614
723
  }
615
724
  const value = chunks.join("");
616
725
  const fingerprint = (yield authCall(fingerprintSerializedCatalog, [value]));
@@ -620,18 +729,29 @@ const catalogClientOptionsNative = /* @__PURE__ */ registerAuth(catalogClientOpt
620
729
  !(yield authCall(namespace, [operation])) ||
621
730
  stopped())
622
731
  return undefined;
623
- const result = JSON.parse(value);
624
732
  const method = key.method;
625
- const items = catalogItems(method, result);
626
- if (!Array.isArray(items) ||
627
- items.length > MAX_CATALOG_TOOLS ||
628
- "nextCursor" in result ||
629
- (result.resultType !== undefined && result.resultType !== "complete"))
630
- return undefined;
631
- const validated = (yield authInvoke(catalogSchema(method)["~standard"], "validate", [result]));
632
- if (stopped() || validated.issues)
633
- return undefined;
634
- const clean = intake(ctx, method, validated.value);
733
+ // root binds connector/config/generation/scope/credential; partition
734
+ // also binds the current owner/deployment even for public entries.
735
+ // Hash the chunks again above, never trust the manifest alone.
736
+ const reuseKey = JSON.stringify([storeIdentity, root, partition, m.fingerprint]);
737
+ let accepted = validatedCatalogs.get(reuseKey)?.value;
738
+ if (!accepted) {
739
+ const result = JSON.parse(value);
740
+ const items = catalogItems(method, result);
741
+ if (!Array.isArray(items) ||
742
+ items.length > MAX_CATALOG_TOOLS ||
743
+ "nextCursor" in result ||
744
+ (result.resultType !== undefined && result.resultType !== "complete"))
745
+ return undefined;
746
+ const validated = (yield authInvoke(catalogSchema(method)["~standard"], "validate", [result]));
747
+ if (stopped() || validated.issues)
748
+ return undefined;
749
+ accepted = validated.value;
750
+ }
751
+ // Redaction depends on this request's sent credentials. It must
752
+ // still run, and returns detached objects from the frozen facts.
753
+ const clean = intake(ctx, method, accepted);
754
+ rememberValidated(reuseKey, accepted, fingerprint.byteLength);
635
755
  if (m.expiresAt <= Date.now()) {
636
756
  if (method === "tools/list" && (!staleTools || staleTools.manifest.fetchedAt < m.fetchedAt))
637
757
  staleTools = { result: clean, manifest: m };
@@ -647,6 +767,7 @@ const catalogClientOptionsNative = /* @__PURE__ */ registerAuth(catalogClientOpt
647
767
  const root = (yield authCall(address, [key, operation]));
648
768
  if (!root || stopped())
649
769
  return 0;
770
+ operation.listing?.manifests?.delete(root);
650
771
  const result = JSON.parse(entry.value);
651
772
  const method = key.method;
652
773
  if (catalogTtlMs(ctx, result.ttlMs) === 0) {
@@ -733,10 +854,12 @@ const catalogClientOptionsNative = /* @__PURE__ */ registerAuth(catalogClientOpt
733
854
  delete: cacheMethod((key) => authFlow(function* () {
734
855
  const operation = capture();
735
856
  const root = (yield authCall(address, [key, operation]));
736
- if (root && !operation.stopped())
857
+ if (root && !operation.stopped()) {
858
+ operation.listing?.manifests?.delete(root);
737
859
  (yield authInvoke(operation, "io", [
738
860
  cacheMethod(() => authStorage(storage, "delete", [root])),
739
861
  ]));
862
+ }
740
863
  })),
741
864
  evict: cacheMethod((method) => authFlow(function* () {
742
865
  if (isCatalogMethod(method))
@@ -752,10 +875,11 @@ const catalogClientOptionsNative = /* @__PURE__ */ registerAuth(catalogClientOpt
752
875
  };
753
876
  try {
754
877
  const { stopped, io } = capture({ ctx, generation: connectionGeneration, ended: false });
755
- if (!stopped()) {
756
- const current = (yield authCall(io, [
757
- cacheMethod(() => authStorage(storage, "get", [responseCacheKeys.generation(connectorId)])),
758
- ]));
878
+ // A connectionless reader only lists, and each listing reads its own
879
+ // generation; there are no connection-owned writes to fence.
880
+ if (!connectionless && !stopped()) {
881
+ // Memoized: a connection's writes publish only after a fresh check.
882
+ const current = (yield authCall(io, [cacheMethod(() => generationRead(true))]));
759
883
  if (!stopped())
760
884
  connectionGeneration.value = current ?? crypto.randomUUID();
761
885
  }
@@ -792,9 +916,13 @@ const catalogClientOptionsNative = /* @__PURE__ */ registerAuth(catalogClientOpt
792
916
  listing.credentialIdentity = yield authInvoke(operation, "io", [resolveCredentialIdentity]);
793
917
  if (!operation.stopped()) {
794
918
  try {
795
- const generation = (yield authInvoke(operation, "io", [
796
- cacheMethod(() => authStorage(storage, "get", [responseCacheKeys.generation(connectorId)])),
797
- ])) ?? crypto.randomUUID();
919
+ // Memoized: it locates the listing's entries, which the
920
+ // fresh fence in get accepts or refuses.
921
+ const stored = (yield authInvoke(operation, "io", [
922
+ cacheMethod(() => generationRead(true)),
923
+ ]));
924
+ const generation = stored ?? crypto.randomUUID();
925
+ listing.generationStored = stored !== null;
798
926
  listing.generation.value = generation;
799
927
  connectionGeneration.value = generation;
800
928
  }
@@ -820,6 +948,34 @@ const catalogClientOptionsNative = /* @__PURE__ */ registerAuth(catalogClientOpt
820
948
  void previous.then(release);
821
949
  }
822
950
  }));
951
+ // A complete, unexpired tools/list entry, read before any connection
952
+ // exists. This is the SDK's own two-probe read: the private slot
953
+ // `[serverIdentity, cachePartition]`, then a shared connector's public
954
+ // slot `[serverIdentity, ""]`, served only when written as public.
955
+ // `address` binds the configured connector rather than the self-reported
956
+ // identity, so the pre-connect `""` identity selects the same entries.
957
+ // Expired, partial, refused (`ttlMs: 0`) and invalidated entries miss,
958
+ // and the read writes nothing.
959
+ const cachedTools = cacheMethod((listingCtx) => authCall(withListing, [
960
+ listingCtx,
961
+ cacheMethod(() => authFlow(function* () {
962
+ // No stored generation means no entry, and claiming one is a write.
963
+ if (!active?.generationStored)
964
+ return undefined;
965
+ const read = (slot) => authInvoke(store, "get", [
966
+ { method: "tools/list", params: "", partition: JSON.stringify(["", slot]) },
967
+ ]);
968
+ let entry = (yield read(partition));
969
+ if (entry === undefined && sharing === "shared") {
970
+ const shared = (yield read(""));
971
+ entry = shared?.scope === "public" ? shared : undefined;
972
+ }
973
+ if (entry?.expiresAt === undefined || !(entry.expiresAt > Date.now()))
974
+ return undefined;
975
+ const result = JSON.parse(entry.value);
976
+ return Array.isArray(result.tools) ? result : undefined;
977
+ })),
978
+ ]));
823
979
  const fallbackTools = cacheMethod(() => authFlow(function* () {
824
980
  const operation = capture();
825
981
  if (!staleTools ||
@@ -837,6 +993,7 @@ const catalogClientOptionsNative = /* @__PURE__ */ registerAuth(catalogClientOpt
837
993
  intake,
838
994
  completeCatalogRefresh,
839
995
  fallbackTools,
996
+ cachedTools,
840
997
  responseCacheStore: store,
841
998
  cachePartition: partition,
842
999
  defaultCacheTtlMs: policy?.defaultTtlMs ?? CONFIG_DEFAULTS.discovery.catalogTtlSeconds * 1000,
@@ -855,7 +1012,10 @@ function customCatalogCache(ctx, connectorId) {
855
1012
  }
856
1013
  const customCatalogCacheNative = /* @__PURE__ */ registerAuth(customCatalogCache, (ctx, connectorId) => authFlow(function* () {
857
1014
  const credential = (yield authValue(() => ctx.credential?.getAll()));
858
- const auth = (yield authCall(fingerprintSerializedCatalog, [JSON.stringify(credential ?? null)])).fingerprint;
1015
+ // Credential values enter the stored partition only through a keyed digest.
1016
+ const auth = credential == null
1017
+ ? (yield authCall(fingerprintSerializedCatalog, ["null"])).fingerprint
1018
+ : (yield authCall(secretDigest, [ctx, JSON.stringify(credential)]));
859
1019
  const cache = (yield authCall(catalogClientOptions, [ctx, connectorId, "custom-connector", auth]));
860
1020
  const key = {
861
1021
  method: "tools/list",
@@ -0,0 +1,18 @@
1
+ // The catalog cache's per-view policy: its store, owner partition and TTL
2
+ // bounds, attached to each connector context by the registry. Kept apart from
3
+ // the cache itself so bounded facts stored beside it (connector health, the
4
+ // operator page's status observations) reach the partition without the MCP
5
+ // client the cache adapter imports.
6
+ import { Context } from "effect";
7
+ import { callerOf } from "./connector-caller.js";
8
+ import { serviceFor } from "./runtime/call.js";
9
+ export class CatalogPolicy extends Context.Service()("connecta/operation/CatalogPolicy") {
10
+ }
11
+ /** The store and owner partition this connector's catalog cache uses, for bounded facts kept beside it. */
12
+ export function catalogOwnerPartition(ctx) {
13
+ const policy = serviceFor(ctx, CatalogPolicy);
14
+ return {
15
+ storage: policy?.storage ?? ctx.storage,
16
+ partition: JSON.stringify([policy?.partition ?? JSON.stringify(callerOf(ctx) ?? null), ctx.baseUrl, ctx.publicUrl]),
17
+ };
18
+ }