@zackbart/connecta 0.39.0 → 0.41.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 (120) hide show
  1. package/CHANGELOG.md +115 -0
  2. package/bin/connecta.mjs +4 -8
  3. package/bin/version.mjs +1 -1
  4. package/dist/access-tokens.js +6 -4
  5. package/dist/activity.d.ts +30 -4
  6. package/dist/activity.js +28 -2
  7. package/dist/agent-observation.js +24 -2
  8. package/dist/agent-routes.js +4 -41
  9. package/dist/auth/clerk.d.ts +6 -1
  10. package/dist/auth/clerk.js +16 -0
  11. package/dist/auth/cloudflare-access.js +49 -7
  12. package/dist/auth/downstream-oauth/consent.js +79 -29
  13. package/dist/auth/downstream-oauth/grant-store.js +58 -7
  14. package/dist/auth/downstream-oauth/refresh-records.js +6 -5
  15. package/dist/auth/downstream-oauth.js +12 -4
  16. package/dist/auth/native.js +10 -0
  17. package/dist/auth/oauth-request-not-sent.js +4 -0
  18. package/dist/auth/static-oauth.js +51 -21
  19. package/dist/catalog-cache.js +151 -37
  20. package/dist/catalog-freshness.js +13 -0
  21. package/dist/catalog-service.js +39 -12
  22. package/dist/catalog.js +103 -38
  23. package/dist/config.d.ts +10 -4
  24. package/dist/config.js +30 -7
  25. package/dist/connectors/remote-mcp/call.js +1 -1
  26. package/dist/connectors/remote-mcp/connection.js +52 -7
  27. package/dist/connectors/remote-mcp/oauth.js +6 -2
  28. package/dist/connectors/remote-mcp/resources.js +5 -2
  29. package/dist/connectors/remote-mcp/transport.js +37 -8
  30. package/dist/connectors/remote-mcp.js +8 -5
  31. package/dist/credential-contract.d.ts +3 -1
  32. package/dist/describe-config.d.ts +1 -0
  33. package/dist/describe-config.js +1 -0
  34. package/dist/downstream-input.js +11 -2
  35. package/dist/errors.d.ts +8 -1
  36. package/dist/errors.js +2 -0
  37. package/dist/execute.js +20 -7
  38. package/dist/executor-result.js +1 -1
  39. package/dist/executors/quickjs-child.js +1 -1
  40. package/dist/executors/quickjs-runtime.js +1 -1
  41. package/dist/executors/quickjs.js +1 -1
  42. package/dist/guarded-transport.js +2 -0
  43. package/dist/http/client.js +7 -0
  44. package/dist/http/hatch.js +2 -0
  45. package/dist/http/node-transport.js +110 -4
  46. package/dist/http/oauth.js +17 -6
  47. package/dist/index.js +10 -21
  48. package/dist/invocation-failure.js +28 -0
  49. package/dist/invocation.js +77 -35
  50. package/dist/membership.d.ts +1 -1
  51. package/dist/meta-tools.js +58 -36
  52. package/dist/module-contracts.d.ts +3 -1
  53. package/dist/native-tools.js +12 -5
  54. package/dist/node.d.ts +7 -0
  55. package/dist/node.js +82 -3
  56. package/dist/operation-activity.js +103 -0
  57. package/dist/operator-record.d.ts +8 -0
  58. package/dist/operator-record.js +15 -0
  59. package/dist/operator-ui/generated.js +2 -2
  60. package/dist/provider-call-facts.js +6 -2
  61. package/dist/provider.d.ts +1 -1
  62. package/dist/providers/_shared/google/workspace.d.ts +1 -1
  63. package/dist/providers/breeze/index.js +1 -1
  64. package/dist/providers/ccb/index.js +1 -1
  65. package/dist/providers/cloudflare/index.js +1 -1
  66. package/dist/providers/docs/index.js +1 -1
  67. package/dist/providers/drive/index.js +1 -1
  68. package/dist/providers/forms/index.js +1 -1
  69. package/dist/providers/github/index.js +1 -1
  70. package/dist/providers/gmail/index.js +1 -1
  71. package/dist/providers/granola/index.js +1 -1
  72. package/dist/providers/overflow/index.js +1 -1
  73. package/dist/providers/planning-center/index.js +1 -1
  74. package/dist/providers/planning-center/lib/constants.d.ts +1 -1
  75. package/dist/providers/quickbooks/api.js +28 -25
  76. package/dist/providers/quickbooks/index.js +1 -1
  77. package/dist/providers/quickbooks/lib/writes.js +55 -0
  78. package/dist/providers/quickbooks/safety.js +17 -5
  79. package/dist/providers/quickbooks/skill.generated.js +1 -1
  80. package/dist/providers/sheets/index.js +1 -1
  81. package/dist/providers/slides/index.js +1 -1
  82. package/dist/providers/stripe/index.js +2 -2
  83. package/dist/providers/tithely/index.js +1 -1
  84. package/dist/providers/vercel/api.js +1 -0
  85. package/dist/registry.js +16 -4
  86. package/dist/resource-read.js +21 -2
  87. package/dist/route-names.js +44 -0
  88. package/dist/routes/activity-context.js +1 -0
  89. package/dist/routes/activity-disclosure.js +26 -4
  90. package/dist/routes/activity.js +2 -1
  91. package/dist/routes/mcp.js +32 -13
  92. package/dist/routes/ui-config.js +1 -1
  93. package/dist/routes/ui-data.js +3 -2
  94. package/dist/runtime/native-storage.js +15 -0
  95. package/dist/runtime/request-reads.js +87 -10
  96. package/dist/runtime/request-scope.js +2 -1
  97. package/dist/skill-origin.js +16 -0
  98. package/dist/skills.js +132 -43
  99. package/dist/storage/d1-driver.js +33 -5
  100. package/dist/storage/keys.js +7 -1
  101. package/dist/storage/sql-native.js +85 -3
  102. package/dist/storage/sql.js +6 -0
  103. package/dist/timing-trace.js +84 -0
  104. package/dist/types.d.ts +5 -0
  105. package/dist/url-safety.js +38 -20
  106. package/dist/utility-recovery.js +33 -0
  107. package/dist/version.d.ts +1 -1
  108. package/dist/version.js +1 -1
  109. package/dist/worker.js +33 -8
  110. package/documentation/architecture.md +83 -2
  111. package/documentation/auth.md +132 -37
  112. package/documentation/code-mode.md +61 -46
  113. package/documentation/deploying.md +25 -1
  114. package/documentation/integrating.md +2 -2
  115. package/documentation/meta-tools.md +134 -15
  116. package/documentation/operating.md +86 -1
  117. package/documentation/operator-ui.md +51 -2
  118. package/documentation/providers.md +16 -14
  119. package/package.json +10 -8
  120. package/templates/node/package.json +1 -1
