@zackbart/connecta 0.40.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 (115) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/bin/version.mjs +1 -1
  3. package/dist/activity.d.ts +30 -4
  4. package/dist/activity.js +28 -2
  5. package/dist/agent-observation.js +24 -2
  6. package/dist/agent-routes.js +4 -41
  7. package/dist/auth/clerk.d.ts +6 -1
  8. package/dist/auth/clerk.js +16 -0
  9. package/dist/auth/cloudflare-access.js +49 -7
  10. package/dist/auth/downstream-oauth/consent.js +79 -29
  11. package/dist/auth/downstream-oauth/grant-store.js +58 -7
  12. package/dist/auth/downstream-oauth/refresh-records.js +6 -5
  13. package/dist/auth/downstream-oauth.js +12 -4
  14. package/dist/auth/oauth-request-not-sent.js +4 -0
  15. package/dist/auth/static-oauth.js +51 -21
  16. package/dist/catalog-cache.js +62 -21
  17. package/dist/catalog-freshness.js +13 -0
  18. package/dist/catalog-service.js +39 -12
  19. package/dist/catalog.js +103 -38
  20. package/dist/config.d.ts +10 -4
  21. package/dist/config.js +30 -7
  22. package/dist/connectors/remote-mcp/call.js +1 -1
  23. package/dist/connectors/remote-mcp/connection.js +52 -18
  24. package/dist/connectors/remote-mcp/oauth.js +2 -2
  25. package/dist/connectors/remote-mcp/resources.js +5 -2
  26. package/dist/connectors/remote-mcp/transport.js +24 -6
  27. package/dist/connectors/remote-mcp.js +4 -4
  28. package/dist/credential-contract.d.ts +3 -1
  29. package/dist/describe-config.d.ts +1 -0
  30. package/dist/describe-config.js +1 -0
  31. package/dist/downstream-input.js +11 -2
  32. package/dist/errors.d.ts +8 -1
  33. package/dist/errors.js +2 -0
  34. package/dist/execute.js +20 -7
  35. package/dist/executor-result.js +1 -1
  36. package/dist/executors/quickjs-child.js +1 -1
  37. package/dist/executors/quickjs-runtime.js +1 -1
  38. package/dist/executors/quickjs.js +1 -1
  39. package/dist/guarded-transport.js +2 -0
  40. package/dist/http/client.js +7 -0
  41. package/dist/http/hatch.js +2 -0
  42. package/dist/http/node-transport.js +110 -4
  43. package/dist/http/oauth.js +17 -6
  44. package/dist/index.js +10 -21
  45. package/dist/invocation-failure.js +28 -0
  46. package/dist/invocation.js +72 -33
  47. package/dist/membership.d.ts +1 -1
  48. package/dist/meta-tools.js +27 -13
  49. package/dist/module-contracts.d.ts +3 -1
  50. package/dist/native-tools.js +11 -4
  51. package/dist/node.d.ts +7 -0
  52. package/dist/node.js +82 -3
  53. package/dist/operation-activity.js +103 -0
  54. package/dist/operator-record.d.ts +8 -0
  55. package/dist/operator-record.js +15 -0
  56. package/dist/operator-ui/generated.js +2 -2
  57. package/dist/provider.d.ts +1 -1
  58. package/dist/providers/_shared/google/workspace.d.ts +1 -1
  59. package/dist/providers/breeze/index.js +1 -1
  60. package/dist/providers/ccb/index.js +1 -1
  61. package/dist/providers/cloudflare/index.js +1 -1
  62. package/dist/providers/docs/index.js +1 -1
  63. package/dist/providers/drive/index.js +1 -1
  64. package/dist/providers/forms/index.js +1 -1
  65. package/dist/providers/github/index.js +1 -1
  66. package/dist/providers/gmail/index.js +1 -1
  67. package/dist/providers/granola/index.js +1 -1
  68. package/dist/providers/overflow/index.js +1 -1
  69. package/dist/providers/planning-center/index.js +1 -1
  70. package/dist/providers/planning-center/lib/constants.d.ts +1 -1
  71. package/dist/providers/quickbooks/api.js +28 -25
  72. package/dist/providers/quickbooks/index.js +1 -1
  73. package/dist/providers/quickbooks/lib/writes.js +55 -0
  74. package/dist/providers/quickbooks/safety.js +17 -5
  75. package/dist/providers/quickbooks/skill.generated.js +1 -1
  76. package/dist/providers/sheets/index.js +1 -1
  77. package/dist/providers/slides/index.js +1 -1
  78. package/dist/providers/stripe/index.js +2 -2
  79. package/dist/providers/tithely/index.js +1 -1
  80. package/dist/providers/vercel/api.js +1 -0
  81. package/dist/registry.js +16 -4
  82. package/dist/resource-read.js +21 -2
  83. package/dist/route-names.js +44 -0
  84. package/dist/routes/activity-context.js +1 -0
  85. package/dist/routes/activity-disclosure.js +26 -4
  86. package/dist/routes/activity.js +2 -1
  87. package/dist/routes/mcp.js +32 -13
  88. package/dist/routes/ui-config.js +1 -1
  89. package/dist/routes/ui-data.js +3 -2
  90. package/dist/runtime/native-storage.js +12 -0
  91. package/dist/runtime/request-reads.js +66 -14
  92. package/dist/runtime/request-scope.js +2 -1
  93. package/dist/skill-origin.js +16 -0
  94. package/dist/skills.js +132 -43
  95. package/dist/storage/d1-driver.js +33 -5
  96. package/dist/storage/keys.js +7 -1
  97. package/dist/storage/sql-native.js +60 -3
  98. package/dist/storage/sql.js +6 -0
  99. package/dist/timing-trace.js +84 -0
  100. package/dist/types.d.ts +5 -0
  101. package/dist/url-safety.js +38 -20
  102. package/dist/utility-recovery.js +33 -0
  103. package/dist/version.d.ts +1 -1
  104. package/dist/version.js +1 -1
  105. package/dist/worker.js +33 -8
  106. package/documentation/architecture.md +48 -1
  107. package/documentation/auth.md +116 -36
  108. package/documentation/code-mode.md +61 -46
  109. package/documentation/deploying.md +25 -1
  110. package/documentation/meta-tools.md +113 -11
  111. package/documentation/operating.md +86 -1
  112. package/documentation/operator-ui.md +51 -2
  113. package/documentation/providers.md +16 -14
  114. package/package.json +10 -8
  115. 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";
