@zackbart/connecta 0.40.0 → 0.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (195) hide show
  1. package/CHANGELOG.md +148 -0
  2. package/bin/connecta.mjs +26 -17
  3. package/bin/meta-tool-names.d.mts +3 -1
  4. package/bin/meta-tool-names.mjs +4 -1
  5. package/bin/version.mjs +1 -1
  6. package/dist/activity.d.ts +32 -5
  7. package/dist/activity.js +32 -6
  8. package/dist/advertised-schema.js +15 -0
  9. package/dist/agent-observation.js +24 -2
  10. package/dist/agent-routes.js +33 -44
  11. package/dist/auth/clerk.d.ts +6 -1
  12. package/dist/auth/clerk.js +16 -0
  13. package/dist/auth/cloudflare-access.js +49 -7
  14. package/dist/auth/downstream-oauth/consent.js +79 -29
  15. package/dist/auth/downstream-oauth/grant-store.js +58 -7
  16. package/dist/auth/downstream-oauth/refresh-coordinator.js +4 -1
  17. package/dist/auth/downstream-oauth/refresh-records.js +6 -5
  18. package/dist/auth/downstream-oauth.js +15 -4
  19. package/dist/auth/oauth-request-not-sent.js +4 -0
  20. package/dist/auth/static-oauth.js +77 -39
  21. package/dist/auth-elicitation.js +118 -61
  22. package/dist/authorization-handoff.js +36 -3
  23. package/dist/catalog-cache.js +352 -43
  24. package/dist/catalog-freshness.js +25 -0
  25. package/dist/catalog-service.js +408 -78
  26. package/dist/catalog.js +216 -62
  27. package/dist/config-defaults.d.ts +2 -1
  28. package/dist/config-defaults.js +4 -1
  29. package/dist/config.d.ts +53 -21
  30. package/dist/config.js +56 -17
  31. package/dist/connectors/remote-mcp/call.js +1 -1
  32. package/dist/connectors/remote-mcp/catalog.js +52 -28
  33. package/dist/connectors/remote-mcp/connection.js +113 -22
  34. package/dist/connectors/remote-mcp/oauth.js +2 -2
  35. package/dist/connectors/remote-mcp/resources.js +5 -2
  36. package/dist/connectors/remote-mcp/transport.js +24 -6
  37. package/dist/connectors/remote-mcp.js +14 -5
  38. package/dist/credential-contract.d.ts +3 -1
  39. package/dist/describe-config.d.ts +4 -2
  40. package/dist/describe-config.js +1 -0
  41. package/dist/discovery-health.js +26 -11
  42. package/dist/downstream-input.js +11 -2
  43. package/dist/errors.d.ts +60 -4
  44. package/dist/errors.js +88 -3
  45. package/dist/execute.js +491 -202
  46. package/dist/executor-admission.js +2 -0
  47. package/dist/executor-contract.js +6 -0
  48. package/dist/executor-result.js +3 -3
  49. package/dist/executors/quickjs-child.js +14 -5
  50. package/dist/executors/quickjs-runtime.d.ts +2 -0
  51. package/dist/executors/quickjs-runtime.js +37 -20
  52. package/dist/executors/quickjs.js +24 -7
  53. package/dist/guarded-transport.js +2 -0
  54. package/dist/guest-functions.js +5 -4
  55. package/dist/guest-runtime.js +37 -1
  56. package/dist/guest-types.d.ts +18 -0
  57. package/dist/http/client.js +14 -2
  58. package/dist/http/errors.js +9 -4
  59. package/dist/http/fields.js +17 -5
  60. package/dist/http/hatch.js +11 -3
  61. package/dist/http/node-transport.js +110 -4
  62. package/dist/http/oauth.js +17 -6
  63. package/dist/http/paging.js +1 -1
  64. package/dist/http/provider.js +2 -6
  65. package/dist/http/vendor.js +6 -1
  66. package/dist/index.d.ts +1 -1
  67. package/dist/index.js +15 -21
  68. package/dist/invocation-failure.js +28 -0
  69. package/dist/invocation.js +167 -64
  70. package/dist/membership.d.ts +1 -1
  71. package/dist/meta-output.js +24 -18
  72. package/dist/meta-tool-names.d.ts +1 -1
  73. package/dist/meta-tool-names.js +1 -1
  74. package/dist/meta-tools.js +286 -73
  75. package/dist/module-contracts.d.ts +3 -1
  76. package/dist/native-recovery.js +4 -1
  77. package/dist/native-tools.js +177 -55
  78. package/dist/node.d.ts +7 -0
  79. package/dist/node.js +85 -4
  80. package/dist/oauth-handoff.js +53 -43
  81. package/dist/operation-activity.js +104 -0
  82. package/dist/operation-vocabulary.js +6 -0
  83. package/dist/operator-record.d.ts +9 -1
  84. package/dist/operator-record.js +16 -0
  85. package/dist/operator-ui/generated.js +2 -2
  86. package/dist/program-result.js +2 -0
  87. package/dist/program-source.js +1 -1
  88. package/dist/program-writes.js +112 -19
  89. package/dist/provider.d.ts +1 -14
  90. package/dist/provider.js +0 -3
  91. package/dist/providers/_shared/google/kit.js +2 -2
  92. package/dist/providers/_shared/google/workspace.d.ts +1 -1
  93. package/dist/providers/breeze/api.js +2 -2
  94. package/dist/providers/breeze/index.js +1 -1
  95. package/dist/providers/breeze/skill.generated.js +1 -1
  96. package/dist/providers/ccb/api.js +2 -2
  97. package/dist/providers/ccb/index.js +1 -1
  98. package/dist/providers/ccb/skill.generated.js +1 -1
  99. package/dist/providers/cloudflare/api.js +2 -2
  100. package/dist/providers/cloudflare/index.js +3 -1
  101. package/dist/providers/cloudflare/skill.generated.js +1 -1
  102. package/dist/providers/docs/index.js +1 -1
  103. package/dist/providers/docs/skill.generated.js +1 -1
  104. package/dist/providers/drive/index.js +1 -1
  105. package/dist/providers/drive/skill.generated.js +1 -1
  106. package/dist/providers/forms/index.js +2 -1
  107. package/dist/providers/forms/skill.generated.js +1 -1
  108. package/dist/providers/github/api.js +98 -6
  109. package/dist/providers/github/index.js +8 -3
  110. package/dist/providers/github/lib/auth.d.ts +3 -1
  111. package/dist/providers/github/lib/auth.js +43 -12
  112. package/dist/providers/github/lib/discovery.js +11 -1
  113. package/dist/providers/github/lib/operations.js +8 -0
  114. package/dist/providers/github/safety.js +39 -0
  115. package/dist/providers/github/skill.generated.js +1 -1
  116. package/dist/providers/github/tools.js +12 -5
  117. package/dist/providers/gmail/index.js +2 -1
  118. package/dist/providers/gmail/skill.generated.js +1 -1
  119. package/dist/providers/granola/index.js +1 -1
  120. package/dist/providers/granola/skill.generated.js +1 -1
  121. package/dist/providers/overflow/index.js +3 -1
  122. package/dist/providers/overflow/skill.generated.js +1 -1
  123. package/dist/providers/planning-center/index.js +12 -6
  124. package/dist/providers/planning-center/lib/constants.d.ts +1 -1
  125. package/dist/providers/planning-center/skill.generated.js +1 -1
  126. package/dist/providers/quickbooks/api.js +28 -25
  127. package/dist/providers/quickbooks/index.js +3 -6
  128. package/dist/providers/quickbooks/lib/writes.js +55 -0
  129. package/dist/providers/quickbooks/safety.js +17 -5
  130. package/dist/providers/quickbooks/skill.generated.js +1 -1
  131. package/dist/providers/sheets/index.js +2 -1
  132. package/dist/providers/sheets/skill.generated.js +1 -1
  133. package/dist/providers/slides/index.js +3 -3
  134. package/dist/providers/slides/skill.generated.js +1 -1
  135. package/dist/providers/stripe/index.js +2 -2
  136. package/dist/providers/stripe/skill.generated.js +1 -1
  137. package/dist/providers/tithely/index.js +2 -1
  138. package/dist/providers/tithely/skill.generated.js +1 -1
  139. package/dist/providers/vercel/api.js +3 -2
  140. package/dist/providers/vercel/skill.generated.js +1 -1
  141. package/dist/registry.d.ts +36 -4
  142. package/dist/registry.js +142 -30
  143. package/dist/resource-read.js +21 -2
  144. package/dist/result-preview.js +210 -0
  145. package/dist/result-seal.js +168 -0
  146. package/dist/route-names.js +53 -0
  147. package/dist/routes/activity-context.js +1 -0
  148. package/dist/routes/activity-disclosure.js +26 -4
  149. package/dist/routes/activity.js +2 -1
  150. package/dist/routes/connect.js +21 -13
  151. package/dist/routes/mcp.js +83 -35
  152. package/dist/routes/shared.d.ts +6 -0
  153. package/dist/routes/ui-config.js +1 -1
  154. package/dist/routes/ui-data.js +3 -2
  155. package/dist/runtime/call.js +15 -10
  156. package/dist/runtime/native-storage.js +12 -0
  157. package/dist/runtime/request-reads.js +66 -14
  158. package/dist/runtime/request-scope.js +38 -2
  159. package/dist/runtime/run.js +4 -1
  160. package/dist/sent-secrets.d.ts +20 -0
  161. package/dist/sent-secrets.js +49 -1
  162. package/dist/skill-origin.js +16 -0
  163. package/dist/skills.js +213 -57
  164. package/dist/storage/d1-driver.js +33 -5
  165. package/dist/storage/keys.js +16 -7
  166. package/dist/storage/sql-native.js +60 -3
  167. package/dist/storage/sql.js +6 -0
  168. package/dist/timing-trace.js +84 -0
  169. package/dist/tool-safety.d.ts +33 -0
  170. package/dist/tool-safety.js +51 -15
  171. package/dist/types.d.ts +36 -0
  172. package/dist/url-safety.js +38 -20
  173. package/dist/usage-guide.js +14 -13
  174. package/dist/utility-recovery.js +33 -0
  175. package/dist/validate.js +29 -10
  176. package/dist/version.d.ts +1 -1
  177. package/dist/version.js +1 -1
  178. package/dist/worker.js +60 -15
  179. package/documentation/architecture.md +152 -43
  180. package/documentation/auth.md +160 -53
  181. package/documentation/code-mode.md +396 -119
  182. package/documentation/deploying.md +35 -10
  183. package/documentation/hosted-mcp-migration.md +9 -3
  184. package/documentation/integrating.md +27 -5
  185. package/documentation/meta-tools.md +761 -157
  186. package/documentation/operating.md +91 -4
  187. package/documentation/operator-ui.md +51 -2
  188. package/documentation/providers.md +34 -20
  189. package/examples/worker/README.md +3 -3
  190. package/guides/how-it-works.md +2 -1
  191. package/guides/safety-and-trust.md +1 -3
  192. package/package.json +10 -8
  193. package/templates/node/AGENTS.md +3 -3
  194. package/templates/node/README.md +1 -1
  195. package/templates/node/package.json +1 -1