@@ -1,3 +1,5 @@
1
+ import { traceFor } from "./timing-trace.js";
2
+ import { markCatalogGeneration } from "./catalog-freshness.js";
1
3
  import { detach, runEdge } from "./runtime/run.js";
2
4
  import { Deferred, Effect } from "effect";
3
5
  import { authFlow, authCall, authInvoke, authValue, authMethod, authProgram, registerAuth, authStorage, authStorageMany, authMap, authAwait, } from "./auth/native.js";
@@ -412,6 +414,83 @@ const invalidateCatalogCacheNative = /* @__PURE__ */ registerAuth(invalidateCata
412
414
  advanceCatalogRevision(storage);
413
415
  }
414
416
  }));
417
+ // Only detached JSON facts cross requests: no context, storage, promises or
418
+ // callbacks. FIFO bounds retained partitions, JSON/key text (UTF-16 worst
419
+ // case) and structural slots. No WeakRef or timers required.
420
+ const catalogStores = new WeakMap();
421
+ const validatedCatalogs = new Map();
422
+ let validatedBytes = 0;
423
+ const VALIDATED_BYTES = 8 * 1024 * 1024;
424
+ function rememberValidated(key, value, bytes) {
425
+ bytes = bytes * 2 + key.length * 2;
426
+ if (bytes > VALIDATED_BYTES || validatedCatalogs.has(key))
427
+ return;
428
+ const pending = [value];
429
+ while (pending.length) {
430
+ const item = pending.pop();
431
+ if (!item || typeof item !== "object" || Object.isFrozen(item))
432
+ continue;
433
+ Object.freeze(item);
434
+ for (const child of Object.values(item)) {
435
+ // Charge structural overhead too: tiny JSON objects/arrays can occupy
436
+ // much more heap than their serialized bytes alone suggest.
437
+ bytes += 64;
438
+ if (bytes > VALIDATED_BYTES)
439
+ return;
440
+ pending.push(child);
441
+ }
442
+ }
443
+ while (validatedCatalogs.size >= 4 || validatedBytes + bytes > VALIDATED_BYTES) {
444
+ const oldest = validatedCatalogs.keys().next().value;
445
+ validatedBytes -= validatedCatalogs.get(oldest).bytes;
446
+ validatedCatalogs.delete(oldest);
447
+ }
448
+ validatedCatalogs.set(key, { value, bytes });
449
+ validatedBytes += bytes;
450
+ }
451
+ const catalogLocation = authMethod((ctx, config, authPartition) => authFlow(function* () {
452
+ const policy = serviceFor(ctx, CatalogPolicy);
453
+ const partition = JSON.stringify([
454
+ policy?.partition ?? JSON.stringify(callerOf(ctx) ?? null),
455
+ ctx.baseUrl,
456
+ ctx.publicUrl,
457
+ authPartition,
458
+ ]);
459
+ const sharedPartition = JSON.stringify([
460
+ "host-shared",
461
+ policy?.sharedPartition ?? JSON.stringify([ctx.baseUrl, ctx.publicUrl, callerOf(ctx)?.pool]),
462
+ authPartition,
463
+ ]);
464
+ const configHash = (yield authCall(fingerprintSerializedCatalog, [
465
+ JSON.stringify([
466
+ config,
467
+ policy?.defaultTtlMs ?? 300000,
468
+ policy?.minTtlMs ?? 0,
469
+ policy?.maxTtlMs ?? MAX_CACHE_TTL_MS,
470
+ ]),
471
+ ])).fingerprint;
472
+ return { partition, sharedPartition, configHash };
473
+ }));
474
+ /** Prefetch candidates only; authority, identity and acceptance stay fresh. */
475
+ export const prefetchCatalog = authMethod((ctx, connectorId, config, authPartition, sharing, credentialIdentity, negotiationKey) => authFlow(function* () {
476
+ const { partition, sharedPartition, configHash } = yield authCall(catalogLocation, [ctx, config, authPartition]);
477
+ const slots = sharing === "shared" ? [partition, sharedPartition] : [partition];
478
+ const suffixes = [];
479
+ for (const slot of slots)
480
+ suffixes.push((yield authCall(fingerprintSerializedCatalog, [
481
+ JSON.stringify(["tools/list", slot, credentialIdentity ?? null]),
482
+ ])).fingerprint);
483
+ const storage = serviceFor(ctx, CatalogPolicy)?.storage ?? ctx.storage;
484
+ yield ownerReads(Context.get(viewServices(ctx), RequestScope)).prefetch([
485
+ { storage: ctx.storage, key: negotiationKey },
486
+ { storage, key: responseCacheKeys.generation(connectorId) },
487
+ ], (storage, keys) => authStorageMany(storage, keys), {
488
+ storage,
489
+ generationKey: responseCacheKeys.generation(connectorId),
490
+ prefix: responseCacheKeys.locatorPrefix(connectorId, configHash),
491
+ suffixes,
492
+ });
493
+ }));
415
494
  /** Keys ignore self-reported serverInfo and bind the configured connector instead. */
