@zackbart/connecta 0.39.0 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/CHANGELOG.md +115 -0
  2. package/bin/connecta.mjs +4 -8
  3. package/bin/version.mjs +1 -1
  4. package/dist/access-tokens.js +6 -4
  5. package/dist/activity.d.ts +30 -4
  6. package/dist/activity.js +28 -2
  7. package/dist/agent-observation.js +24 -2
  8. package/dist/agent-routes.js +4 -41
  9. package/dist/auth/clerk.d.ts +6 -1
  10. package/dist/auth/clerk.js +16 -0
  11. package/dist/auth/cloudflare-access.js +49 -7
  12. package/dist/auth/downstream-oauth/consent.js +79 -29
  13. package/dist/auth/downstream-oauth/grant-store.js +58 -7
  14. package/dist/auth/downstream-oauth/refresh-records.js +6 -5
  15. package/dist/auth/downstream-oauth.js +12 -4
  16. package/dist/auth/native.js +10 -0
  17. package/dist/auth/oauth-request-not-sent.js +4 -0
  18. package/dist/auth/static-oauth.js +51 -21
  19. package/dist/catalog-cache.js +151 -37
  20. package/dist/catalog-freshness.js +13 -0
  21. package/dist/catalog-service.js +39 -12
  22. package/dist/catalog.js +103 -38
  23. package/dist/config.d.ts +10 -4
  24. package/dist/config.js +30 -7
  25. package/dist/connectors/remote-mcp/call.js +1 -1
  26. package/dist/connectors/remote-mcp/connection.js +52 -7
  27. package/dist/connectors/remote-mcp/oauth.js +6 -2
  28. package/dist/connectors/remote-mcp/resources.js +5 -2
  29. package/dist/connectors/remote-mcp/transport.js +37 -8
  30. package/dist/connectors/remote-mcp.js +8 -5
  31. package/dist/credential-contract.d.ts +3 -1
  32. package/dist/describe-config.d.ts +1 -0
  33. package/dist/describe-config.js +1 -0
  34. package/dist/downstream-input.js +11 -2
  35. package/dist/errors.d.ts +8 -1
  36. package/dist/errors.js +2 -0
  37. package/dist/execute.js +20 -7
  38. package/dist/executor-result.js +1 -1
  39. package/dist/executors/quickjs-child.js +1 -1
  40. package/dist/executors/quickjs-runtime.js +1 -1
  41. package/dist/executors/quickjs.js +1 -1
  42. package/dist/guarded-transport.js +2 -0
  43. package/dist/http/client.js +7 -0
  44. package/dist/http/hatch.js +2 -0
  45. package/dist/http/node-transport.js +110 -4
  46. package/dist/http/oauth.js +17 -6
  47. package/dist/index.js +10 -21
  48. package/dist/invocation-failure.js +28 -0
  49. package/dist/invocation.js +77 -35
  50. package/dist/membership.d.ts +1 -1
  51. package/dist/meta-tools.js +58 -36
  52. package/dist/module-contracts.d.ts +3 -1
  53. package/dist/native-tools.js +12 -5
  54. package/dist/node.d.ts +7 -0
  55. package/dist/node.js +82 -3
  56. package/dist/operation-activity.js +103 -0
  57. package/dist/operator-record.d.ts +8 -0
  58. package/dist/operator-record.js +15 -0
  59. package/dist/operator-ui/generated.js +2 -2
  60. package/dist/provider-call-facts.js +6 -2
  61. package/dist/provider.d.ts +1 -1
  62. package/dist/providers/_shared/google/workspace.d.ts +1 -1
  63. package/dist/providers/breeze/index.js +1 -1
  64. package/dist/providers/ccb/index.js +1 -1
  65. package/dist/providers/cloudflare/index.js +1 -1
  66. package/dist/providers/docs/index.js +1 -1
  67. package/dist/providers/drive/index.js +1 -1
  68. package/dist/providers/forms/index.js +1 -1
  69. package/dist/providers/github/index.js +1 -1
  70. package/dist/providers/gmail/index.js +1 -1
  71. package/dist/providers/granola/index.js +1 -1
  72. package/dist/providers/overflow/index.js +1 -1
  73. package/dist/providers/planning-center/index.js +1 -1
  74. package/dist/providers/planning-center/lib/constants.d.ts +1 -1
  75. package/dist/providers/quickbooks/api.js +28 -25
  76. package/dist/providers/quickbooks/index.js +1 -1
  77. package/dist/providers/quickbooks/lib/writes.js +55 -0
  78. package/dist/providers/quickbooks/safety.js +17 -5
  79. package/dist/providers/quickbooks/skill.generated.js +1 -1
  80. package/dist/providers/sheets/index.js +1 -1
  81. package/dist/providers/slides/index.js +1 -1
  82. package/dist/providers/stripe/index.js +2 -2
  83. package/dist/providers/tithely/index.js +1 -1
  84. package/dist/providers/vercel/api.js +1 -0
  85. package/dist/registry.js +16 -4
  86. package/dist/resource-read.js +21 -2
  87. package/dist/route-names.js +44 -0
  88. package/dist/routes/activity-context.js +1 -0
  89. package/dist/routes/activity-disclosure.js +26 -4
  90. package/dist/routes/activity.js +2 -1
  91. package/dist/routes/mcp.js +32 -13
  92. package/dist/routes/ui-config.js +1 -1
  93. package/dist/routes/ui-data.js +3 -2
  94. package/dist/runtime/native-storage.js +15 -0
  95. package/dist/runtime/request-reads.js +87 -10
  96. package/dist/runtime/request-scope.js +2 -1
  97. package/dist/skill-origin.js +16 -0
  98. package/dist/skills.js +132 -43
  99. package/dist/storage/d1-driver.js +33 -5
  100. package/dist/storage/keys.js +7 -1
  101. package/dist/storage/sql-native.js +85 -3
  102. package/dist/storage/sql.js +6 -0
  103. package/dist/timing-trace.js +84 -0
  104. package/dist/types.d.ts +5 -0
  105. package/dist/url-safety.js +38 -20
  106. package/dist/utility-recovery.js +33 -0
  107. package/dist/version.d.ts +1 -1
  108. package/dist/version.js +1 -1
  109. package/dist/worker.js +33 -8
  110. package/documentation/architecture.md +83 -2
  111. package/documentation/auth.md +132 -37
  112. package/documentation/code-mode.md +61 -46
  113. package/documentation/deploying.md +25 -1
  114. package/documentation/integrating.md +2 -2
  115. package/documentation/meta-tools.md +134 -15
  116. package/documentation/operating.md +86 -1
  117. package/documentation/operator-ui.md +51 -2
  118. package/documentation/providers.md +16 -14
  119. package/package.json +10 -8
  120. package/templates/node/package.json +1 -1
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]);
@@ -12,7 +12,7 @@ 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";
15
+ import { catalogClientOptions, prefetchCatalog, observeCatalogChange } from "../../catalog-cache.js";
16
16
  import { ConnectorCallError, resendAdvice } from "../../errors.js";
