@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
@@ -13,11 +13,11 @@
13
13
  * legacy IPv4 spelling (`2130706433`, `0x7f.1`, `0177.0.0.1`) into dotted
14
14
  * decimal and every IPv6 literal into compressed hex, so one canonical form is
15
15
  * all this module reads. It never resolves a name: that needs DNS, which the
16
- * Workers-safe core cannot do, and a name that resolves somewhere private — or
17
- * rebinds after the check — is out of scope. Pure Web API, no `node:` import.
16
+ * Workers-safe core cannot do. The owned Node transport additionally resolves
17
+ * and pins learned destinations at connect time. Pure Web API, no `node:` import.
18
18
  */
19
19
  function classifyIpv4(octets) {
20
- const [a = 0, b = 0] = octets;
20
+ const [a = 0, b = 0, c = 0] = octets;
21
21
  if (a === 127)
22
22
  return "loopback";
23
23
  if (a === 0 ||
@@ -25,7 +25,14 @@ function classifyIpv4(octets) {
25
25
  (a === 172 && b >= 16 && b <= 31) ||
26
26
  (a === 192 && b === 168) ||
27
27
  (a === 169 && b === 254) ||
28
- (a === 100 && b >= 64 && b <= 127)) {
28
+ (a === 100 && b >= 64 && b <= 127) ||
29
+ (a === 192 && b === 0 && (c === 0 || c === 2)) || // IETF protocol, documentation
30
+ (a === 192 && b === 88 && c === 99) || // 6to4 relay anycast
31
+ (a === 198 && (b === 18 || b === 19)) || // benchmarking
32
+ (a === 198 && b === 51 && c === 100) || // documentation
33
+ (a === 203 && b === 0 && c === 113) || // documentation
34
+ a >= 224 // multicast, reserved, broadcast
35
+ ) {
29
36
  return "private";
30
37
  }
31
38
  return "public";
@@ -61,24 +68,35 @@ function ipv6Pieces(host) {
61
68
  return [...head, ...Array.from({ length: missing }, () => 0), ...tail];
62
69
  }
63
70
  function classifyIpv6(pieces) {
64
- const [first = 0] = pieces;
65
- const leadingZeros = pieces.slice(0, 5).every((piece) => piece === 0);
66
- if (leadingZeros && pieces[5] === 0 && pieces[6] === 0) {
67
- if (pieces[7] === 0)
68
- return "private"; // ::
69
- if (pieces[7] === 1)
70
- return "loopback"; // ::1
71
+ const [p0 = 0, p1 = 0, p2 = 0, p3 = 0, p4 = 0, p5 = 0, p6 = 0, p7 = 0] = pieces;
72
+ const v4 = (high, low) => classifyIpv4([high >> 8, high & 0xff, low >> 8, low & 0xff]);
73
+ // A transitional form reaches, at most, the IPv4 address it carries. Only a
74
+ // mapped address can reach this host, so only it stays `loopback`; every
75
+ // other non-public carrier is `private`, keeping it out of a loopback
76
+ // connector's learned URLs.
77
+ const carried = (high, low) => (v4(high, low) === "public" ? "public" : "private");
78
+ const zeros = p0 === 0 && p1 === 0 && p2 === 0 && p3 === 0;
79
+ if (zeros && p4 === 0) {
80
+ if (p5 === 0xffff)
81
+ return v4(p6, p7); // ::ffff:a.b.c.d mapped
82
+ if (p5 === 0) {
83
+ if (p6 === 0 && p7 === 1)
84
+ return "loopback"; // ::1
85
+ return carried(p6, p7); // :: and ::a.b.c.d compatible
86
+ }
71
87
  }
72
- // ::ffff:a.b.c.d reaches the mapped IPv4 destination; classify that.
73
- if (leadingZeros && pieces[5] === 0xffff) {
74
- const high = pieces[6] ?? 0;
75
- const low = pieces[7] ?? 0;
76
- return classifyIpv4([high >> 8, high & 0xff, low >> 8, low & 0xff]);
88
+ if (zeros && p4 === 0xffff && p5 === 0)
89
+ return carried(p6, p7); // ::ffff:0:a.b.c.d translated
90
+ if (p0 === 0x64 && p1 === 0xff9b && p2 === 0 && p3 === 0 && p4 === 0 && p5 === 0)
91
+ return carried(p6, p7); // NAT64
92
+ if (p0 === 0x2002)
93
+ return carried(p1, p2); // 6to4
94
+ // Global unicast is 2000::/3. Outside it: unique-local, link-local,
95
+ // site-local, multicast, local-use NAT64, reserved. Inside it, special-purpose
96
+ // 2001::/23 and documentation 2001:db8::/32 and 3fff::/20 are not destinations.
97
+ if ((p0 & 0xe000) !== 0x2000 || (p0 === 0x2001 && (p1 < 0x200 || p1 === 0xdb8)) || (p0 === 0x3fff && p1 < 0x1000)) {
98
+ return "private";
77
99
  }
78
- if ((first & 0xfe00) === 0xfc00)
79
- return "private"; // fc00::/7
80
- if ((first & 0xffc0) === 0xfe80)
81
- return "private"; // fe80::/10
82
100
  return "public";
83
101
  }
84
102
  /**
@@ -0,0 +1,33 @@
1
+ /** Actions outside the tool namespace never invent a route on a serving surface. */
2
+ export function utilityRecovery(code, message, retryable = false) {
3
+ return {
4
+ code,
5
+ message,
6
+ retryable,
7
+ nextAction: {
8
+ action: ["unsupported_capability", "input_required_unsupported", "invalid_args"].includes(code)
9
+ ? "inspect_capabilities"
10
+ : code === "not_found"
11
+ ? "recover_result"
12
+ : "contact_operator",
13
+ purpose: ["unsupported_capability", "input_required_unsupported", "invalid_args"].includes(code)
14
+ ? "Inspect the supported utility input and client capabilities before a fresh call. Never repeat a write whose outcome is unknown."
15
+ : code === "not_found"
16
+ ? "Use a retained result handle if available. Otherwise report the missing output; never repeat a write to recover output."
17
+ : "Ask the operator to restore availability. Retry reads only after recovery; reconcile any write whose outcome is unknown.",
18
+ },
19
+ };
20
+ }
21
+ /** Missing skill names need local discovery, never service restoration or result recovery. */
22
+ export function skillLookupRecovery(message) {
23
+ return {
24
+ code: "not_found",
25
+ message,
26
+ retryable: false,
27
+ nextAction: {
28
+ tool: "skills",
29
+ arguments: {},
30
+ purpose: "List available skills, then fetch an exact name from that list.",
31
+ },
32
+ };
33
+ }
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const CONNECTA_VERSION = "0.40.0";
1
+ export declare const CONNECTA_VERSION = "0.41.0";
package/dist/version.js CHANGED
@@ -1,3 +1,3 @@
1
1
  // Generated from package.json by scripts/generate-version.mjs.
2
2
  // Workers-safe: no runtime filesystem read or deployment override.
3
- export const CONNECTA_VERSION = "0.40.0";
3
+ export const CONNECTA_VERSION = "0.41.0";
package/dist/worker.js CHANGED
@@ -99,6 +99,20 @@ export function workerExecutor(options) {
99
99
  // The SDK prepares its source/dispatchers against this inert loader.
100
100
  // Actual Loader and RPC acquisition belongs to the native program below.
101
101
  let prepared;
102
+ // Upstream 0.5.5+ answers every host call that settles after its
103
+ // evaluate() with a late-call error. Its inert evaluate stays pending
104
+ // until the native evaluation ends, so the SDK's gate stays open for the
105
+ // whole run and closes once the run is over or the lease is released.
106
+ let evaluating;
107
+ const evaluated = new Promise((resolve) => {
108
+ evaluating = resolve;
109
+ });
110
+ let settleUpstream;
111
+ const endUpstream = () => {
112
+ const settle = settleUpstream;
113
+ settleUpstream = undefined;
114
+ settle?.();
115
+ };
102
116
  const dispose = (resource) => Effect.sync(() => {
103
117
  try {
104
118
  const method = Reflect.get(resource, Symbol.dispose);
@@ -309,7 +323,10 @@ const {
309
323
  getEntrypoint: () => ({
310
324
  evaluate: (...evaluateArgs) => {
311
325
  acquisition.args = evaluateArgs;
312
- return Promise.resolve({ result: undefined });
326
+ return new Promise((resolve) => {
327
+ settleUpstream = () => resolve({ result: undefined });
328
+ evaluating?.();
329
+ });
313
330
  },
314
331
  }),
315
332
  };
@@ -342,15 +359,21 @@ const {
342
359
  const started = Date.now();
343
360
  const operation = Effect.gen(function* () {
344
361
  const setup = yield* Effect.tryPromise({
345
- try: () => new DynamicWorkerExecutor({
346
- loader,
347
- ...(options.timeout !== undefined ? { timeout: options.timeout } : {}),
348
- }).execute(code, detached),
362
+ try: () => {
363
+ const upstream = new DynamicWorkerExecutor({
364
+ loader,
365
+ ...(options.timeout !== undefined ? { timeout: options.timeout } : {}),
366
+ }).execute(code, detached);
367
+ // The SDK's own result, which settles only after endUpstream(),
368
+ // carries nothing; the native evaluation below owns the outcome.
369
+ upstream.catch(() => undefined);
370
+ return Promise.race([upstream, evaluated.then(() => undefined)]);
371
+ },
349
372
  catch: (error) => error,
350
373
  });
351
374
  // Provider validation can return before the SDK reaches its loader.
352
- if (!prepared)
353
- return setup;
375
+ if (setup !== undefined || !prepared)
376
+ return setup ?? { result: undefined };
354
377
  const load = prepared.load;
355
378
  const args = prepared.args;
356
379
  const open = () => Effect.suspend(() => released || resources.state._tag === "Closed"
@@ -369,7 +392,7 @@ const {
369
392
  try: () => Reflect.apply(Reflect.get(entrypoint, "evaluate"), entrypoint, args ?? []),
370
393
  catch: (error) => error,
371
394
  });
372
- }).pipe(Scope.provide(resources));
395
+ }).pipe(Scope.provide(resources), Effect.ensuring(Effect.sync(endUpstream)));
373
396
  const result = yield* evaluation;
374
397
  const outcome = result;
375
398
  if (outcome.failure) {
@@ -407,6 +430,7 @@ const {
407
430
  }));
408
431
  }).pipe(Effect.raceFirst(Deferred.await(stopped)), Effect.catchDefect((error) => Effect.fail(error)), Effect.ensuring(Effect.sync(() => {
409
432
  runEnded = true;
433
+ endUpstream();
410
434
  hostProviders = undefined;
411
435
  prepared = undefined;
412
436
  hostFailures.clear();
@@ -420,6 +444,7 @@ const {
420
444
  if (released)
421
445
  return;
422
446
  released = true;
447
+ endUpstream();
423
448
  hostProviders = undefined;
424
449
  prepared = undefined;
425
450
  hostFailures.clear();
@@ -1042,7 +1042,15 @@ run of K = 8 consecutive UTF-16 units of a registered form of a value at least K
1042
1042
  long, in the raw text or any JSON-decoded layer; text cut where the cut can end
1043
1043
  inside a form loses that unfinished tail, however short.** Runs formed from
1044
1044
  placeholder text and kept text or numbers are covered too; the placeholder
1045
- itself is reserved replacement text. Registered forms are
1045
+ itself is reserved replacement text. The guarantee excludes an occurrence made
1046
+ entirely of reserved replacement text, including a run across the seam between
1047
+ adjacent markers, whether or not the registered form contains a complete
1048
+ `[redacted]`. For example, register `secretsecret` and `ted][red`, then redact
1049
+ `["secretsecret", "secretsecret"]`: the returned leaves may be
1050
+ `["[redacted]", "[redacted]"]`. Their joined projection spells the second
1051
+ registered value using only reserved replacement text; no original secret
1052
+ units survive. An occurrence that includes retained original text stays covered.
1053
+ Registered forms are
1046
1054
  the raw value, its URL/form encodings (either percent hex case), base64 and
1047
1055
  base64url, and the `Bearer `/`token ` framings. The framing words are public, so
1048
1056
  no indexed run starts inside them; a run reaching K units of the value is
@@ -3772,6 +3780,16 @@ change. All 88 declarations still exclude Effect and have complete references.
3772
3780
 
3773
3781
  ### Native remote MCP programs (contributor)
3774
3782
 
3783
+ Legacy session termination may read a token before rotation and send its DELETE
3784
+ on the wire after rotation. This is accepted only for bounded, headers-only
3785
+ termination of the old session, never for application traffic (`tools/call`,
3786
+ `tools/list`, or other RPCs). Connection scope close retains the same cleanup
3787
+ completion after revocation; later connector close joins it so the runtime's
3788
+ deferred hook (Workers `waitUntil`) owns the bounded tail. Abandoned leases from
3789
+ rotation and failed connects, and authorization exchange transports, retain their
3790
+ cleanup in that same request state. Termination and local close keep their
3791
+ existing one-second budgets; the caller-facing close window remains 100 ms.
3792
+
3775
3793
  Remote MCP connection joins, credential and epoch checks, catalog intake,
3776
3794
  negotiation, calls, resources, Skills, auth and status compose native programs.
3777
3795
  `connectors/remote-mcp.ts` keeps the published options, the redirect guard and
@@ -3918,3 +3936,32 @@ cache. Exact authored names and complete authored matches precede API pointers;
3918
3936
  ordinary result limits, offsets, catalog failure disclosure and byte bounds apply.
3919
3937
  The published discovery contract states compilation, query and cache bounds.
3920
3938
  No request identity, signal, credential or promise enters a vocabulary cache.
3939
+
3940
+ SQL catalog locating snapshots derive at most two manifest candidates from the
3941
+ stored generation alongside negotiation and generation rows. These candidates
3942
+ are request-local hints with exact config, owner and credential partitions.
3943
+ Complete chunk verification and a separate later primary generation and identity
3944
+ fence decide acceptance. OAuth invocation and dispatch reads stay separate.
3945
+
3946
+ SQL result stashes write at most 64 trailing rows per awaited transaction, with one
3947
+ expiry sweep per batch. Header publication follows confirmed trailing writes in a
3948
+ separate transaction; uncertain writes retain their ledger charge until expiry.
3949
+ Custom storage keeps sequential writes.
3950
+
3951
+ Admission observation sampling retains at most 256 scalar deadlines per live
3952
+ physical storage identity in an isolate. Only a recent valid stored timestamp
3953
+ or confirmed CAS populates it, for at most one minute. This telemetry cache
3954
+ never answers token admission; every request still checks current credentials.
3955
+
3956
+ ### Lexical token reuse (contributor)
3957
+
3958
+ Search may retain four complete accepted catalog token indexes, bounded by an
3959
+ 8 MiB estimate including key text, token strings, postings and structural slots.
3960
+ Identity includes complete served content (including schemas, classification and
3961
+ order), connector, physical store, owner partition/revisions and the downstream catalog
3962
+ generation observed by the current successful listing. The generation marker
3963
+ is request-local primitive evidence; it grants no cache acceptance or authority. Only immutable
3964
+ strings and numeric positions cross requests. Search views map positions to the
3965
+ current request's definitions after owner visibility, classification, redaction
3966
+ and endpoint restrictions; no query results are retained. Partial failed
3967
+ catalogs cannot enter this cache. Eviction changes cost only, never results.
@@ -295,7 +295,7 @@ It has no queue and no configuration option. When full, a request receives HTTP
295
295
  `error: { code: "authentication_busy", message: "Authentication capacity reached.", retryable: true }`.
296
296
  The operator log records only the checked `authentication_busy` reason, never
297
297
  credentials or identity-provider diagnostics. Retry after the indicated delay;
298
- Connecta does not automatically retry the authentication.
298
+ Connecta does not automatically retry authentication, except for one rejected Cloudflare Access identity lookup inside its existing deadline (see [Access on Workers](#cloudflare-access-on-workers)).
299
299
 
300
300
  Underlying work stays charged until it settles, including cancellation cleanup.
301
301
  A caller leaving alone never releases capacity. After an authorization's hard
@@ -399,7 +399,9 @@ This bound is independent of MCP request/program admission and downstream call
399
399
  limits. `Mcp-Method: subscriptions/listen` still defers its MCP request permit:
400
400
  after authentication and endpoint admission, a body-confirmed modern listen is
401
401
  refused promptly even when execution capacity is full. It always passes the
402
- authentication bound, and no request body is read before authentication.
402
+ authentication bound, and the MCP route reads no request body before
403
+ authentication. On Node, `listen()` has already buffered the body up to its own
404
+ [pre-auth cap](./deploying.md#start-on-node) before core sees the request.
403
405
 
404
406
  Routes that do not invoke an inbound verifier consume no authentication slot:
405
407
  health, public OAuth discovery and client metadata, UI shells and static assets,
@@ -558,6 +560,7 @@ createConnecta({
558
560
  storage,
559
561
  agents: ["assistant", "ci"],
560
562
  accessTokens: accessTokens(storage),
563
+ publicUrl: "https://connecta.example.com",
561
564
  auth: clerkAuth({ publishableKey, secretKey }),
562
565
  ui: operatorUi(),
563
566
  maintainers: ["your-operator-id"],
@@ -948,6 +951,7 @@ cache entries remain partitioned by the admitted pool.
948
951
  createConnecta({
949
952
  storage,
950
953
  accessTokens: accessTokens(storage),
954
+ publicUrl: "https://connecta.example.com",
951
955
  auth: clerkAuth({ publishableKey, secretKey }),
952
956
  readOnly: false, // required; pools without their own readOnly inherit it
953
957
  maintainers: ["user_owner"],
@@ -971,19 +975,19 @@ warning says, every pool path answers the 404 because nobody can be a member,
971
975
  and every agent endpoint answers the machine-only 401 because no agent can be
972
976
  admitted.
973
977
 
974
- | Rule | Contract |
975
- | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
976
- | Scope | `/mcp/<pool>` and `/agent/<pool>` serve exactly the pool's `tools` (connector ids or exact `connector.tool` addresses), with the pool's `readOnly`, to its member people and member agents respectively. Plain `/mcp` serves the whole catalog to maintainers and the unique member pool to other people. Neither intersects with or widens anything. |
977
- | Refusal | A person with zero or several memberships at `/mcp`, a non-member, and an undeclared pool get an identical 404 status, body, and headers, after auth. The operator log records `not_maintainer` (zero memberships), `ambiguous_pool` (several memberships), `not_pool_member`, or `pool_not_declared`. Names are not anonymously enumerable; valid credentials cannot distinguish these cases by content. |
978
- | Agents | An agent member reaches its pools only at `/agent/<pool>`, with its `cta_` token. Human endpoints refuse `cta_`; see [agent endpoints](#agent-endpoints). |
979
- | Surface | From 0.35.0, optional `surface`: `"meta"` (default) serves the six meta-tools; `"native"` lists each of the pool's downstream tools directly, for clients with their own tool search, with optional `executeCode: false` to replace `execute_code` with `read_result` (invalid on meta pools). Calls take the same paths either way. See the [native tool surface](./meta-tools.md#native-tool-surface). |
980
- | Description | Optional `description`: plain text telling the model what this endpoint is for. `/mcp/<pool>` and `/agent/<pool>` append it to the MCP `initialize` instructions after `This endpoint: `; a maintainer at plain `/mcp` and a pool without one serve the shared instructions unchanged. A routed person receives their pool description. It grants, hides, and refuses nothing, and changes no tool or tool description. |
981
- | Construction | Malformed names, unknown options or connectors, empty pools, empty or missing `members`, mixed people and agent membership (a `ConfigError` naming the pool), unresolvable member references, a non-boolean `readOnly`, an `owner` that is not a Workspace email address, a `description` that is not 1 to 500 characters of one-line text after trimming, a retired `trust` or `grant`, and addresses absent from an `api()` static catalog throw. Remote catalogs are checked at lazy load. |
982
- | Discovery | Clerk's pool 401 names `/.well-known/oauth-protected-resource/mcp/<pool>` with the pool URL as `resource`, matching RFC 9728. Cloudflare Managed OAuth is application-level and needs no pool setup. |
983
- | Drift | No wildcards; pool tools are exact names. Missing live tools are unreachable and warned once while in a bounded 1,024-entry FIFO. |
978
+ | Rule | Contract |
979
+ | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
980
+ | Scope | `/mcp/<pool>` and `/agent/<pool>` serve exactly the pool's `tools` (connector ids or exact `connector.tool` addresses), with the pool's `readOnly`, to its member people and member agents respectively. Plain `/mcp` serves the whole catalog to maintainers and the unique member pool to other people. Neither intersects with or widens anything. |
981
+ | Refusal | A person with zero or several memberships at `/mcp`, a non-member, and an undeclared pool get an identical 404 status, body, and headers, after auth. The operator log records `not_maintainer` (zero memberships), `ambiguous_pool` (several memberships), `not_pool_member`, or `pool_not_declared`. Names are not anonymously enumerable; valid credentials cannot distinguish these cases by content. |
982
+ | Agents | An agent member reaches its pools only at `/agent/<pool>`, with its `cta_` token. Human endpoints refuse `cta_`; see [agent endpoints](#agent-endpoints). |
983
+ | Surface | From 0.35.0, optional `surface`: `"meta"` (default) serves the six meta-tools; `"native"` lists each of the pool's downstream tools directly, for clients with their own tool search, with optional `executeCode: false` to replace `execute_code` with `read_result` (invalid on meta pools). Calls take the same paths either way. See the [native tool surface](./meta-tools.md#native-tool-surface). |
984
+ | Description | Optional `description`: plain text telling the model what this endpoint is for. `/mcp/<pool>` and `/agent/<pool>` prefix the MCP instructions with `Use skills. ` and the description before routing rules; a maintainer at plain `/mcp` and a pool without one serve the shared instructions unchanged. A routed person receives their pool description. It grants, hides, and refuses nothing, and changes no tool or tool description. |
985
+ | Construction | Malformed names, unknown options or connectors, empty pools, empty or missing `members`, mixed people and agent membership (a `ConfigError` naming the pool), unresolvable member references, a non-boolean `readOnly`, an `owner` that is not a Workspace email address, a `description` that is not 1 to 500 UTF-16 code units of one-line text after trimming, a retired `trust` or `grant`, and addresses absent from an `api()` static catalog throw. Remote catalogs are checked at lazy load. |
986
+ | Discovery | Clerk's pool 401 names `/.well-known/oauth-protected-resource/mcp/<pool>` with the pool URL as `resource`, matching RFC 9728. Cloudflare Managed OAuth is application-level and needs no pool setup. |
987
+ | Drift | No wildcards; pool tools are exact names. Missing live tools are unreachable and warned once while in a bounded 1,024-entry FIFO. |
984
988
 
985
989
  Pool descriptions are trimmed before validation. The remaining text must be
986
- 1 to 500 characters and contain no C0 or C1 control characters (including tabs,
990
+ 1 to 500 UTF-16 code units (non-BMP characters such as emoji count as two units) and contain no C0 or C1 control characters (including tabs,
987
991
  newlines, and NEL U+0085), LINE SEPARATOR (U+2028), or PARAGRAPH SEPARATOR
988
992
  (U+2029).
989
993
 
@@ -1102,6 +1106,7 @@ const team = [
1102
1106
  { handle: "bob", id: "user_bob", email: "bob@example.com" },
1103
1107
  ];
1104
1108
  createConnecta({
1109
+ publicUrl: "https://connecta.example.com",
1105
1110
  auth: clerkAuth({ publishableKey, secretKey }),
1106
1111
  storage,
1107
1112
  accessTokens: accessTokens(storage),
@@ -1250,6 +1255,7 @@ const workspace: GoogleWorkspaceOptions = {
1250
1255
  };
1251
1256
 
1252
1257
  createConnecta({
1258
+ publicUrl: "https://connecta.example.com",
1253
1259
  auth: clerkAuth({ publishableKey, secretKey }),
1254
1260
  storage,
1255
1261
  accessTokens: accessTokens(storage),
@@ -1364,21 +1370,34 @@ Each owner of an OAuth connector (the deployment when shared, each principal
1364
1370
  when personal) has one **grant record**, `oauth:grant` in the connector's
1365
1371
  namespace: the live **epoch**, the latest consent's state digest, and one
1366
1372
  authorization server's client registration, tokens, and discovery. Each
1367
- consent has a **flow record**, `oauth:flow:<sha256(state)>`, holding its
1368
- epoch, consent URL, and PKCE verifier for fifteen minutes; the state itself is
1369
- stored nowhere.
1373
+ consent has a **flow record**, `oauth:flow:<sha256(state)>`, holding its epoch
1374
+ and timestamp, its binding to the selected server and client, its consent URL,
1375
+ and its PKCE verifier for fifteen minutes; the state itself is stored nowhere
1376
+ outside that URL.
1370
1377
 
1371
- With a vault, the grant's body and each verifier are sealed with its AES-GCM
1372
- key, the additional authenticated data naming the connector, owner partition,
1378
+ With a vault, the grant's body and each flow record's binding, consent URL and
1379
+ verifier are sealed with its AES-GCM key, the additional authenticated data naming the connector, owner partition,
1373
1380
  record, and epoch, so ciphertext moved to another connector, principal,
1374
1381
  record, or epoch does not open. Anything that fails to open (tampered, or
1375
1382
  under a rotated key) reads as absent: `auth_required` and a logged warning.
1376
- The epoch, consent URL, and state digest stay plaintext and authenticate
1377
- nothing. A plaintext body written before the vault could seal is sealed where
1383
+ The epoch, timestamp, and state digest stay plaintext and authenticate nothing. A plaintext body written before the vault could seal is sealed where
1378
1384
  it lies on first read, by compare-and-set. An [`api()` OAuth
1379
- connector](#downstream-oauth-on-api) stores no client. A vault without the
1380
- optional `seal`/`open` members keeps the body plaintext with a startup
1381
- warning; no vault, plaintext as always.
1385
+ connector](#downstream-oauth-on-api) stores no client.
1386
+
1387
+ `createConnecta` throws when a configured vault lacks `seal` or `open` and any
1388
+ connector has `startAuth`. A vault without them still works for deployments with
1389
+ no OAuth connector. To migrate, use `encryptedCredentialVault(storage, key)` or
1390
+ add both methods; grants stored plaintext are then sealed on first read as above.
1391
+
1392
+ **With no vault, the guarantee is narrower.** Nothing is encrypted at rest: the
1393
+ grant's body and the PKCE verifier are plaintext, and a configured URL's path can
1394
+ appear in them. The flow record is minimized instead. It stores no consent URL,
1395
+ and its binding keeps no URL userinfo, query or fragment, in plaintext or in its
1396
+ unkeyed discovery digest. Connect, callback and restart still work, and two things
1397
+ differ. A repeated non-forced start mints a fresh consent rather than handing the
1398
+ pending URL back; earlier links stay valid until their epoch ends. A change confined
1399
+ to a URL's query or fragment does not fence a pending consent.
1400
+ Use a vault wherever stored OAuth state must be protected.
1382
1401
 
1383
1402
  The body also holds `expiresAt`, the epoch milliseconds the stored access token
1384
1403
  expires: its `expires_in`, counted from just before the request that issued it
@@ -2083,11 +2102,17 @@ targeting the host's private network:
2083
2102
  - A URL on the connector's configured origin is trusted; the operator wrote it.
2084
2103
  - Any other URL must be `https` and must not name a private host: `localhost`
2085
2104
  and `*.localhost`, `127/8`, `10/8`, `172.16/12`, `192.168/16`, `169.254/16`,
2086
- `100.64/10`, `0/8`, `::`, `::1`, `fc00::/7`, `fe80::/10`, or an IPv4-mapped
2087
- IPv6 form of any of those.
2105
+ `100.64/10`, `0/8`, `192.0.0/24`, `192.0.2/24`, `192.88.99/24`, `198.18/15`,
2106
+ `198.51.100/24`, `203.0.113/24`, `224/4`, `240/4`, `::`, `::1`, any IPv6
2107
+ address outside global unicast `2000::/3` (which covers `fc00::/7`,
2108
+ `fe80::/10`, `fec0::/10`, `ff00::/8` and `64:ff9b:1::/48`), `2001::/23`,
2109
+ `2001:db8::/32`, or `3fff::/20`. An IPv6 address carrying an IPv4 address in
2110
+ mapped, compatible, translated, NAT64 (`64:ff9b::/96`) or 6to4 (`2002::/16`)
2111
+ form is judged by the address it carries.
2088
2112
  - A connector configured on a loopback host may also learn loopback URLs, over
2089
2113
  `http` too, so a local MCP server with a local authorization server keeps
2090
- working. LAN and link-local addresses stay refused.
2114
+ working. Of the IPv4-carrying forms, only IPv4-mapped loopback counts as
2115
+ loopback. LAN and link-local addresses stay refused.
2091
2116
 
2092
2117
  A refused URL is never requested. Discovery, registration, and code exchange
2093
2118
  fail with a non-retryable `connector_call_failed`. Resource-metadata origin
@@ -2098,10 +2123,22 @@ and the grant requires re-consent. Redirects cannot route around the
2098
2123
  rule; token requests never follow them, and resource redirects follow only
2099
2124
  same-origin hops.
2100
2125
 
2101
- The check is syntactic: it reads the host after the WHATWG URL parser folds
2102
- `2130706433` and `0x7f.1` into `127.0.0.1`, and never resolves a name — the
2103
- Workers-safe core has no DNS. A public name resolving or rebinding to a private
2104
- address is out of scope; a host that must stop that needs an egress policy.
2126
+ The shared check reads canonical URL syntax (including legacy IPv4 spellings).
2127
+ On Node, the owned transport also resolves learned hosts immediately before
2128
+ connecting, refuses the whole answer set if any IPv4 or IPv6 address violates
2129
+ the same rules, and pins the connection to a checked address. TLS SNI and
2130
+ certificate validation retain the original hostname, including through a proxy.
2131
+ Proxy TLS authenticates the proxy hostname separately from destination TLS.
2132
+ Resolution uses request-owned A/AAAA queries, cancelled when the request ends
2133
+ and bounded to ten seconds even without a caller deadline. Only absent address
2134
+ families may be omitted; resolver failures refuse the request. These DNS queries
2135
+ do not consult OS hosts-file aliases.
2136
+ Each redirect hop is checked. Configured origins remain exempt. Resolution
2137
+ refusals are typed, non-retryable, and send no destination request.
2138
+
2139
+ Workers exposes no connection-time DNS hook and has no route to private networks;
2140
+ its behavior remains the shared syntactic check. The shared core never imports
2141
+ Node networking or the optional undici peer.
2105
2142
 
2106
2143
  Downstream advertisements choose consent destinations, never destinations for
2107
2144
  existing grants. Tokens and registered clients bind to their issuing server;
@@ -2228,7 +2265,9 @@ the first request on a fresh instance. It grants no identity and never falls
2228
2265
  back to another provider. Missing context without an assertion still returns 401. An unusable trusted AUD returns retryable 503 with
2229
2266
  `problem: "access_audience_invalid"`; it never proceeds to identity lookup.
2230
2267
  A rejected human identity, including a service identity or an unusable user
2231
- id, returns 403. A thrown identity lookup or its five-second deadline returns retryable
2268
+ id, returns 403. A thrown identity lookup gets one retry after 100–250 ms of
2269
+ jitter, within the original five-second deadline. A hanging lookup is never
2270
+ retried concurrently. The second rejection or the deadline returns retryable
2232
2271
  503 with `problem: "identity_unavailable"` and `Retry-After: 1`. Every refusal
2233
2272
  carries `Cache-Control: no-store`; every unavailable-verification 503 carries
2234
2273
  `Retry-After: 1` and no `WWW-Authenticate` challenge. None of these failures admits an identity,
@@ -2246,9 +2285,24 @@ identifies the generic route fallthrough, `authorization_header_invalid` a
2246
2285
  malformed header, and `authentication_failed` an unclassified provider refusal.
2247
2286
  Access verification 503s emit `[connecta] Cloudflare Access verification unavailable`
2248
2287
  with the same facts and `access_context_unavailable`, `access_audience_invalid`,
2249
- or `access_identity_unavailable`. These records contain no header values,
2288
+ or `access_identity_unavailable`. Identity lookup failures additionally carry
2289
+ checked `identityLookup` facts: `outcome` (`timeout` or `rejected`), `durationMs`,
2290
+ `cold` (whether this was the isolate's first identity lookup), and `inFlight`
2291
+ (the isolate's lookup attempts still pending when the failure was recorded; this
2292
+ request's own attempt counts only while it is pending, so a hung lookup counts
2293
+ itself, and a rejected lookup or a deadline during retry backoff does not).
2294
+ These records contain no header values,
2250
2295
  assertions, cookies, identities, URLs, response bodies, or exception text.
2251
2296
 
2297
+ Identity lookups stay request-owned: Connecta neither coalesces them across
2298
+ requests nor caches identity results. A caller-supplied assertion header is
2299
+ not an identity cache key. Failed identity lookups always refuse admission.
2300
+
2301
+ After an authorized release, validate this mitigation with about 20 concurrent
2302
+ sessions. Count runs containing `identity_unavailable`, targeting fewer than
2303
+ 1% of runs; distinguish `timeout` from `rejected` in the checked lookup facts.
2304
+ A passing local concurrency fixture does not establish production acceptance.
2305
+
2252
2306
  Protect the Worker with a Worker-level Access application whose destination is
2253
2307
  `{ "type": "worker", "worker_id": "<the Worker script tag>" }`. A traditional
2254
2308
  hostname-level application blocks the URL but does not attach `ctx.access` to
@@ -2293,7 +2347,15 @@ tokens by default, both JWT and opaque. Verified `aud` or `resource` must
2293
2347
  contain the endpoint's exact canonical URL from protected-resource metadata.
2294
2348
  If both claims are present, both must match. Trailing slashes, case, queries,
2295
2349
  and fragments are not normalized. `/mcp` and `/mcp/support` are distinct
2296
- resources. Set `publicUrl` behind an internal origin.
2350
+ resources.
2351
+
2352
+ Clerk needs a fixed deployment URL. Set `ConnectaConfig.publicUrl`, or
2353
+ `clerkAuth({ publicUrl })` when only Clerk should use it; `createConnecta` throws
2354
+ when neither is set, on Node and on Workers. Both token `resource` checks and the
2355
+ session `azp` pin read this value, so a request's `Host` and `X-Forwarded-Proto`
2356
+ never decide them. There is no implicit loopback default: a local deployment names
2357
+ its origin, such as `http://localhost:8787`. The Node template does this when
2358
+ `PUBLIC_URL` is unset.
2297
2359
 
2298
2360
  ```ts
2299
2361
  clerkAuth({ publishableKey, secretKey,
@@ -2923,9 +2985,27 @@ grant with different consent settings.
2923
2985
 
2924
2986
  The grant record and consents, epoch fencing, vault sealing, shared/personal ownership,
2925
2987
  callback state/principal checks, `authorize_connector`, Connect, Restart, and
2926
- Disconnect match `remoteMcp()`. Status calls report a stored grant for the
2927
- configured token endpoint healthy without downstream probes and never start
2928
- authorization. `oauth` and
2988
+ Disconnect match `remoteMcp()`. Status reads do not probe tokens, start OAuth,
2989
+ or write grant state. A grant for the configured token endpoint starts as `ok`;
2990
+ a real API 401 records `refused` and status becomes `auth_required`. A real API
2991
+ 2xx records `accepted`. Other HTTP statuses do not establish an auth outcome.
2992
+ A persisted refusal does not block a later invocation: the API may have recovered,
2993
+ and an accepted response restores `ok`. Within the failing request, a second 401
2994
+ still latches authorization failure. Connect on a refused grant begins fresh consent
2995
+ rather than reporting existing tokens as healthy. Maintained OAuth providers record
2996
+ these same outcomes for initial and replayed requests. Repeated identical outcomes
2997
+ for the current revision do not rewrite the grant.
2998
+ Each consent and refresh advances a monotonic token revision within its grant
2999
+ epoch and clears the prior observation in the same compare-and-set write.
3000
+ Call observations carry the epoch and revision read with their token and are
3001
+ accepted only while both still match the live grant. No wall clock orders
3002
+ observations. Reconsent with an identical token still advances the revision.
3003
+ The observation stores only its revision and the `accepted`/`refused` enum;
3004
+ arguments, response bodies, code and error text are excluded. A declared oversized
3005
+ 401 records refusal without reading its body, including on a replay. Normal Connect
3006
+ resets only while the refused epoch, revision and outcome it read still match;
3007
+ a concurrently recovered grant is preserved. An observation
3008
+ storage failure does not replace the already received API response. `oauth` and
2929
3009
  `credential` are exclusive per connector, giving `auth_required` one recovery.
2930
3010
  For providers offering both, deployment config chooses; handlers check `ctx.oauth`.
2931
3011