package/dist/config.js CHANGED
@@ -82,7 +82,8 @@ const calls = {
82
82
  /**
83
83
  * Deadline (ms) for `call_tool`/`call_destructive_tool` calls that pass no
84
84
  * `timeoutMs`. An explicit per-call value wins. Unset by default, when those
85
- * calls fall back to `execute.hostCallTimeoutMs`.
85
+ * calls fall back to `execute.hostCallTimeoutMs`. `execute.watchdogMs` caps
86
+ * every one of them.
86
87
  *
87
88
  * Each call makes one attempt. `connecta.call` inside `execute_code` uses
88
89
  * it too, ahead of `execute.hostCallTimeoutMs`; `connecta.result`,
@@ -118,16 +119,19 @@ const execute = {
118
119
  * `connecta.read`, in milliseconds. Default 15_000. Discovery uses
119
120
  * `discovery.probeTimeoutMs`. `connecta.call` and the direct call tools use it only when
120
121
  * `calls.defaultTimeoutMs` is unset, and an explicit per-call `timeoutMs`
121
- * always wins. Raise it for providers whose legitimate calls run longer,
122
- * such as analytics queries.
122
+ * wins, up to `execute.watchdogMs`. Raise it for providers whose legitimate
123
+ * calls run longer, such as analytics queries.
123
124
  */
124
125
  hostCallTimeoutMs: whole({ min: 1, default: D.execute.hostCallTimeoutMs }),
125
126
  /**
126
- * Hard ceiling on one execution, in milliseconds, enforced outside the
127
- * sandbox. Default 120_000, above both executors' own deadlines (QuickJS
128
- * 30_000, the Dynamic Worker 60_000), so it only ends a run whose executor
129
- * never settled; that run fails as unresponsive and its admission slot is
130
- * released. Keep it above any raised executor deadline.
127
+ * Ceiling on one `execute_code` or direct call response, in milliseconds,
128
+ * counted from the request's arrival and enforced outside the sandbox.
129
+ * Default 55_000: MCP clients commonly abandon a request at 60_000, and only
130
+ * a response sent before that reaches the agent as a typed result. It sits
131
+ * above both executors' own deadlines (QuickJS 30_000, the Dynamic Worker
132
+ * 50_000), so a run ends here only if its executor never settled, or if
133
+ * queueing spent its time. An explicit `timeoutMs` above it is cut to it. Raise it,
134
+ * and any executor deadline with it, only for clients whose own limit is raised.
131
135
  */
132
136
  watchdogMs: whole({ min: 1, default: D.execute.watchdogMs }),
133
137
  /**
@@ -143,6 +147,15 @@ const execute = {
143
147
  * model step. From 4_000 to 1_000_000.
144
148
  */
145
149
  maxResultChars: whole({ min: 4_000, max: 1_000_000, default: D.execute.maxResultChars }),
150
+ /**
151
+ * `false` stops listing `execute_read_code` on writable human endpoints. A
152
+ * read program runs unprompted on hosts that auto-approve reads, so a deployment
153
+ * that wants a person to see every program can turn it off. Default true.
154
+ */
155
+ readRoute: opaque({
156
+ check: assertFlag,
157
+ resolve: (value) => value ?? D.execute.readRoute,
158
+ }),
146
159
  };