416
495
  export function catalogClientOptions(ctx, connectorId, config, authPartition, connectionSignal, owner, sharing = "private", resolveCredentialIdentity, connectionless = false) {
417
496
  return runEdge(authProgram(catalogClientOptionsNative, [
@@ -441,26 +520,11 @@ const catalogClientOptionsNative = /* @__PURE__ */ registerAuth(catalogClientOpt
441
520
  // canonical request owner, never the initiating call's live services.
442
521
  const cacheMethod = (program) => authMethod((...args) => program(...args).pipe(Effect.provideService(RequestScope, request)));
443
522
  const policy = serviceFor(ctx, CatalogPolicy);
444
- const partition = JSON.stringify([
445
- policy?.partition ?? JSON.stringify(callerOf(ctx) ?? null),
446
- ctx.baseUrl,
447
- ctx.publicUrl,
448
- authPartition,
449
- ]);
450
- const sharedPartition = JSON.stringify([
451
- "host-shared",
452
- policy?.sharedPartition ?? JSON.stringify([ctx.baseUrl, ctx.publicUrl, callerOf(ctx)?.pool]),
453
- authPartition,
454
- ]);
523
+ const { partition, sharedPartition, configHash } = yield authCall(catalogLocation, [ctx, config, authPartition]);
455
524
  const storage = policy?.storage ?? ctx.storage;
456
- const configHash = (yield authCall(fingerprintSerializedCatalog, [
457
- JSON.stringify([
458
- config,
459
- policy?.defaultTtlMs ?? 300000,
460
- policy?.minTtlMs ?? 0,
461
- policy?.maxTtlMs ?? MAX_CACHE_TTL_MS,
462
- ]),
463
- ])).fingerprint;
525
+ let storeIdentity = catalogStores.get(storage);
526
+ if (!storeIdentity)
527
+ catalogStores.set(storage, (storeIdentity = crypto.randomUUID()));
464
528
  const scope = cacheScope(ctx, connectorId, owner);
465
529
  let unavailable = false;
466
530
  const connectionGeneration = {};
@@ -518,7 +582,8 @@ const catalogClientOptionsNative = /* @__PURE__ */ registerAuth(catalogClientOpt
518
582
  }));
519
583
  const namespace = cacheMethod((operation, memoized = false) => authFlow(function* () {
520
584
  const { stopped, generation, io } = operation;
521
- if (resolveCredentialIdentity &&
585
+ if (!memoized &&
586
+ resolveCredentialIdentity &&
522
587
  (yield authCall(io, [resolveCredentialIdentity])) !== operation.credentialIdentity)
523
588
  return undefined;
524
589
  const current = (yield authCall(currentGeneration, [operation, memoized]));
@@ -544,7 +609,9 @@ const catalogClientOptionsNative = /* @__PURE__ */ registerAuth(catalogClientOpt
544
609
  pair.length !== 2 ||
545
610
  (pair[1] !== partition && (pair[1] !== "" || sharing !== "shared")))
546
611
  return undefined;
547
- // Locating only: get's fence and set's publication re-check fresh.
612
+ // Locate with the listing's pinned identity and generation. Neither
613
+ // authorizes acceptance: get's post-chunk fence and set's publication
614
+ // re-read both, so rotation or disconnect during I/O refuses the entry.
548
615
  const prefix = (yield authCall(namespace, [operation, true]));
549
616
  if (!prefix || stopped())
550
617
  return undefined;
@@ -636,12 +703,36 @@ const catalogClientOptionsNative = /* @__PURE__ */ registerAuth(catalogClientOpt
636
703
  get: cacheMethod((key) => authFlow(function* () {
637
704
  const operation = capture();
638
705
  const { ctx, stopped, io } = operation;
706
+ const trace = traceFor(ctx.requestScope);
707
+ trace?.record("catalog.cache.lookup");
639
708
  const root = (yield authCall(address, [key, operation]));
640
709
  if (!root || stopped())
641
710
  return undefined;
642
- const m = manifest((yield authCall(io, [
643
- cacheMethod(() => authStorage(storage, "get", [root])),
644
- ])));
711
+ // The SDK probes private, then public. Derive both addresses from
712
+ // the already-bound locating generation and identity, and fetch
713
+ // them in one statement. The post-chunk namespace check stays fresh.
714
+ const listing = operation.listing;
715
+ if (listing && !listing.manifests?.has(root)) {
716
+ const slots = sharing === "shared" ? [partition, sharedPartition] : [partition];
717
+ const roots = [];
718
+ for (const slot of slots) {
719
+ const { fingerprint } = (yield authCall(fingerprintSerializedCatalog, [
720
+ JSON.stringify([key.method, slot, operation.credentialIdentity ?? null]),
721
+ ]));
722
+ roots.push(responseCacheKeys.entry(responseCacheKeys.namespace(connectorId, configHash, operation.generation.value), fingerprint));
723
+ }
724
+ yield reads.prefetch(roots.map((key) => ({ storage, key })), (storage, keys) => authStorageMany(storage, keys));
725
+ const values = [];
726
+ for (const root of roots)
727
+ values.push(yield reads.read(storage, root, authStorage(storage, "get", [root])));
728
+ listing.manifests ??= new Map();
729
+ roots.forEach((root, i) => listing.manifests.set(root, values[i] ?? null));
730
+ }
731
+ const m = manifest(listing?.manifests?.has(root)
732
+ ? listing.manifests.get(root)
733
+ : (yield authCall(io, [
734
+ cacheMethod(() => authStorage(storage, "get", [root])),
735
+ ])));
645
736
  if (stopped() ||
646
737
  !m ||
647
738
  (m.expiresAt <= Date.now() &&
@@ -670,23 +761,41 @@ const catalogClientOptionsNative = /* @__PURE__ */ registerAuth(catalogClientOpt
670
761
  !(yield authCall(namespace, [operation])) ||
671
762
  stopped())
672
763
  return undefined;
673
- const result = JSON.parse(value);
674
764
  const method = key.method;
675
- const items = catalogItems(method, result);
676
- if (!Array.isArray(items) ||
677
- items.length > MAX_CATALOG_TOOLS ||
678
- "nextCursor" in result ||
679
- (result.resultType !== undefined && result.resultType !== "complete"))
680
- return undefined;
681
- const validated = (yield authInvoke(catalogSchema(method)["~standard"], "validate", [result]));
682
- if (stopped() || validated.issues)
683
- return undefined;
684
- const clean = intake(ctx, method, validated.value);
765
+ // root binds connector/config/generation/scope/credential; partition
766
+ // also binds the current owner/deployment even for public entries.
767
+ // Hash the chunks again above, never trust the manifest alone.
768
+ const reuseKey = JSON.stringify([storeIdentity, root, partition, m.fingerprint]);
769
+ let accepted = validatedCatalogs.get(reuseKey)?.value;
770
+ trace?.record(accepted ? "catalog.validated.hit" : "catalog.validated.miss");
771
+ if (!accepted) {
772
+ const result = JSON.parse(value);
773
+ trace?.record("catalog.decode", 0, fingerprint.byteLength);
774
+ const items = catalogItems(method, result);
775
+ if (!Array.isArray(items) ||
776
+ items.length > MAX_CATALOG_TOOLS ||
777
+ "nextCursor" in result ||
778
+ (result.resultType !== undefined && result.resultType !== "complete"))
779
+ return undefined;
780
+ const validated = (yield authInvoke(catalogSchema(method)["~standard"], "validate", [result]));
781
+ trace?.record("catalog.validate");
782
+ if (stopped() || validated.issues)
783
+ return undefined;
784
+ accepted = validated.value;
785
+ }
786
+ // Redaction depends on this request's sent credentials. It must
787
+ // still run, and returns detached objects from the frozen facts.
788
+ const clean = intake(ctx, method, accepted);
789
+ trace?.record("catalog.intake");
790
+ trace?.record("catalog.chunks", 0, fingerprint.byteLength, 0, 0, m.chunkCount);
791
+ rememberValidated(reuseKey, accepted, fingerprint.byteLength);
685
792
  if (m.expiresAt <= Date.now()) {
793
+ trace?.record("catalog.cache.expired");
686
794
  if (method === "tools/list" && (!staleTools || staleTools.manifest.fetchedAt < m.fetchedAt))
687
795
  staleTools = { result: clean, manifest: m };
688
796
  return undefined;
689
797
  }
798
+ trace?.record("catalog.cache.hit");
690
799
  observeCatalogFetch(ctx, m.fetchedAt, method);
691
800
  observeCatalogExpiry(ctx, method, clean.ttlMs, m.expiresAt);
692
801
  return { value: JSON.stringify(clean), stamp: m.stamp, expiresAt: m.expiresAt, scope: m.scope };
@@ -697,6 +806,7 @@ const catalogClientOptionsNative = /* @__PURE__ */ registerAuth(catalogClientOpt
697
806
  const root = (yield authCall(address, [key, operation]));
698
807
  if (!root || stopped())
699
808
  return 0;
809
+ operation.listing?.manifests?.delete(root);
700
810
  const result = JSON.parse(entry.value);
701
811
  const method = key.method;
702
812
  if (catalogTtlMs(ctx, result.ttlMs) === 0) {
@@ -783,10 +893,12 @@ const catalogClientOptionsNative = /* @__PURE__ */ registerAuth(catalogClientOpt
783
893
  delete: cacheMethod((key) => authFlow(function* () {
784
894
  const operation = capture();
785
895
  const root = (yield authCall(address, [key, operation]));
786
- if (root && !operation.stopped())
896
+ if (root && !operation.stopped()) {
897
+ operation.listing?.manifests?.delete(root);
787
898
  (yield authInvoke(operation, "io", [
788
899
  cacheMethod(() => authStorage(storage, "delete", [root])),
789
900
  ]));
901
+ }
790
902
  })),
791
903
  evict: cacheMethod((method) => authFlow(function* () {
792
904
  if (isCatalogMethod(method))
@@ -860,7 +972,9 @@ const catalogClientOptionsNative = /* @__PURE__ */ registerAuth(catalogClientOpt
860
972
  logFailure(listingCtx.logger, "catalog read failed", failureRecord({ connector: connectorId }, error));
861
973
  }
862
974
  }
863
- return yield authCall(read, []);
975
+ const result = yield authCall(read, []);
976
+ markCatalogGeneration(listingCtx.requestScope, connectorId, listing.generation.value);
977
+ return result;
864
978
  }
865
979
  finally {
866
980
  listing.ended = true;
@@ -11,3 +11,16 @@ export function carryCatalogFreshness(source, copy) {
11
11
  export function catalogIsFresh(tools) {
12
12
  return (deadlines.get(tools) ?? 0) > Date.now();
13
13
  }
14
+ // Primitive accepted-generation evidence lives only beside the current request.
15
+ const generations = new WeakMap();
16
+ export function markCatalogGeneration(scope, connector, generation) {
17
+ if (!scope || generation === undefined)
18
+ return;
19
+ let values = generations.get(scope);
20
+ if (!values)
21
+ generations.set(scope, (values = new Map()));
22
+ values.set(connector, generation);
23
+ }
24
+ export function catalogGenerationFor(scope, connector) {
25
+ return generations.get(scope)?.get(connector);
26
+ }
@@ -1,12 +1,16 @@
1
+ import { utilityRecovery } from "./utility-recovery.js";
2
+ import { traceFor } from "./timing-trace.js";
3
+ import { utf8Length } from "./bytes.js";
4
+ import { catalogOwnerPartition } from "./catalog-policy.js";
1
5
  import { registerAuthMethod } from "./auth/native.js";
2
6
  import { META_ROUTES, routeToolInPlace } from "./agent-routes.js";
3
7
  import { withAuthorizationHandoffEffect } from "./authorization-handoff.js";
4
- import { carryCatalogFreshness, catalogIsFresh } from "./catalog-freshness.js";
8
+ import { carryCatalogFreshness, catalogIsFresh, catalogGenerationFor } from "./catalog-freshness.js";
5
9
  import { retainedCatalogIdentity } from "./catalog-cache.js";
6
10
  import { cloneTool } from "./tool-safety.js";
7
11
  import { hasControlCharacters } from "./tool-name.js";
8
12
  import { Effect, Result } from "effect";
9
- import { compactDiscoverySchema, compactDescriptionSchema, lexicalCorpusStatistics, lexicalQueryTerms, lexicalSearchQuery, matchesLexicalTerm, rankTools, schemaObjectKeys, summarizeDiscoveryDescription, summarizeDescription, typescriptSignature, } from "./catalog.js";
13
+ import { prepareLexicalCatalog, carryLexicalCatalog, compactDiscoverySchema, compactDescriptionSchema, lexicalCorpusStatistics, lexicalQueryTerms, lexicalSearchQuery, matchesLexicalTerm, rankTools, schemaObjectKeys, summarizeDiscoveryDescription, summarizeDescription, typescriptSignature, } from "./catalog.js";
10
14
  import { resolveDiscoveryConcurrency } from "./concurrency.js";
11
15
  import { boundedEchoText, classifyCallError, ConnectorCallError, framingError } from "./errors.js";
12
16
  import { connectorGuide, connectorGuideRequired, connectorGuideSummary, connectorSkillName } from "./skills.js";
@@ -20,6 +24,8 @@ import { keepErrorName } from "./redaction-identity.js";
20
24
  import { operationVocabularyMatches, searchSynonyms } from "./operation-vocabulary.js";
21
25
  /** The slice of an unscoped search's budget kept for recording what its probes saw. */
22
26
  const SETTLE_RESERVE_MS = 500;
27
+ const lexicalStores = new WeakMap();
28
+ let nextLexicalStore = 0;
23
29
  /** How long a probe claim outlives its request's discovery budget, for settlement and clock skew. */
24
30
  const CLAIM_MARGIN_MS = 2_000;
25
31
  /** A connector unscoped search skipped because its discovery failed recently. */
@@ -299,19 +305,17 @@ const catalogFailureProperties = {
299
305
  },
300
306
  },
301
307
  retry: { type: "string" },
308
+ // Handoff fields above carry the detailed authorization contract. Keep
309
+ // additive action advice compact in the always-loaded search schema.
302
310
  nextAction: {
303
311
  type: "object",
304
- required: ["tool", "arguments", "operatorHandoff"],
305
- additionalProperties: false,
312
+ anyOf: [{ required: ["tool"] }, { required: ["action"] }],
306
313
  properties: {
307
314
  tool: { const: "authorize_connector" },
308
- arguments: {
309
- type: "object",
310
- required: ["connector"],
311
- additionalProperties: false,
312
- properties: { connector: { type: "string" } },
313
- },
314
- operatorHandoff: { type: "string" },
315
+ action: { enum: ["contact_operator", "inspect_capabilities"] },
316
+ purpose: STRING,
317
+ operatorHandoff: STRING,
318
+ arguments: { type: "object", properties: { connector: STRING } },
315
319
  },
316
320
  },
317
321
  };
@@ -533,6 +537,7 @@ export class CatalogService {
533
537
  timeoutMs: this.probeTimeoutMs,
534
538
  ...(this.defer ? { defer: this.defer } : {}),
535
539
  }).pipe(Effect.map((tools) => {
540
+ traceFor(this.requestScope)?.record("catalog.accepted", 0, utf8Length(JSON.stringify(tools)), 0, 0, tools.length);
536
541
  // An authored catalog lists its declared descriptions for this request's surface.
537
542
  const connector = this.registry.getConnector(id);
538
543
  return carryCatalogFreshness(tools, tools.map((tool) => {
@@ -565,6 +570,7 @@ export class CatalogService {
565
570
  let view = views.get(key);
566
571
  if (!view) {
567
572
  view = toolsForSafety(readOnly ? tools.filter((tool) => tool.classification === "read") : tools, safety ?? "all");
573
+ carryLexicalCatalog(tools, view);
568
574
  views.set(key, view);
569
575
  }
570
576
  return view;
@@ -793,6 +799,9 @@ export class CatalogService {
793
799
  message: `Connector "${connector.id}" was skipped because its catalog lookup failed recently (${failure.code}). Search with connector "${connector.id}" to try it now.`,
794
800
  retryable: true,
795
801
  retryAfterMs: failure.retryAfterMs,
802
+ ...(["unavailable", "catalog_lookup_failed", "unsupported_capability"].includes(failure.code)
803
+ ? { nextAction: utilityRecovery(failure.code, "Catalog lookup is unavailable.").nextAction }
804
+ : {}),
796
805
  };
797
806
  }
798
807
  const classified = classifyCallError(failure, "catalog_lookup_failed");
@@ -830,6 +839,9 @@ export class CatalogService {
830
839
  error.retry =
831
840
  "Ask the provider's resource owner or administrator to grant the required permission or scope, then retry. Reconnecting alone will not grant access.";
832
841
  }
842
+ if (["unavailable", "catalog_lookup_failed", "unsupported_capability"].includes(error.code)) {
843
+ error.nextAction = utilityRecovery(error.code, error.message).nextAction;
844
+ }
833
845
  return yield* withAuthorizationHandoffEffect(this.requestScope, error);
834
846
  });
835
847
  }
@@ -877,7 +889,22 @@ export class CatalogService {
877
889
  const connectors = absence ? [] : args.connector ? (scopedConnector ? [scopedConnector] : []) : visibleConnectors;
878
890
  // Timeout messages name the route the caller used.
879
891
  const catalogs = yield* this.searchCatalogs(connectors.map((connector) => connector.id), args.connector !== undefined);
880
- const searchableCatalogs = catalogs.map((catalog, index) => Result.map(catalog, (tools) => this.searchView(connectors[index].id, tools, true, safety)));
892
+ const searchableCatalogs = catalogs.map((catalog, index) => Result.map(catalog, (tools) => {
893
+ const id = connectors[index].id;
894
+ const ctx = this.registry.contextFor(id, this.baseUrl, this.requestScope);
895
+ const { storage, partition } = catalogOwnerPartition(ctx);
896
+ let store = lexicalStores.get(storage);
897
+ if (store === undefined)
898
+ lexicalStores.set(storage, (store = ++nextLexicalStore));
899
+ prepareLexicalCatalog(tools, JSON.stringify([
900
+ store,
901
+ id,
902
+ partition,
903
+ retainedCatalogIdentity(ctx),
904
+ catalogGenerationFor(this.requestScope, id),
905
+ ]));
906
+ return this.searchView(id, tools, true, safety);
907
+ }));
881
908
  const matches = [];
882
909
  let matchMode = "all";
883
910
  // Provider-declared synonyms widen term matching within their own catalog only.
package/dist/catalog.js CHANGED
@@ -164,38 +164,93 @@ export function lexicalSearchContent(query) {
164
164
  return contentTerms.join(" ");
165
165
  }
166
166
  const searchIndexes = new WeakMap();
167
- function indexFor(tools) {
168
- let index = searchIndexes.get(tools);
169
- if (!index) {
170
- const nameTokenDocuments = new Map();
171
- const descriptionTokenDocuments = new Map();
172
- const addTokens = (target, tokens, tool) => {
173
- for (const token of new Set(tokens)) {
174
- const documents = target.get(token) ?? [];
175
- documents.push(tool);
176
- target.set(token, documents);
177
- }
178
- };
179
- const documents = tools.map((tool) => {
180
- const nameTokens = lexicalTokens(tool.name);
181
- const descriptionTokens = lexicalTokens(tool.description ?? "");
182
- addTokens(nameTokenDocuments, nameTokens, tool);
183
- addTokens(descriptionTokenDocuments, descriptionTokens, tool);
184
- return {
185
- tool,
186
- name: nameTokens.join(" "),
187
- nameTokens,
188
- descriptionTokens,
189
- };
167
+ // Only immutable token strings and numeric positions cross requests. The key
168
+ // holds exactly what buildIndex reads, in order, so schemas cost nothing here;
169
+ // it avoids hash collisions and charges its UTF-16 storage as well.
170
+ const retainedIndexes = new Map();
171
+ const INDEX_BYTES = 8 * 1024 * 1024;
172
+ let retainedBytes = 0;
173
+ function buildIndex(tools) {
174
+ const nameTokenDocuments = new Map();
175
+ const descriptionTokenDocuments = new Map();
176
+ const addTokens = (target, tokens, position) => {
177
+ for (const token of new Set(tokens)) {
178
+ const documents = target.get(token) ?? [];
179
+ documents.push(position);
180
+ target.set(token, documents);
181
+ }
182
+ };
183
+ const documents = tools.map((tool, position) => {
184
+ const nameTokens = lexicalTokens(tool.name);
185
+ const descriptionTokens = [...new Set(lexicalTokens(tool.description ?? ""))];
186
+ addTokens(nameTokenDocuments, nameTokens, position);
187
+ addTokens(descriptionTokenDocuments, descriptionTokens, position);
188
+ return Object.freeze({
189
+ name: nameTokens.join(" "),
190
+ nameTokens: Object.freeze(nameTokens),
191
+ descriptionTokens: Object.freeze(descriptionTokens),
190
192
  });
191
- index = {
192
- documents,
193
- nameTokenDocuments,
194
- descriptionTokenDocuments,
195
- };
196
- searchIndexes.set(tools, index);
193
+ });
194
+ for (const map of [nameTokenDocuments, descriptionTokenDocuments])
195
+ for (const positions of map.values())
196
+ Object.freeze(positions);
197
+ // Maps remain private to this module and are never mutated after construction.
198
+ return Object.freeze({
199
+ documents: Object.freeze(documents),
200
+ nameTokenDocuments,
201
+ descriptionTokenDocuments,
202
+ });
203
+ }
204
+ function bindIndex(tools, index) {
205
+ const view = { index, tools: new Map(tools.map((tool, position) => [position, tool])) };
206
+ searchIndexes.set(tools, view);
207
+ return view;
208
+ }
209
+ /** Only call after complete catalog acceptance, redaction and classification. */
210
+ export function prepareLexicalCatalog(tools, partition) {
211
+ if (searchIndexes.has(tools))
212
+ return;
213
+ const key = JSON.stringify([partition, tools.map((tool) => [tool.name, tool.description ?? ""])]);
214
+ const retained = retainedIndexes.get(key);
215
+ if (retained) {
216
+ bindIndex(tools, retained.index);
217
+ return;
218
+ }
219
+ const index = buildIndex(tools);
220
+ bindIndex(tools, index);
221
+ let bytes = key.length * 2 + 128;
222
+ for (const doc of index.documents) {
223
+ bytes += 128 + doc.name.length * 2;
224
+ for (const tokens of [doc.nameTokens, doc.descriptionTokens])
225
+ for (const token of tokens)
226
+ bytes += 32 + token.length * 2;
197
227
  }
198
- return index;
228
+ for (const map of [index.nameTokenDocuments, index.descriptionTokenDocuments])
229
+ for (const [token, positions] of map)
230
+ bytes += 96 + token.length * 2 + positions.length * 8;
231
+ if (bytes > INDEX_BYTES)
232
+ return;
233
+ while (retainedIndexes.size >= 4 || retainedBytes + bytes > INDEX_BYTES) {
234
+ const oldest = retainedIndexes.keys().next().value;
235
+ retainedBytes -= retainedIndexes.get(oldest).bytes;
236
+ retainedIndexes.delete(oldest);
237
+ }
238
+ retainedIndexes.set(key, { index, bytes });
239
+ retainedBytes += bytes;
240
+ }
241
+ /** Project immutable positions onto this request's currently visible objects. */
242
+ export function carryLexicalCatalog(source, visible) {
243
+ const view = searchIndexes.get(source);
244
+ if (!view || source === visible)
245
+ return;
246
+ const allowed = new Set(visible);
247
+ searchIndexes.set(visible, {
248
+ index: view.index,
249
+ tools: new Map([...view.tools].filter(([, tool]) => allowed.has(tool))),
250
+ });
251
+ }
252
+ function indexFor(tools) {
253
+ return searchIndexes.get(tools) ?? bindIndex(tools, buildIndex(tools));
199
254
  }
200
255
  /**
201
256
  * Whole-token equality is the ordinary lexical match. A deliberately narrow
@@ -289,11 +344,15 @@ export function lexicalCorpusStatistics(toolSets, query, synonymsFor) {
289
344
  for (const tools of toolSets) {
290
345
  const index = indexFor(tools);
291
346
  for (const candidate of synonymCandidates(term, synonymsFor?.(tools))) {
292
- for (const tool of index.nameTokenDocuments.get(candidate) ?? []) {
293
- termNameMatches.add(tool);
347
+ for (const position of index.index.nameTokenDocuments.get(candidate) ?? []) {
348
+ const tool = index.tools.get(position);
349
+ if (tool)
350
+ termNameMatches.add(tool);
294
351
  }
295
- for (const tool of index.descriptionTokenDocuments.get(candidate) ?? []) {
296
- termDescriptionMatches.add(tool);
352
+ for (const position of index.index.descriptionTokenDocuments.get(candidate) ?? []) {
353
+ const tool = index.tools.get(position);
354
+ if (tool)
355
+ termDescriptionMatches.add(tool);
297
356
  }
298
357
  }
299
358
  }
@@ -365,13 +424,19 @@ export function rankTools(tools, query, mode = "all", statistics = lexicalCorpus
365
424
  const exactNamePhrase = normalized(exactNameQuery);
366
425
  const terms = [...new Set(phrase.split(/\s+/).filter(Boolean))];
367
426
  const ranked = [];
368
- indexFor(tools).documents.forEach((doc, order) => {
369
- const scored = scoreDocument(doc, phrase, terms, mode, statistics);
427
+ const view = indexFor(tools);
428
+ let order = 0;
429
+ view.index.documents.forEach((doc, position) => {
430
+ const tool = view.tools.get(position);
431
+ if (!tool)
432
+ return;
433
+ const visibleOrder = order++;
434
+ const scored = scoreDocument({ ...doc, tool }, phrase, terms, mode, statistics);
370
435
  if (scored !== null) {
371
436
  ranked.push({
372
- tool: doc.tool,
437
+ tool,
373
438
  score: scored.score,
374
- order,
439
+ order: visibleOrder,
375
440
  exactName: queryContainsExactName(doc, exactNamePhrase),
376
441
  matchedTermCount: scored.matchedTermCount,
377
442
  });
package/dist/config.d.ts CHANGED
@@ -211,7 +211,7 @@ declare const pool: {
211
211
  * What this pool is for, in plain words for the model: appended to the MCP
212
212
  * `initialize` instructions at `/mcp/<name>` and `/agent/<name>`, so a
213
213
  * caller learns its scope ("Team tier: shared tools only."). Trimmed, 1 to
214
- * 500 characters, no line breaks or control characters. It grants and hides nothing; plain
214
+ * 500 UTF-16 code units, no line breaks or control characters. It grants and hides nothing; plain
215
215
  * `/mcp` never shows it.
216
216
  */
217
217
  description: import("./config-schema.js").Field<string, string | undefined, true>;
@@ -283,8 +283,9 @@ declare const connectaConfig: {
283
283
  */
284
284
  storage: import("./config-schema.js").Field<KVStorage, KVStorage, true>;
285
285
  /**
286
- * Public base URL. Defaults to the request origin per-request. Configuring an
287
- * HTTPS URL also redirects matching inbound HTTP requests to HTTPS.
286
+ * Public base URL. Defaults to the request origin per-request, except that
287
+ * clerkAuth requires it here or as its own `publicUrl`. Configuring an HTTPS
288
+ * URL also redirects matching inbound HTTP requests to HTTPS.
288
289
  */
289
290
  publicUrl: import("./config-schema.js").Field<string, string | undefined, true>;
290
291
  /**
@@ -294,7 +295,10 @@ declare const connectaConfig: {
294
295
  allowedOrigins: import("./config-schema.js").Field<readonly string[] | "*", readonly string[] | "*" | undefined, true>;
295
296
  /** Optional recorder and reader, created by activityHistory() from /activity. */
296
297
  activity: import("./config-schema.js").Field<ActivityModule, ActivityModule | undefined, true>;
297
- /** Replaceable owner-partitioned credential storage. Omit for config-owned secrets. */
298
+ /**
299
+ * Replaceable owner-partitioned credential storage. Omit for config-owned
300
+ * secrets. With any OAuth connector it must implement `seal` and `open`.
301
+ */
298
302
  vault: import("./config-schema.js").Field<CredentialVault, CredentialVault | undefined, true>;
299
303
  /** Optional connection UI, created by operatorUi() from /ui. */
300
304
  ui: import("./config-schema.js").Field<OperatorSurface, OperatorSurface | undefined, true>;
@@ -542,6 +546,8 @@ declare const connectaConfig: {
542
546
  };
543
547
  };
544
548
  };
549
+ /** Payload-free request timing logs; 0 disables tracing, 1 samples every MCP request. */
550
+ timingTraceSampleRate: import("./config-schema.js").Field<number, number, true>;
545
551
  /** Diagnostic output. Use "silent" to disable all diagnostic logging. */
546
552
  logger: import("./config-schema.js").Field<Logger | "silent", Logger, true>;
547
553
  serverInfo: import("./config-schema.js").Field<{