17
17
  import { carryFailureFacts, failureRecord, logFailure } from "../../operator-record.js";
18
18
  import { CONNECTA_VERSION } from "../../version.js";
@@ -23,11 +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 { negotiationKeys } from "../../storage/keys.js";
26
27
  import { forgetNegotiation, readNegotiation, storeNegotiation } from "../negotiation-cache.js";
27
28
  import { carriesIllegalHeaderChar, credentialHeaderValue } from "./config.js";
28
29
  import { AUTH_OR_THROTTLE, promised, recordWireErrors, requiresAuthorization } from "./errors.js";
29
30
  import { MAX_TOOL_PAGES } from "./catalog.js";
30
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
+ }
31
62
  /** One connector's connection states, credential reads and connect attempts. */
32
63
  export function remoteMcpConnection(rt) {
33
64
  const { requestAuth, forwarding, id, staticClient, clientMetadataUrl, opts, oauthScope, authorizationParams, destination, isOauth, extraHeaders, credentialHeader, credentialScheme, skillsEnabled, principalPolicy, credentialAuth, } = rt;
@@ -103,6 +134,9 @@ export function remoteMcpConnection(rt) {
103
134
  if (!state) {
104
135
  state = {
105
136
  scope: Scope.makeUnsafe(),
137
+ closing: null,
138
+ cleanup: new Set(),
139
+ defer: ctx.defer,
106
140
  lease: null,
107
141
  client: null,
108
142
  transport: null,
@@ -118,10 +152,8 @@ export function remoteMcpConnection(rt) {
118
152
  states.set(key, state);
119
153
  const owned = state;
120
154
  owner.own(() => {
121
- const closing = Scope.closeUnsafe(owned.scope, Exit.void);
155
+ void closeConnectionScope(owned);
122
156
  reset(owned);
123
- if (closing)
124
- detach(closing);
125
157
  });
126
158
  }
127
159
  return state;
@@ -189,7 +221,7 @@ export function remoteMcpConnection(rt) {
189
221
  reset(state);
190
222
  const release = lease && Scope.closeUnsafe(lease, Exit.void);
191
223
  if (release)
192
- detach(release);
224
+ retainCleanup(state, release);
193
225
  };
194
226
  const waitForConnection = (attempt, signal) => runEdge(Deferred.await(attempt), signal ? { signal } : {});
195
227
  const oauthConfigFor = (ctx) => isOauth
@@ -272,6 +304,7 @@ export function remoteMcpConnection(rt) {
272
304
  try {
273
305
  let epoch = "";
274
306
  let identity;
307
+ let pinnedIdentity;
275
308
  if (isOauth) {
276
309
  // Read-only grant snapshots: no in-place sealing, epoch binding or
277
310
  // refresh. Every identity check during cache acceptance re-reads the
@@ -282,6 +315,7 @@ export function remoteMcpConnection(rt) {
282
315
  epoch = snapshot.epoch;
283
316
  if (provider.isOperatorDisconnectedEpoch(epoch) || !snapshot.tokens)
284
317
  return preread;
318
+ pinnedIdentity = yield authCall(secretDigest, [ctx, JSON.stringify(snapshot.tokens)]);
285
319
  const bound = epoch;
286
320
  identity = authMethod(() => authFlow(function* () {
287
321
  const current = (yield authInvoke(provider, "peekGrant", []));
@@ -293,6 +327,17 @@ export function remoteMcpConnection(rt) {
293
327
  }
294
328
  if (opts.versionNegotiation !== "legacy") {
295
329
  const digest = (yield authCall(negotiationKey, [ctx, credentialDigest, epoch]));
330
+ // Both reads only locate cached data. Acceptance and dispatch keep
331
+ // their fresh generation and credential checks.
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
+ ]);
296
341
  const prior = (yield authCall(readNegotiation, [ctx, digest]));
297
342
  preread.negotiation = { digest, prior };
298
343
  if (prior?.kind === "modern")
@@ -633,13 +678,13 @@ export function remoteMcpConnection(rt) {
633
678
  state.authRequired = true;
634
679
  const release = Scope.closeUnsafe(lease, Exit.void);
635
680
  if (release)
636
- detach(release);
681
+ retainCleanup(state, release);
637
682
  }
638
683
  else {
639
684
  // Whoever abandoned this attempt closed the lease already, while
640
685
  // the connect was in flight. A session it acquired after that
641
686
  // close still owes its DELETE.
642
- detach(rt.closeConnection(held.client, held.transport, ctx.logger));
687
+ retainCleanup(state, rt.closeConnection(held.client, held.transport, ctx.logger));
643
688
  }
644
689
  if (err instanceof UnauthorizedError) {
645
690
  return Effect.fail(carryFailureFacts(err, rt.authRequiredError(ctx)));
@@ -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* () {
@@ -399,6 +399,10 @@ export function remoteMcpOAuth(rt) {
399
399
  connector.startAuth = authMethod((ctx, startOpts) => authFlow(function* () {
400
400
  ctx = authorizingContext(ctx);
401
401
  const state = rt.stateFor(ctx);
402
+ // A passive tokenless handshake was refused locally. An explicit
403
+ // start gets its own challenge-capable connection attempt.
404
+ if (state.authRequired && !state.client)
405
+ rt.closeHalf(state);
402
406
  state.provider = null;
403
407
  const p = rt.newProvider(ctx, state);
404
408
  if (ctx.signal?.aborted)
@@ -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,8 +18,8 @@ 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";
22
- import { dispatchProviderRequest, recordProviderResponse, providerRequest, renewedAfterUnauthorized, } from "../../provider-call.js";
21
+ import { learnedDestination, guardedTransport, sendGuarded, sendCredentialPost, } from "../../guarded-transport.js";
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";
25
25
  import { MAX_SKILL_READ_RPC_BYTES, boundedSkillResponse, skillObject } from "./resources.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
  /**
@@ -273,12 +287,27 @@ export function remoteMcpTransport(rt) {
273
287
  if (toolCall && !flight && !untrackedRead(toolCall.params))
274
288
  throw missingDispatch();
275
289
  const credentialPost = isOauth && (init?.method ?? "GET").toUpperCase() === "POST" && (!skillObject(rpc) || rpc.jsonrpc !== "2.0");
290
+ // OAuth discovery, registration and exchange share this fetch, but do
291
+ // not use the resource credential. Guard the assembled MCP request,
292
+ // after the SDK token read and before dispatch bookkeeping or fetch.
293
+ // OAuth is required on passive paths regardless of which provider
294
+ // read the grant. Only an explicit authorization start may probe the
295
+ // resource anonymously for an SDK challenge, never a tools/call.
296
+ if (isOauth && !credentialPost && new URL(input).href === url.href) {
297
+ const authorization = new Headers(init?.headers).get("authorization");
298
+ if ((provider?.allowAuthorization !== true || toolCall) && !authorization)
299
+ throw knownProviderFailure(rt.authRequiredError(ctx), flight?.sent ? undefined : (flight?.request?.entry ?? providerRequest(ctx, true, id)));
300
+ }
276
301
  // A custom transport reaches only the configured origin; redirects are
277
302
  // already same-origin, and OAuth cannot be combined with it.
278
303
  if (customFetch && new URL(input instanceof Request ? input.url : String(input)).origin !== destination.origin)
279
304
  throw new ConnectorCallError("connector_call_failed", `Connector "${id}" refused a request outside its origin; nothing was sent.`, { retryable: false });
280
305
  // The deployment's fetch owns the wire: each write is handed over once.
281
- 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;
282
311
  if (flight?.write) {
283
312
  if (flight.sent) {
284
313
  if (flight.request && flight.authorization)
@@ -410,7 +439,7 @@ export function remoteMcpTransport(rt) {
410
439
  }
411
440
  })));
412
441
  if (opts.auth?.type === "oauth") {
413
- const oauthProvider = provider ?? rt.newProvider(ctx, undefined, signal);
442
+ const oauthProvider = (provider ??= rt.newProvider(ctx, undefined, signal));
414
443
  // The session/provider belong to the request. A renewal belongs to the
415
444
  // exact call whose HTTP response challenged it, until claim submission.
416
445
  // Carry that ownership on the response the SDK passes to onUnauthorized;
@@ -5,7 +5,7 @@ import { authFlow, authCall, authInvoke, authValue, authMethod, authCatch } from
5
5
  import { viewServices, viewSignal } from "../runtime/call.js";
6
6
  import { declareSelfHostedClient } from "../auth/downstream-client-metadata.js";
7
7
  import { isInputRequiredResult } from "@modelcontextprotocol/client";
8
- import { Duration, Effect, Exit, Scope } from "effect";
8
+ import { Duration, Effect } from "effect";
9
9
  import { byteReadResponse } from "../byte-read-response.js";
10
10
  import { redactSentSecrets, requestSentSecrets, sentSecretsFor } from "../sent-secrets.js";
11
11
  import { closeCatalogCacheScope } from "../catalog-cache.js";
@@ -28,7 +28,7 @@ import { providerBoundary } from "../provider-call.js";
28
28
  import { downstreamCallError, hasSdkPayload, payloadFree, remoteMcpErrors } from "./remote-mcp/errors.js";
29
29
  import { LOCAL_CLOSE_BUDGET_MS, REDIRECT_STATUSES, remoteMcpTransport } from "./remote-mcp/transport.js";
30
30
  import { remoteMcpConfig } from "./remote-mcp/config.js";
31
- import { remoteMcpConnection } from "./remote-mcp/connection.js";
31
+ import { closeConnectionScope, remoteMcpConnection } from "./remote-mcp/connection.js";
32
32
  import { remoteMcpOAuth } from "./remote-mcp/oauth.js";
33
33
  import { remoteMcpCatalog } from "./remote-mcp/catalog.js";
34
34
  import { remoteMcpResources } from "./remote-mcp/resources.js";
@@ -267,11 +267,11 @@ export function remoteMcp(id, opts) {
267
267
  // Revocation and all finalizers start before any caller guard. Cleanup
268
268
  // retains its own bounded settlement even after the request has closed.
269
269
  const state = entryFor(ctx);
270
- const release = Scope.closeUnsafe(state.scope, Exit.void);
270
+ const release = closeConnectionScope(state);
271
271
  const cacheClosing = closeCatalogCacheScope(ctx, id, cacheOwner);
272
272
  reset(state);
273
273
  const closing = runEdge(Effect.all([
274
- release ?? Effect.void,
274
+ Effect.promise(() => release),
275
275
  cacheClosing
276
276
  ? Effect.raceAllFirst([
277
277
  Effect.promise(() => cacheClosing),
@@ -481,9 +481,12 @@ export function remoteMcp(id, opts) {
481
481
  const provider = newProvider(ctx, stateFor(ctx));
482
482
  if ((yield authInvoke(provider, "operatorDisconnected", [])))
483
483
  throw operatorDisconnectedError();
484
+ // Keep this separate from the disconnect read: invalidation and
485
+ // refresh can remove or replace tokens without changing the epoch.
486
+ // The SDK may otherwise send anonymously when its tokens() is empty.
484
487
  if (!(yield authInvoke(provider, "tokens", [])))
485
488
  throw authRequiredError(ctx);
486
- // That read decides whether the token is about to expire.
489
+ // That read also decides whether the token is about to expire.
487
490
  const due = provider.renewalDue();
488
491
  if (due !== undefined)
489
492
  (yield authCall(renewBeforeCall, [ctx, due]));
@@ -39,7 +39,9 @@ export interface CredentialVault {
39
39
  * Encrypt downstream OAuth state (tokens, a registered client, a PKCE
40
40
  * verifier) before it enters storage. `purpose` is the storage key the value
41
41
  * is written under, so the result is bound to one connector, owner, and key.
42
- * Without both `seal` and `open`, that state is stored as plaintext.
42
+ * Construction refuses a vault without both `seal` and `open` when any
43
+ * connector has `startAuth`, so that state is never left plaintext beside
44
+ * credentials this vault encrypts.
43
45
  */
44
46
  seal?(connectorId: string, purpose: string, plaintext: string, owner?: string): Promise<string>;
45
47
  /** Sign browser OAuth handoffs with a purpose-specific key; nothing is stored. */
@@ -151,6 +151,7 @@ export interface ConnectaConfigDescription {
151
151
  };
152
152
  activity: {
153
153
  enabled: boolean;
154
+ operations?: true;
154
155
  readable?: boolean;
155
156
  deploymentId?: string;
156
157
  store?: {
@@ -269,6 +269,7 @@ export function describeConfig(input) {
269
269
  ? {
270
270
  enabled: true,
271
271
  readable: typeof config.activity.store.list === "function",
272
+ ...(config.activity.operations ? { operations: true } : {}),
272
273
  ...(str(config.activity.deploymentId) !== undefined ? { deploymentId: config.activity.deploymentId } : {}),
273
274
  store: {
274
275
  kind: storeKind(activityStore),
@@ -316,12 +316,21 @@ export class DownstreamElicitation {
316
316
  // The nonce is spent even when the downstream failure would normally
317
317
  // allow a retry. Preserve write reconciliation and auth prerequisites.
318
318
  const echoed = downstreamArgumentEcho(self.options.requestScope);
319
+ const recoveryError = { ...error };
320
+ const freshRead = state.tool === "call_tool" &&
321
+ error.code !== "provider_permission_denied" &&
322
+ error.nextAction === undefined &&
323
+ error.recovery === undefined &&
324
+ error.writeOutcome !== "unknown" &&
325
+ error.uncertainCall === undefined;
326
+ if (freshRead)
327
+ delete recoveryError.retry;
319
328
  const structuredContent = {
320
329
  ...result.structuredContent,
321
330
  error: {
322
- ...error,
331
+ ...recoveryError,
323
332
  retryable: false,
324
- ...(state.tool === "call_tool" && error.nextAction === undefined && error.retry === undefined
333
+ ...(freshRead
325
334
  ? {
326
335
  nextAction: {
327
336
  tool: state.tool,
package/dist/errors.d.ts CHANGED
@@ -24,7 +24,7 @@ export type ConnectorCallErrorCode = "timeout" | "auth_required" | "downstream_o
24
24
  * change, and retries with the base `current` reports — repeating the same
25
25
  * call can never succeed, and waiting will not help either.
26
26
  */
27
- | "conflict" | "resource_template_ambiguous" | "resource_match_budget_exceeded" | "input_required_unsupported" | "input_required_invalid" | "input_required_limit" | "connector_call_failed";
27
+ | "conflict" | "resource_template_ambiguous" | "resource_match_budget_exceeded" | "unsupported_capability" | "input_required_unsupported" | "input_required_invalid" | "input_required_limit" | "connector_call_failed";
28
28
  /**
29
29
  * Every classification code connecta assigns: a connector's typed codes, the
30
30
  * executor's admission codes, and the refusals connecta frames itself.
@@ -285,6 +285,13 @@ export interface CallErrorDetails extends Partial<AuthorizationHandoff> {
285
285
  writeOutcome?: "unknown";
286
286
  /** The single model-facing entry point for every credential class. */
287
287
  nextAction?: {
288
+ tool: "skills";
289
+ arguments: Record<string, never>;
290
+ purpose: string;
291
+ } | {
292
+ action: "reconnect_host" | "contact_operator" | "inspect_capabilities" | "recover_result";
293
+ purpose: string;
294
+ } | {
288
295
  tool: "authorize_connector";
289
296
  arguments: {
290
297
  connector: string;
package/dist/errors.js CHANGED
@@ -15,6 +15,7 @@ const CLASSIFICATION_CODE_TABLE = {
15
15
  conflict: true,
16
16
  resource_template_ambiguous: true,
17
17
  resource_match_budget_exceeded: true,
18
+ unsupported_capability: true,
18
19
  input_required_unsupported: true,
19
20
  input_required_invalid: true,
20
21
  input_required_limit: true,
@@ -337,6 +338,7 @@ const RETRYABLE_BY_CODE = {
337
338
  conflict: false,
338
339
  resource_template_ambiguous: false,
339
340
  resource_match_budget_exceeded: false,
341
+ unsupported_capability: false,
340
342
  input_required_unsupported: false,
341
343
  input_required_invalid: false,
342
344
  input_required_limit: false,
package/dist/execute.js CHANGED
@@ -1,3 +1,6 @@
1
+ import { operationActivity } from "./operation-activity.js";
2
+ import { SkillLookupError } from "./skills.js";
3
+ import { skillLookupRecovery, utilityRecovery } from "./utility-recovery.js";
1
4
  import { META_ROUTES, routeNames } from "./agent-routes.js";
2
5
  import { authCall, authFlow, authInvoke, authMethod } from "./auth/native.js";
3
6
  import { nativeRecovery } from "./native-recovery.js";
@@ -342,7 +345,9 @@ function serializedDiagnosticBytes(value) {
342
345
  }
343
346
  }
344
347
  function guestFailure(code, message, retryable = false) {
345
- return new InvocationFailure({ code, message, retryable });
348
+ return new InvocationFailure(["unavailable", "unsupported_capability", "input_required_unsupported"].includes(code)
349
+ ? utilityRecovery(code, message, retryable)
350
+ : { code, message, retryable });
346
351
  }
347
352
  /**
348
353
  * E1/X11: bound typed host failure metadata, including JSON escapes, before transport.
@@ -724,7 +729,7 @@ function sandboxProvider(registry, baseUrl, activity, limits, requestScope = {})
724
729
  if (typeof id !== "string" ||
725
730
  (!options && options !== undefined) ||
726
731
  (options !== undefined && (typeof options !== "object" || Array.isArray(options)))) {
727
- throw guestFailure("invalid_args", "Use connecta.result(handleOrId, { page?, offset?, maxBytes? }).");
732
+ throw new InvocationFailure(utilityRecovery("invalid_args", "Use connecta.result(handleOrId, { page?, offset?, maxBytes? })."));
728
733
  }
729
734
  const args = { ...(options ?? {}) };
730
735
  if (Object.keys(args).some((key) => !["page", "offset", "maxBytes"].includes(key)) ||
@@ -737,7 +742,7 @@ function sandboxProvider(registry, baseUrl, activity, limits, requestScope = {})
737
742
  (typeof args.offset !== "number" || !Number.isSafeInteger(args.offset) || args.offset < 0)) ||
738
743
  (args.maxBytes !== undefined &&
739
744
  (typeof args.maxBytes !== "number" || !Number.isSafeInteger(args.maxBytes) || args.maxBytes < 1))) {
740
- throw guestFailure("invalid_args", "Use connecta.result(handleOrId, { page?, offset?, maxBytes? }) with whole page/offset >= 0 and maxBytes >= 1; page and offset are mutually exclusive.");
745
+ throw new InvocationFailure(utilityRecovery("invalid_args", "Use connecta.result(handleOrId, { page?, offset?, maxBytes? }) with whole page/offset >= 0 and maxBytes >= 1; page and offset are mutually exclusive."));
741
746
  }
742
747
  // A page must fit every shipped bridge even when the connector cap is raised.
743
748
  args.maxBytes = Math.min(args.maxBytes ?? PROGRAM_RESULT_PAGE_BYTES, PROGRAM_RESULT_PAGE_BYTES);
@@ -768,6 +773,7 @@ function sandboxProvider(registry, baseUrl, activity, limits, requestScope = {})
768
773
  if (typeof skillName !== "string")
769
774
  throw guestFailure("invalid_args", 'Use connecta.skill("usage") or connecta.skill("connector:<id>") with an exact skill name; the skills tool lists names.');
770
775
  const skills = new SkillsRegistry(registry, baseUrl, {
776
+ readOnly: limits.readOnly,
771
777
  requestScope,
772
778
  requestSignal: utilitySignal ?? hostAccessSignal,
773
779
  onRead: (connector, source) => limits.resultDependencies?.push({
@@ -780,7 +786,11 @@ function sandboxProvider(registry, baseUrl, activity, limits, requestScope = {})
780
786
  });
781
787
  return { name: skillName, format: "text", text: await skills.text(skillName) };
782
788
  },
783
- catch: (err) => err instanceof ConnectorCallError ? guestFailure(err.code, err.message, err.code === "unavailable") : err,
789
+ catch: (err) => err instanceof SkillLookupError
790
+ ? new InvocationFailure(skillLookupRecovery(err.message))
791
+ : err instanceof ConnectorCallError
792
+ ? guestFailure(err.code, "Skills are unavailable.", err.retryable)
793
+ : err,
784
794
  }),
785
795
  read: (uri) => Effect.suspend(() => {
786
796
  const started = Date.now();
@@ -923,7 +933,10 @@ function sandboxProvider(registry, baseUrl, activity, limits, requestScope = {})
923
933
  timeoutError: guestFailure("timeout", `connecta.${name} timed out.`),
924
934
  })
925
935
  : invoke;
926
- const settled = runEdge(guarded.pipe(Effect.catch((error) => {
936
+ const operationEffect = activity?.recordOperation && ["search", "describe", "result", "skill"].includes(name)
937
+ ? operationActivity(activity, registry, name, guarded)
938
+ : guarded;
939
+ const settled = runEdge(operationEffect.pipe(Effect.catch((error) => {
927
940
  if (deadline && hostAccessSignal?.aborted)
928
941
  return typedFailure(guestFailure("cancelled", `connecta.${name} was cancelled because the run ended.`));
929
942
  if (deadline && error instanceof InvocationFailure && error.code === "timeout") {
@@ -1230,7 +1243,7 @@ export function createExecuteTool(registry, baseUrl, executor, logger, activity,
1230
1243
  }
1231
1244
  return play(normalizeProgramSource(code), diagnostics, options.signal, requestScope, hostCalls, programWrites);
1232
1245
  });
1233
- return response.pipe(Effect.provideService(RequestScope, owner), Effect.map((unfinished) => {
1246
+ return operationActivity(activity, registry, "program", response.pipe(Effect.provideService(RequestScope, owner), Effect.map((unfinished) => {
1234
1247
  // Every response, including intake refusals, crosses accounting and
1235
1248
  // exactly one envelope check at this operation boundary.
1236
1249
  const result = redactAgentOutput(sentSecrets, programWrites.finish(unfinished));
@@ -1241,7 +1254,7 @@ export function createExecuteTool(registry, baseUrl, executor, logger, activity,
1241
1254
  if (result.content[0]?.type === "text")
1242
1255
  result.content[0].text = JSON.stringify(result.structuredContent);
1243
1256
  return result;
1244
- }), Effect.catchDefect(Effect.fail), Effect.mapError((error) => redactAgentOutput(sentSecrets, error)));
1257
+ }), Effect.catchDefect(Effect.fail), Effect.mapError((error) => redactAgentOutput(sentSecrets, error))), () => ({ ...hostCalls, failed: hostCalls.attempted - hostCalls.succeeded }));
1245
1258
  }));
1246
1259
  }
1247
1260
  /** An execution that never produced an ExecuteResult, as the model sees it. */
@@ -1,4 +1,4 @@
1
- import { META_ROUTES } from "./agent-routes.js";
1
+ import { META_ROUTES } from "./route-names.js";
2
2
  import { CONFIG_DEFAULTS } from "./config-defaults.js";
3
3
  import { msg } from "./errors.js";
4
4
  /** ~6k tokens. Sandbox code should filter data down before returning. */
@@ -1,5 +1,5 @@
1
1
  import { prepareExecuteResultForTransport } from "../executor-result.js";
2
- import { InvocationFailure } from "../invocation.js";
2
+ import { InvocationFailure } from "../invocation-failure.js";
3
3
  import { msg } from "../errors.js";
4
4
  import { MAX_QUICKJS_IPC_BYTES, MAX_QUICKJS_RESULT_BYTES, QUICKJS_RESULT_CHUNK_CHARS, MAX_QUICKJS_HOST_RPC_BYTES, serializedBytes, stringifyBounded, } from "./quickjs-protocol.js";
5
5
  import { executeQuickJs, prepareQuickJs } from "./quickjs-runtime.js";
@@ -9,7 +9,7 @@ import { utf8Length } from "../bytes.js";
9
9
  // continuations from a host-side loop. Values cross the boundary as JSON, so
10
10
  // provider args/results must be JSON-serializable.
11
11
  import { getQuickJS } from "quickjs-emscripten";
12
- import { InvocationFailure } from "../invocation.js";
12
+ import { InvocationFailure } from "../invocation-failure.js";
13
13
  import { msg } from "../errors.js";
14
14
  import { withoutProgramTerminator } from "../program-source.js";
15
15
  import { hostCallLabel, MAX_QUICKJS_LOG_TRANSPORT_BYTES, serializedBytes } from "./quickjs-protocol.js";
@@ -18,7 +18,7 @@ import { fileURLToPath } from "node:url";
18
18
  import { Deferred, Duration, Effect, Exit, Schedule, Scope } from "effect";
19
19
  import { AdmissionController, ExecutorAdmissionError, ExecutorExecutionError } from "../executor-admission.js";
20
20
  import { msg } from "../errors.js";
21
- import { InvocationFailure } from "../invocation.js";
21
+ import { InvocationFailure } from "../invocation-failure.js";
22
22
  import { brandExecutor } from "../executor-contract.js";
23
23
  import { MAX_EXECUTE_LOG_CHARS } from "../executor-result.js";
24
24
  import { admit } from "../runtime/admission.js";