147
160
  function admissionPool(defaults) {
148
161
  return {
@@ -189,7 +202,7 @@ function assertFlag(value, path) {
189
202
  if (typeof value !== "boolean")
190
203
  throw new ConfigError(`${path} must be true or false.`);
191
204
  }
192
- /** Longest pool `description`, in characters, after trimming. */
205
+ /** Longest pool `description`, in UTF-16 code units, after trimming. */
193
206
  const MAX_POOL_DESCRIPTION_LENGTH = 500;
194
207
  /**
195
208
  * A pool description is one line of prose for the model. C0 and C1 controls
@@ -209,9 +222,9 @@ function assertPoolDescription(value, path) {
209
222
  const trimmed = typeof value === "string" ? value.trim() : undefined;
210
223
  if (trimmed === undefined ||
211
224
  trimmed.length === 0 ||
212
- [...trimmed].length > MAX_POOL_DESCRIPTION_LENGTH ||
225
+ trimmed.length > MAX_POOL_DESCRIPTION_LENGTH ||
213
226
  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.`);
227
+ 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
228
  }
216
229
  }
217
230
  /** Shape only; each reference resolves against auth and agents in createConnecta. */
@@ -263,7 +276,7 @@ const pool = {
263
276
  * What this pool is for, in plain words for the model: appended to the MCP
264
277
  * `initialize` instructions at `/mcp/<name>` and `/agent/<name>`, so a
265
278
  * 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
279
+ * 500 UTF-16 code units, no line breaks or control characters. It grants and hides nothing; plain
267
280
  * `/mcp` never shows it.
268
281
  */
269
282
  description: opaque({ check: assertPoolDescription }),
@@ -359,11 +372,14 @@ const connectaConfig = {
359
372
  /** Named tool pools, each served at `/mcp/<name>` to its members only. */
360
373
  pools: record(object(pool)),
361
374
  /** Exact connector id -> tool name -> verdict. Hidden tools cannot be discovered or called.
375
+ * `"read"` applies only where the source is silent, and asserts the call is safe to repeat; a
376
+ * source's declared write stays a write and is reported. `"read-repeatable"` also serves a
377
+ * declared write as a read: the deployment has checked it and accepts that hosts may repeat it.
362
378
  * Unknown static names fail construction; absent runtime names stay dormant and are reported. */
363
379
  classification: record(record(opaque({
364
380
  check: (value, path) => {
365
- if (value !== "read" && value !== "write" && value !== "hidden") {
366
- throw new ConfigError(`${path} must be "read", "write", or "hidden".`);
381
+ if (value !== "read" && value !== "read-repeatable" && value !== "write" && value !== "hidden") {
382
+ throw new ConfigError(`${path} must be "read", "read-repeatable", "write", or "hidden".`);
367
383
  }
368
384
  },
369
385
  }))),
@@ -396,8 +412,9 @@ const connectaConfig = {
396
412
  resolve: (value) => value ?? memoryStorage(),
397
413
  }),
398
414
  /**
399
- * Public base URL. Defaults to the request origin per-request. Configuring an
400
- * HTTPS URL also redirects matching inbound HTTP requests to HTTPS.
415
+ * Public base URL. Defaults to the request origin per-request, except that
416
+ * clerkAuth requires it here or as its own `publicUrl`. Configuring an HTTPS
417
+ * URL also redirects matching inbound HTTP requests to HTTPS.
401
418
  */
402
419
  publicUrl: opaque({ check: httpUrl }),
403
420
  /**
@@ -419,7 +436,10 @@ const connectaConfig = {
419
436
  }
420
437
  },
421
438
  }),
422
- /** Replaceable owner-partitioned credential storage. Omit for config-owned secrets. */
439
+ /**
440
+ * Replaceable owner-partitioned credential storage. Omit for config-owned
441
+ * secrets. With any OAuth connector it must implement `seal` and `open`.
442
+ */
423
443
  vault: opaque({
424
444
  check: (value) => {
425
445
  if (!hasMethods(value, ["get", "getAll", "set", "setAll", "metadata", "delete"]) ||
@@ -464,6 +484,14 @@ const connectaConfig = {
464
484
  * saturation.
465
485
  */
466
486
  admission: object(admission),
487
+ /** Payload-free request timing logs; 0 disables tracing, 1 samples every MCP request. */
488
+ timingTraceSampleRate: opaque({
489
+ check: (value, path) => {
490
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 1)
491
+ throw new ConfigError(`${path} must be a finite number between 0 and 1.`);
492
+ },
493
+ resolve: (value) => value ?? 0,
494
+ }),
467
495
  /** Diagnostic output. Use "silent" to disable all diagnostic logging. */
468
496
  logger: opaque({
469
497
  check: (value, path) => {
@@ -528,10 +556,21 @@ function assertCoherent(config) {
528
556
  config.discovery.catalogMaxTtlSeconds > 86_400) {
529
557
  throw new ConfigError("ConnectaConfig.discovery requires catalogMinTtlSeconds <= catalogMaxTtlSeconds <= 86400.");
530
558
  }
559
+ const { vault } = config;
560
+ const oauth = config.connectors.find((connector) => connector.startAuth);
561
+ if (oauth && vault && (typeof vault.seal !== "function" || typeof vault.open !== "function")) {
562
+ throw new ConfigError(`ConnectaConfig.vault must implement both seal and open: connector "${oauth.id}" stores downstream OAuth ` +
563
+ "tokens, client registration and PKCE verifiers through it. Use encryptedCredentialVault(storage, key) " +
564
+ "from @zackbart/connecta/credentials, or add both methods.");
565
+ }
531
566
  if (config.agents.some((agent) => !agent.vercel) && !config.accessTokens) {
532
567
  throw new ConfigError("ConnectaConfig.agents needs accessTokens: accessTokens(storage) from @zackbart/connecta/auth/access-tokens " +
533
568
  "holds each agent's token slot.");
534
569
  }
570
+ if (!config.publicUrl && config.auth.some((provider) => provider.requiresPublicUrl)) {
571
+ throw new ConfigError("ConnectaConfig.publicUrl is required with clerkAuth, which checks tokens and sessions against it: " +
572
+ "set it, or clerkAuth({ publicUrl }), to this deployment's URL, http://localhost:<port> for local development.");
573
+ }
535
574
  }
536
575
  /**
537
576
  * 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]);
@@ -5,7 +5,7 @@ import { SdkHttpError, specTypeSchemas, UnauthorizedError } from "@modelcontextp
5
5
  import { redactCatalog } from "../../sent-secrets.js";
6
6
  import { MAX_CATALOG_TOOLS, MAX_SERIALIZED_CATALOG_BYTES } from "../../catalog-limits.js";
7
7
  import { catalogItems, observeCompletedCatalogRefresh, observeCatalogFetch, observeCatalogExpiry, } from "../../catalog-cache.js";
8
- import { ConnectorCallError, msg } from "../../errors.js";
8
+ import { ConnectorCallError, isAccessRefusal, msg } from "../../errors.js";
9
9
  import { carryFailureFacts, failureRecord, logFailure } from "../../operator-record.js";
10
10
  import { assertDownstreamOutputSafe } from "../../downstream-input-context.js";
11
11
  import { hasSdkPayload, ownAbortReason, requiresAuthorization } from "./errors.js";
@@ -78,6 +78,10 @@ export function remoteMcpCatalog(rt) {
78
78
  listingContext(options),
79
79
  authMethod(() => authFlow(function* () {
80
80
  const before = toolPages;
81
+ // The one write that follows no cache read, which is what
82
+ // otherwise captures the fence this listing may lift.
83
+ if (options?.cacheMode === "refresh")
84
+ (yield authInvoke(cache, "captureFence", []));
81
85
  let result;
82
86
  try {
83
87
  result = (yield authCall(listTools, [params, options]));
@@ -86,6 +90,9 @@ export function remoteMcpCatalog(rt) {
86
90
  const bounded = rt.atMcpBoundary(cache.currentContext(), error, "tools/list", client.transport, [
87
91
  cache.currentContext().signal,
88
92
  ]);
93
+ // Fenced as this listing was bound, whatever is current by now.
94
+ if (requiresAuthorization(bounded) || isAccessRefusal(bounded))
95
+ (yield authInvoke(cache, "fenceListing", []));
89
96
  const transient = (error instanceof SdkHttpError && [502, 503, 504].includes(error.status)) ||
90
97
  (bounded instanceof ConnectorCallError && bounded.code === "unavailable");
91
98
  const fallback = transient && toolPages === before
@@ -247,32 +254,49 @@ export function remoteMcpCatalog(rt) {
247
254
  }
248
255
  }
249
256
  state.toolDefinitions = new Map(clean.map((tool) => [tool.name, tool]));
250
- return clean.map((t) => ({
251
- name: t.name,
252
- ...(t.title !== undefined ? { title: t.title } : {}),
253
- ...(t.icons !== undefined
254
- ? {
255
- icons: t.icons.filter((icon) => !/^data:/i.test(icon.src)),
256
- }
257
- : {}),
258
- ...(t.execution !== undefined ? { execution: t.execution } : {}),
259
- ...(t.description !== undefined ? { description: t.description } : {}),
260
- ...(t.inputSchema !== undefined
261
- ? {
262
- inputSchema: t.inputSchema,
263
- }
264
- : {}),
265
- ...(t.outputSchema !== undefined
266
- ? {
267
- outputSchema: t.outputSchema,
268
- }
269
- : {}),
270
- ...(t.annotations !== undefined
271
- ? {
272
- annotations: t.annotations,
273
- }
274
- : {}),
275
- }));
257
+ return toolDefinitions(clean);
258
+ }));
259
+ // Served by discovery only, marked by the caller; the listing above stays
260
+ // the only source a call resolves through.
261
+ const peekCatalogOperation = authMethod((ctx) => authFlow(function* () {
262
+ if (liveListings.has(ctx))
263
+ return undefined;
264
+ const entry = (yield authCall(rt.peekCatalog, [ctx]));
265
+ if (!entry)
266
+ return undefined;
267
+ return {
268
+ tools: toolDefinitions(redactCatalog(ctx, entry.result.tools)),
269
+ fetchedAt: entry.fetchedAt,
270
+ expiresAt: entry.expiresAt,
271
+ };
272
+ }));
273
+ return { installCatalogIntake, listingContexts, liveListings, listToolsOperation, peekCatalogOperation };
274
+ }
275
+ function toolDefinitions(tools) {
276
+ return tools.map((t) => ({
277
+ name: t.name,
278
+ ...(t.title !== undefined ? { title: t.title } : {}),
279
+ ...(t.icons !== undefined
280
+ ? {
281
+ icons: t.icons.filter((icon) => !/^data:/i.test(icon.src)),
282
+ }
283
+ : {}),
284
+ ...(t.execution !== undefined ? { execution: t.execution } : {}),
285
+ ...(t.description !== undefined ? { description: t.description } : {}),
286
+ ...(t.inputSchema !== undefined
287
+ ? {
288
+ inputSchema: t.inputSchema,
289
+ }
290
+ : {}),
291
+ ...(t.outputSchema !== undefined
292
+ ? {
293
+ outputSchema: t.outputSchema,
294
+ }
295
+ : {}),
296
+ ...(t.annotations !== undefined
297
+ ? {
298
+ annotations: t.annotations,
299
+ }
300
+ : {}),
276
301
  }));
277
- return { installCatalogIntake, listingContexts, liveListings, listToolsOperation };
278
302
  }
@@ -3,8 +3,8 @@
3
3
  // served without connecting (INV-10).
4
4
  import { routePhrases } from "../../agent-routes.js";
5
5
  import { authView } from "../../auth/native-call.js";
6
- import { authFlow, authCall, authInvoke, authMethod, authStorageMany } from "../../auth/native.js";
7
- import { openRequest, viewServices, viewSignal, viewReads, serviceFor } from "../../runtime/call.js";
6
+ import { authFlow, authCall, authInvoke, authMethod } from "../../auth/native.js";
7
+ import { openRequest, viewServices, viewSignal } from "../../runtime/call.js";
8
8
  import { secretDigest } from "../../storage-digest.js";
9
9
  import { downstreamClientMetadata, downstreamRedirectUri, remoteClientAuthMethod, selfHostedClientUrl, } from "../../auth/downstream-client-metadata.js";
10
10
  import { Client, SdkErrorCode, SdkHttpError, UnauthorizedError } from "@modelcontextprotocol/client";
@@ -12,8 +12,8 @@ import { Deferred, Effect, Exit, Scope } from "effect";
12
12
  import { KvOAuthProvider, refreshCoordinatorsByPartition } from "../../auth/downstream-oauth.js";
13
13
  import { trackOAuthStartReset } from "../../auth/oauth-start-reset.js";
14
14
  import { sentSecretsFor } from "../../sent-secrets.js";
15
- import { catalogClientOptions, observeCatalogChange } from "../../catalog-cache.js";
16
- import { ConnectorCallError, resendAdvice } from "../../errors.js";
15
+ import { catalogClientOptions, observeCatalogChange, observeCatalogExpiry, observeCatalogFetch, prefetchCatalog, retainedCatalogIdentity, } from "../../catalog-cache.js";
16
+ import { ConnectorCallError, isAccessRefusal, resendAdvice } from "../../errors.js";
17
17
  import { carryFailureFacts, failureRecord, logFailure } from "../../operator-record.js";
18
18
  import { CONNECTA_VERSION } from "../../version.js";
19
19
  import { oauthSealerFor } from "../../oauth-sealing.js";
@@ -23,13 +23,42 @@ import { downstreamInputCapabilities } from "../../downstream-input-context.js";
23
23
  import { detach, runEdge } from "../../runtime/run.js";
24
24
  import { requestPrincipalFor } from "../../request-principal.js";
25
25
  import { callerOf } from "../../connector-caller.js";
26
- import { CatalogPolicy } from "../../catalog-policy.js";
27
- import { negotiationKeys, responseCacheKeys } from "../../storage/keys.js";
26
+ import { negotiationKeys } from "../../storage/keys.js";
28
27
  import { forgetNegotiation, readNegotiation, storeNegotiation } from "../negotiation-cache.js";
29
28
  import { carriesIllegalHeaderChar, credentialHeaderValue } from "./config.js";
30
29
  import { AUTH_OR_THROTTLE, promised, recordWireErrors, requiresAuthorization } from "./errors.js";
31
30
  import { MAX_TOOL_PAGES } from "./catalog.js";
32
31
  import { SKILLS_EXTENSION } from "./resources.js";
32
+ /** Retain every abandoned lease release until the final scope close joins it. */
33
+ export function retainCleanup(state, release) {
34
+ const pending = detach(release);
35
+ state.cleanup.add(pending);
36
+ if (state.closing)
37
+ state.closing = Promise.all([state.closing, pending]).then(() => { });
38
+ // Register at the release itself as well: a late connect can acquire a
39
+ // session after the owner's first close has already settled.
40
+ try {
41
+ state.defer?.(pending);
42
+ }
43
+ catch {
44
+ // Runtime hooks are best-effort; the bounded cleanup is already running.
45
+ }
46
+ void pending.then(() => state.cleanup.delete(pending));
47
+ return pending;
48
+ }
49
+ /** Scope.closeUnsafe returns nothing on a second close, even during release. */
50
+ export function closeConnectionScope(state) {
51
+ if (!state.closing) {
52
+ const release = Scope.closeUnsafe(state.scope, Exit.void);
53
+ if (release)
54
+ retainCleanup(state, release);
55
+ state.closing = (async () => {
56
+ while (state.cleanup.size)
57
+ await Promise.all(state.cleanup);
58
+ })();
59
+ }
60
+ return state.closing;
61
+ }
33
62
  /** One connector's connection states, credential reads and connect attempts. */
34
63
  export function remoteMcpConnection(rt) {
35
64
  const { requestAuth, forwarding, id, staticClient, clientMetadataUrl, opts, oauthScope, authorizationParams, destination, isOauth, extraHeaders, credentialHeader, credentialScheme, skillsEnabled, principalPolicy, credentialAuth, } = rt;
@@ -105,6 +134,9 @@ export function remoteMcpConnection(rt) {
105
134
  if (!state) {
106
135
  state = {
107
136
  scope: Scope.makeUnsafe(),
137
+ closing: null,
138
+ cleanup: new Set(),
139
+ defer: ctx.defer,
108
140
  lease: null,
109
141
  client: null,
110
142
  transport: null,
@@ -120,10 +152,8 @@ export function remoteMcpConnection(rt) {
120
152
  states.set(key, state);
121
153
  const owned = state;
122
154
  owner.own(() => {
123
- const closing = Scope.closeUnsafe(owned.scope, Exit.void);
155
+ void closeConnectionScope(owned);
124
156
  reset(owned);
125
- if (closing)
126
- detach(closing);
127
157
  });
128
158
  }
129
159
  return state;
@@ -191,7 +221,7 @@ export function remoteMcpConnection(rt) {
191
221
  reset(state);
192
222
  const release = lease && Scope.closeUnsafe(lease, Exit.void);
193
223
  if (release)
194
- detach(release);
224
+ retainCleanup(state, release);
195
225
  };
196
226
  const waitForConnection = (attempt, signal) => runEdge(Deferred.await(attempt), signal ? { signal } : {});
197
227
  const oauthConfigFor = (ctx) => isOauth
@@ -258,7 +288,7 @@ export function remoteMcpConnection(rt) {
258
288
  * same cache through the SDK, so it skips this read. What was already read
259
289
  * is handed to that connect, so a resolver runs once per listing.
260
290
  */
261
- const cachedCatalog = authMethod((ctx) => authFlow(function* () {
291
+ const readCatalog = authMethod((ctx, use) => authFlow(function* () {
262
292
  const principal = principalPolicy
263
293
  ? requestPrincipalFor(principalPolicy, ctx, (reason) => new rt.CredentialRequiredError(`Connector "${id}" ${reason}.`))
264
294
  : undefined;
@@ -274,6 +304,7 @@ export function remoteMcpConnection(rt) {
274
304
  try {
275
305
  let epoch = "";
276
306
  let identity;
307
+ let pinnedIdentity;
277
308
  if (isOauth) {
278
309
  // Read-only grant snapshots: no in-place sealing, epoch binding or
279
310
  // refresh. Every identity check during cache acceptance re-reads the
@@ -284,6 +315,7 @@ export function remoteMcpConnection(rt) {
284
315
  epoch = snapshot.epoch;
285
316
  if (provider.isOperatorDisconnectedEpoch(epoch) || !snapshot.tokens)
286
317
  return preread;
318
+ pinnedIdentity = yield authCall(secretDigest, [ctx, JSON.stringify(snapshot.tokens)]);
287
319
  const bound = epoch;
288
320
  identity = authMethod(() => authFlow(function* () {
289
321
  const current = (yield authInvoke(provider, "peekGrant", []));
@@ -297,16 +329,18 @@ export function remoteMcpConnection(rt) {
297
329
  const digest = (yield authCall(negotiationKey, [ctx, credentialDigest, epoch]));
298
330
  // Both reads only locate cached data. Acceptance and dispatch keep
299
331
  // their fresh generation and credential checks.
300
- yield viewReads(ctx).prefetch([
301
- { storage: ctx.storage, key: negotiationKeys.verdict(digest) },
302
- {
303
- storage: serviceFor(ctx, CatalogPolicy)?.storage ?? ctx.storage,
304
- key: responseCacheKeys.generation(id),
305
- },
306
- ], (storage, keys) => authStorageMany(storage, keys));
332
+ yield authCall(prefetchCatalog, [
333
+ ctx,
334
+ id,
335
+ (yield authCall(catalogConfigFor, [ctx])),
336
+ JSON.stringify([epoch, credentialDigest]),
337
+ catalogSharing,
338
+ pinnedIdentity,
339
+ negotiationKeys.verdict(digest),
340
+ ]);
307
341
  const prior = (yield authCall(readNegotiation, [ctx, digest]));
308
342
  preread.negotiation = { digest, prior };
309
- if (prior?.kind === "modern")
343
+ if (prior?.kind === "modern" && use === "list")
310
344
  return preread;
311
345
  }
312
346
  const cache = (yield authCall(catalogClientOptions, [
@@ -320,6 +354,10 @@ export function remoteMcpConnection(rt) {
320
354
  identity,
321
355
  true,
322
356
  ]));
357
+ if (use === "peek") {
358
+ const entry = (yield authInvoke(cache, "peekTools", [ctx]));
359
+ return entry ? { ...preread, peek: entry } : preread;
360
+ }
323
361
  const result = (yield authInvoke(cache, "cachedTools", [ctx]));
324
362
  if (result)
325
363
  return { tools: result.tools };
@@ -331,6 +369,34 @@ export function remoteMcpConnection(rt) {
331
369
  }
332
370
  return preread;
333
371
  }));
372
+ // A peek is the first half of the listing that may follow it: that listing
373
+ // takes what the peek read, once, so a discovery that peeks first still reads
374
+ // its stored entry and runs a credential resolver once. Nothing is taken if
375
+ // the catalog was invalidated or the credential changed in between, and the
376
+ // whole hand-off lives and dies with the request scope.
377
+ const peeks = new WeakMap();
378
+ const peekCatalog = authMethod((ctx) => authFlow(function* () {
379
+ // A connector known to need authorization is never listed from a cache.
380
+ if (stateFor(ctx).authRequired)
381
+ return undefined;
382
+ const read = (yield authCall(readCatalog, [ctx, "peek"]));
383
+ peeks.set(ctx.requestScope ?? ctx, { identity: retainedCatalogIdentity(ctx), read });
384
+ return read.peek;
385
+ }));
386
+ const cachedCatalog = authMethod((ctx) => authFlow(function* () {
387
+ const scope = ctx.requestScope ?? ctx;
388
+ const handed = peeks.get(scope);
389
+ peeks.delete(scope);
390
+ if (!handed || handed.identity !== retainedCatalogIdentity(ctx))
391
+ return (yield authCall(readCatalog, [ctx, "list"]));
392
+ const { peek, ...preread } = handed.read;
393
+ if (!peek || peek.expiresAt <= Date.now())
394
+ return preread;
395
+ // The listing's own view keeps the provenance a direct read would record.
396
+ observeCatalogFetch(ctx, peek.fetchedAt);
397
+ observeCatalogExpiry(ctx, "tools/list", typeof peek.result.ttlMs === "number" ? peek.result.ttlMs : peek.expiresAt - peek.fetchedAt, peek.expiresAt);
398
+ return { tools: peek.result.tools };
399
+ }));
334
400
  const ensureConnected = authMethod((ctx, state, preread) => authFlow(function* () {
335
401
  let joined = state.connecting;
336
402
  if (joined)
@@ -520,6 +586,25 @@ export function remoteMcpConnection(rt) {
520
586
  ])));
521
587
  if (!owned())
522
588
  return yield* Effect.fail(rt.scopeEndedError());
589
+ // The generation a refused handshake is about, as it is now. Read from
590
+ // this connect's memo, so a connect that is not refused costs no I/O.
591
+ const handshakeBinding = yield* promised(authMethod(() => authInvoke(cacheOptions, "bindListing", [ctx, { credentialIdentity: undefined }]))).pipe(Effect.catch(() => Effect.succeed(undefined)));
592
+ if (!owned())
593
+ return yield* Effect.fail(rt.scopeEndedError());
594
+ // What the attempt sent, in memory: never the grant as it is by now. A
595
+ // provider that read no tokens in this epoch sent none and has no catalog.
596
+ const fenceHandshake = authMethod(() => authFlow(function* () {
597
+ let credentialIdentity;
598
+ if (provider) {
599
+ if (!provider.observedTokens || provider.observed?.epoch !== genAtStart)
600
+ return;
601
+ credentialIdentity = yield authCall(secretDigest, [ctx, JSON.stringify(provider.observedTokens)]);
602
+ }
603
+ (yield authInvoke(cacheOptions, "fenceTools", [
604
+ ctx,
605
+ { ...handshakeBinding, credentialIdentity },
606
+ ]));
607
+ }));
523
608
  const makeClient = () => {
524
609
  const { intake: _intake, completeCatalogRefresh: _refresh, withListing: _listing, currentContext: _context, cachedTools: _cached, ...clientOptions } = cacheOptions;
525
610
  const client = new Client({ name: "connecta", version: CONNECTA_VERSION }, {
@@ -587,7 +672,12 @@ export function remoteMcpConnection(rt) {
587
672
  }
588
673
  }))).pipe(Effect.mapError((err) => rt.atMcpBoundary(ctx,
589
674
  // First, while the runtime's text can still be read for it.
590
- rt.withoutCredential(ctx, err, credentialValue, credentialFramed), "MCP handshake", t, [ctx.signal, connectionAbort.signal, handshakeAbort.signal])));
675
+ rt.withoutCredential(ctx, err, credentialValue, credentialFramed), "MCP handshake", t, [ctx.signal, connectionAbort.signal, handshakeAbort.signal])),
676
+ // Before the lease closes the signal this write runs under. Waiters see
677
+ // the failure after it, and it never replaces the refusal.
678
+ Effect.tapError((error) => handshakeBinding && (requiresAuthorization(error) || isAccessRefusal(error))
679
+ ? promised(fenceHandshake).pipe(Effect.catch(() => Effect.void))
680
+ : Effect.void));
591
681
  held.client = c;
592
682
  // A probe deadline can end its scope while connect is still in flight.
593
683
  // The transport is closed immediately by closeScope; if connect wins
@@ -644,13 +734,13 @@ export function remoteMcpConnection(rt) {
644
734
  state.authRequired = true;
645
735
  const release = Scope.closeUnsafe(lease, Exit.void);
646
736
  if (release)
647
- detach(release);
737
+ retainCleanup(state, release);
648
738
  }
649
739
  else {
650
740
  // Whoever abandoned this attempt closed the lease already, while
651
741
  // the connect was in flight. A session it acquired after that
652
742
  // close still owes its DELETE.
653
- detach(rt.closeConnection(held.client, held.transport, ctx.logger));
743
+ retainCleanup(state, rt.closeConnection(held.client, held.transport, ctx.logger));
654
744
  }
655
745
  if (err instanceof UnauthorizedError) {
656
746
  return Effect.fail(carryFailureFacts(err, rt.authRequiredError(ctx)));
@@ -675,6 +765,7 @@ export function remoteMcpConnection(rt) {
675
765
  ensureConnected,
676
766
  requestOptions,
677
767
  cachedCatalog,
768
+ peekCatalog,
678
769
  cacheOwner,
679
770
  reset,
680
771
  states,
@@ -1,5 +1,6 @@
1
1
  // The downstream OAuth surface: bounded SDK flows, early renewal before a
2
2
  // call, unchallenged consent starts, and the connector's auth operations.
3
+ import { retainCleanup } from "./connection.js";
3
4
  import { authView } from "../../auth/native-call.js";
4
5
  import { authFlow, authCall, authInvoke, authMethod } from "../../auth/native.js";
5
6
  import { viewSignal } from "../../runtime/call.js";
@@ -10,7 +11,6 @@ import { byteReadResponse } from "../../byte-read-response.js";
10
11
  import { requestSentSecrets, sentSecretsFor } from "../../sent-secrets.js";
11
12
  import { ConnectorCallError, resendAdvice, WithheldTextError } from "../../errors.js";
12
13
  import { attachFailureFacts, carryFailureFacts, OAUTH_ERROR_CODES } from "../../operator-record.js";
13
- import { detach } from "../../runtime/run.js";
14
14
  import { retryAfterMs } from "../../http-policy.js";
15
15
  import { sendCredentialPost } from "../../guarded-transport.js";
16
16
  import { UnboundOAuthFlowsError, downstreamCallError, errorKind, hasSdkPayload, ownAbortReason, requiresAuthorization, startMessage, withheldAs, } from "./errors.js";
@@ -376,7 +376,7 @@ export function remoteMcpOAuth(rt) {
376
376
  ]));
377
377
  }
378
378
  finally {
379
- detach(rt.closeConnection(null, t, ctx.logger));
379
+ retainCleanup(state, rt.closeConnection(null, t, ctx.logger));
380
380
  }
381
381
  })), true));
382
382
  connector.verifyState = authMethod((oauthState, ctx) => authFlow(function* () {
@@ -345,8 +345,11 @@ export function remoteMcpResources(rt) {
345
345
  if (client.getProtocolEra() !== "modern" ||
346
346
  !skillObject(capabilities?.resources) ||
347
347
  !skillObject(extension) ||
348
+ // The released Beta specification defines no revision setting.
349
+ // Never enable a future breaking revision under the same ID.
350
+ extension.revision !== undefined ||
348
351
  (extension.directoryRead !== undefined && typeof extension.directoryRead !== "boolean")) {
349
- throw new ConnectorCallError("connector_call_failed", "The downstream did not declare a valid Skills extension and resources capability.");
352
+ throw new ConnectorCallError("unsupported_capability", "The downstream did not declare a valid Skills extension and resources capability.");
350
353
  }
351
354
  const result = (yield authCall(run, [client, state]));
352
355
  skillScopeOpen(ctx, state);
@@ -415,7 +418,7 @@ export function remoteMcpResources(rt) {
415
418
  rt.listingContexts.set(options, ctx);
416
419
  try {
417
420
  if (!client.getServerCapabilities()?.resources)
418
- throw unadvertisedResource();
421
+ throw new ConnectorCallError("unsupported_capability", "The downstream did not declare resources support.");
419
422
  const resources = (yield authInvoke(client, "listResources", [undefined, options]));
420
423
  const templates = (yield authInvoke(client, "listResourceTemplates", [
421
424
  undefined,
@@ -18,7 +18,7 @@ import { learnedUrlRefusal } from "../../url-safety.js";
18
18
  import { RequestScope } from "../../runtime/request-scope.js";
19
19
  import { Context } from "effect";
20
20
  import { downstreamWriteContinuation } from "../../downstream-input-context.js";
21
- import { guardedTransport, sendGuarded, sendCredentialPost } from "../../guarded-transport.js";
21
+ import { learnedDestination, guardedTransport, sendGuarded, sendCredentialPost, } from "../../guarded-transport.js";
22
22
  import { dispatchProviderRequest, recordProviderResponse, knownProviderFailure, providerRequest, renewedAfterUnauthorized, } from "../../provider-call.js";
23
23
  import { recordRequestField } from "../../provider-call-facts.js";
24
24
  import { AUTH_OR_THROTTLE, errorBodyText, jsonRpcErrorBody, keepsModernVerdict, promised } from "./errors.js";
@@ -128,11 +128,25 @@ labelErrorClass(RemoteMcpDestinationError, "RemoteMcpDestinationError");
128
128
  */
129
129
  export function learnedUrlSafeFetch(connectorId, configured, baseFetch) {
130
130
  return authMethod((input, init) => authFlow(function* () {
131
- const reason = learnedUrlRefusal(configured, new URL(input));
132
- if (reason !== undefined) {
133
- throw new RemoteMcpDestinationError(connectorId, reason);
131
+ let target = new URL(input);
132
+ for (let hop = 0; hop < 10; hop++) {
133
+ const reason = learnedUrlRefusal(configured, target);
134
+ if (reason !== undefined)
135
+ throw new RemoteMcpDestinationError(connectorId, reason);
136
+ const marked = { ...init, redirect: "manual", [learnedDestination]: configured };
137
+ const response = (yield authCall(baseFetch, [target, marked]));
138
+ if (!REDIRECT_STATUSES.has(response.status) ||
139
+ !response.headers.has("location") ||
140
+ (init?.method ?? "GET").toUpperCase() !== "GET")
141
+ return byteReadResponse(response);
142
+ const location = response.headers.get("location");
143
+ void response.body?.cancel().catch(() => { });
144
+ const redirected = new URL(location, target);
145
+ if (redirected.origin !== target.origin)
146
+ throw new RemoteMcpDestinationError(connectorId, "cross-origin redirect refused");
147
+ target = redirected;
134
148
  }
135
- return byteReadResponse((yield authCall(baseFetch, [input, init])));
149
+ throw new RemoteMcpDestinationError(connectorId, "redirect limit exceeded");
136
150
  }));
137
151
  }
138
152
  /**
@@ -289,7 +303,11 @@ export function remoteMcpTransport(rt) {
289
303
  if (customFetch && new URL(input instanceof Request ? input.url : String(input)).origin !== destination.origin)
290
304
  throw new ConnectorCallError("connector_call_failed", `Connector "${id}" refused a request outside its origin; nothing was sent.`, { retryable: false });
291
305
  // The deployment's fetch owns the wire: each write is handed over once.
292
- const sender = flight?.write ? (customSender ?? guardedTransport(true)) : undefined;
306
+ const sender = flight?.write
307
+ ? (customSender ?? guardedTransport(true))
308
+ : init?.[learnedDestination]
309
+ ? guardedTransport(false)
310
+ : undefined;
293
311
  if (flight?.write) {
294
312
  if (flight.sent) {
295
313
  if (flight.request && flight.authorization)