@@ -446,6 +448,49 @@ function rememberValidated(key, value, bytes) {
446
448
  validatedCatalogs.set(key, { value, bytes });
447
449
  validatedBytes += bytes;
448
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
+ }));
449
494
  /** Keys ignore self-reported serverInfo and bind the configured connector instead. */
450
495
  export function catalogClientOptions(ctx, connectorId, config, authPartition, connectionSignal, owner, sharing = "private", resolveCredentialIdentity, connectionless = false) {
451
496
  return runEdge(authProgram(catalogClientOptionsNative, [
@@ -475,29 +520,11 @@ const catalogClientOptionsNative = /* @__PURE__ */ registerAuth(catalogClientOpt
475
520
  // canonical request owner, never the initiating call's live services.
476
521
  const cacheMethod = (program) => authMethod((...args) => program(...args).pipe(Effect.provideService(RequestScope, request)));
477
522
  const policy = serviceFor(ctx, CatalogPolicy);
478
- const partition = JSON.stringify([
479
- policy?.partition ?? JSON.stringify(callerOf(ctx) ?? null),
480
- ctx.baseUrl,
481
- ctx.publicUrl,
482
- authPartition,
483
- ]);
484
- const sharedPartition = JSON.stringify([
485
- "host-shared",
486
- policy?.sharedPartition ?? JSON.stringify([ctx.baseUrl, ctx.publicUrl, callerOf(ctx)?.pool]),
487
- authPartition,
488
- ]);
523
+ const { partition, sharedPartition, configHash } = yield authCall(catalogLocation, [ctx, config, authPartition]);
489
524
  const storage = policy?.storage ?? ctx.storage;
490
525
  let storeIdentity = catalogStores.get(storage);
491
526
  if (!storeIdentity)
492
527
  catalogStores.set(storage, (storeIdentity = crypto.randomUUID()));
493
- const configHash = (yield authCall(fingerprintSerializedCatalog, [
494
- JSON.stringify([
495
- config,
496
- policy?.defaultTtlMs ?? 300000,
497
- policy?.minTtlMs ?? 0,
498
- policy?.maxTtlMs ?? MAX_CACHE_TTL_MS,
499
- ]),
500
- ])).fingerprint;
501
528
  const scope = cacheScope(ctx, connectorId, owner);
502
529
  let unavailable = false;
503
530
  const connectionGeneration = {};
@@ -676,6 +703,8 @@ const catalogClientOptionsNative = /* @__PURE__ */ registerAuth(catalogClientOpt
676
703
  get: cacheMethod((key) => authFlow(function* () {
677
704
  const operation = capture();
678
705
  const { ctx, stopped, io } = operation;
706
+ const trace = traceFor(ctx.requestScope);
707
+ trace?.record("catalog.cache.lookup");
679
708
  const root = (yield authCall(address, [key, operation]));
680
709
  if (!root || stopped())
681
710
  return undefined;
@@ -692,7 +721,10 @@ const catalogClientOptionsNative = /* @__PURE__ */ registerAuth(catalogClientOpt
692
721
  ]));
693
722
  roots.push(responseCacheKeys.entry(responseCacheKeys.namespace(connectorId, configHash, operation.generation.value), fingerprint));
694
723
  }
695
- const values = (yield authCall(io, [cacheMethod(() => authStorageMany(storage, roots))]));
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])));
696
728
  listing.manifests ??= new Map();
697
729
  roots.forEach((root, i) => listing.manifests.set(root, values[i] ?? null));
698
730
  }
@@ -735,8 +767,10 @@ const catalogClientOptionsNative = /* @__PURE__ */ registerAuth(catalogClientOpt
735
767
  // Hash the chunks again above, never trust the manifest alone.
736
768
  const reuseKey = JSON.stringify([storeIdentity, root, partition, m.fingerprint]);
737
769
  let accepted = validatedCatalogs.get(reuseKey)?.value;
770
+ trace?.record(accepted ? "catalog.validated.hit" : "catalog.validated.miss");
738
771
  if (!accepted) {
739
772
  const result = JSON.parse(value);
773
+ trace?.record("catalog.decode", 0, fingerprint.byteLength);
740
774
  const items = catalogItems(method, result);
741
775
  if (!Array.isArray(items) ||
742
776
  items.length > MAX_CATALOG_TOOLS ||
@@ -744,6 +778,7 @@ const catalogClientOptionsNative = /* @__PURE__ */ registerAuth(catalogClientOpt
744
778
  (result.resultType !== undefined && result.resultType !== "complete"))
745
779
  return undefined;
746
780
  const validated = (yield authInvoke(catalogSchema(method)["~standard"], "validate", [result]));
781
+ trace?.record("catalog.validate");
747
782
  if (stopped() || validated.issues)
748
783
  return undefined;
749
784
  accepted = validated.value;
@@ -751,12 +786,16 @@ const catalogClientOptionsNative = /* @__PURE__ */ registerAuth(catalogClientOpt
751
786
  // Redaction depends on this request's sent credentials. It must
752
787
  // still run, and returns detached objects from the frozen facts.
753
788
  const clean = intake(ctx, method, accepted);
789
+ trace?.record("catalog.intake");
790
+ trace?.record("catalog.chunks", 0, fingerprint.byteLength, 0, 0, m.chunkCount);
754
791
  rememberValidated(reuseKey, accepted, fingerprint.byteLength);
755
792
  if (m.expiresAt <= Date.now()) {
793
+ trace?.record("catalog.cache.expired");
756
794
  if (method === "tools/list" && (!staleTools || staleTools.manifest.fetchedAt < m.fetchedAt))
757
795
  staleTools = { result: clean, manifest: m };
758
796
  return undefined;
759
797
  }
798
+ trace?.record("catalog.cache.hit");
760
799
  observeCatalogFetch(ctx, m.fetchedAt, method);
761
800
  observeCatalogExpiry(ctx, method, clean.ttlMs, m.expiresAt);
762
801
  return { value: JSON.stringify(clean), stamp: m.stamp, expiresAt: m.expiresAt, scope: m.scope };
@@ -933,7 +972,9 @@ const catalogClientOptionsNative = /* @__PURE__ */ registerAuth(catalogClientOpt
933
972
  logFailure(listingCtx.logger, "catalog read failed", failureRecord({ connector: connectorId }, error));
934
973
  }
935
974
  }
936
- return yield authCall(read, []);
975
+ const result = yield authCall(read, []);
976
+ markCatalogGeneration(listingCtx.requestScope, connectorId, listing.generation.value);
977
+ return result;
937
978
  }
938
979
  finally {
939
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<{
package/dist/config.js CHANGED
@@ -189,7 +189,7 @@ function assertFlag(value, path) {
189
189
  if (typeof value !== "boolean")
190
190
  throw new ConfigError(`${path} must be true or false.`);
191
191
  }
192
- /** Longest pool `description`, in characters, after trimming. */
192
+ /** Longest pool `description`, in UTF-16 code units, after trimming. */
193
193
  const MAX_POOL_DESCRIPTION_LENGTH = 500;
194
194
  /**
195
195
  * A pool description is one line of prose for the model. C0 and C1 controls
@@ -209,9 +209,9 @@ function assertPoolDescription(value, path) {
209
209
  const trimmed = typeof value === "string" ? value.trim() : undefined;
210
210
  if (trimmed === undefined ||
211
211
  trimmed.length === 0 ||
212
- [...trimmed].length > MAX_POOL_DESCRIPTION_LENGTH ||
212
+ trimmed.length > MAX_POOL_DESCRIPTION_LENGTH ||
213
213
  hasLineBreakOrControlCharacter(trimmed)) {
214
- throw new ConfigError(`${path} must be text of 1 to ${MAX_POOL_DESCRIPTION_LENGTH} characters after trimming, with no line breaks or control characters.`);
214
+ throw new ConfigError(`${path} must be text of 1 to ${MAX_POOL_DESCRIPTION_LENGTH} UTF-16 code units after trimming, with no line breaks or control characters.`);
215
215
  }
216
216
  }
217
217
  /** Shape only; each reference resolves against auth and agents in createConnecta. */
@@ -263,7 +263,7 @@ const pool = {
263
263
  * What this pool is for, in plain words for the model: appended to the MCP
264
264
  * `initialize` instructions at `/mcp/<name>` and `/agent/<name>`, so a
265
265
  * caller learns its scope ("Team tier: shared tools only."). Trimmed, 1 to
266
- * 500 characters, no line breaks or control characters. It grants and hides nothing; plain
266
+ * 500 UTF-16 code units, no line breaks or control characters. It grants and hides nothing; plain
267
267
  * `/mcp` never shows it.
268
268
  */
269
269
  description: opaque({ check: assertPoolDescription }),
@@ -396,8 +396,9 @@ const connectaConfig = {
396
396
  resolve: (value) => value ?? memoryStorage(),
397
397
  }),
398
398
  /**
399
- * Public base URL. Defaults to the request origin per-request. Configuring an
400
- * HTTPS URL also redirects matching inbound HTTP requests to HTTPS.
399
+ * Public base URL. Defaults to the request origin per-request, except that
400
+ * clerkAuth requires it here or as its own `publicUrl`. Configuring an HTTPS
401
+ * URL also redirects matching inbound HTTP requests to HTTPS.
401
402
  */
402
403
  publicUrl: opaque({ check: httpUrl }),
403
404
  /**
@@ -419,7 +420,10 @@ const connectaConfig = {
419
420
  }
420
421
  },
421
422
  }),
422
- /** Replaceable owner-partitioned credential storage. Omit for config-owned secrets. */
423
+ /**
424
+ * Replaceable owner-partitioned credential storage. Omit for config-owned
425
+ * secrets. With any OAuth connector it must implement `seal` and `open`.
426
+ */
423
427
  vault: opaque({
424
428
  check: (value) => {
425
429
  if (!hasMethods(value, ["get", "getAll", "set", "setAll", "metadata", "delete"]) ||
@@ -464,6 +468,14 @@ const connectaConfig = {
464
468
  * saturation.
465
469
  */
466
470
  admission: object(admission),
471
+ /** Payload-free request timing logs; 0 disables tracing, 1 samples every MCP request. */
472
+ timingTraceSampleRate: opaque({
473
+ check: (value, path) => {
474
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 1)
475
+ throw new ConfigError(`${path} must be a finite number between 0 and 1.`);
476
+ },
477
+ resolve: (value) => value ?? 0,
478
+ }),
467
479
  /** Diagnostic output. Use "silent" to disable all diagnostic logging. */
468
480
  logger: opaque({
469
481
  check: (value, path) => {
@@ -528,10 +540,21 @@ function assertCoherent(config) {
528
540
  config.discovery.catalogMaxTtlSeconds > 86_400) {
529
541
  throw new ConfigError("ConnectaConfig.discovery requires catalogMinTtlSeconds <= catalogMaxTtlSeconds <= 86400.");
530
542
  }
543
+ const { vault } = config;
544
+ const oauth = config.connectors.find((connector) => connector.startAuth);
545
+ if (oauth && vault && (typeof vault.seal !== "function" || typeof vault.open !== "function")) {
546
+ throw new ConfigError(`ConnectaConfig.vault must implement both seal and open: connector "${oauth.id}" stores downstream OAuth ` +
547
+ "tokens, client registration and PKCE verifiers through it. Use encryptedCredentialVault(storage, key) " +
548
+ "from @zackbart/connecta/credentials, or add both methods.");
549
+ }
531
550
  if (config.agents.some((agent) => !agent.vercel) && !config.accessTokens) {
532
551
  throw new ConfigError("ConnectaConfig.agents needs accessTokens: accessTokens(storage) from @zackbart/connecta/auth/access-tokens " +
533
552
  "holds each agent's token slot.");
534
553
  }
554
+ if (!config.publicUrl && config.auth.some((provider) => provider.requiresPublicUrl)) {
555
+ throw new ConfigError("ConnectaConfig.publicUrl is required with clerkAuth, which checks tokens and sessions against it: " +
556
+ "set it, or clerkAuth({ publicUrl }), to this deployment's URL, http://localhost:<port> for local development.");
557
+ }
535
558
  }
536
559
  /**
537
560
  * Validate a configuration and apply every default. Unknown keys are refused
@@ -55,7 +55,7 @@ export function remoteMcpCall(rt) {
55
55
  }
56
56
  : state.toolDefinitions.get(name);
57
57
  if (toolDefinition?.execution?.taskSupport === "required") {
58
- throw new ConnectorCallError("connector_call_failed", `Tool "${name}" requires task-based execution, which Connecta does not support.`);
58
+ throw new ConnectorCallError("unsupported_capability", `Tool "${name}" requires task-based execution, which Connecta does not support.`);
59
59
  }
60
60
  let output;
61
61
  const outputError = (code, message) => rt.atMcpBoundary(ctx, new ProtocolError(code, message), "tools/call", client.transport, [ctx.signal]);