@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
@@ -6,7 +6,7 @@ import { SentSecrets } from "../sent-secrets.js";
6
6
  import { authenticateRemoteClient, downstreamClientMetadata, remoteClientAuthMethod, } from "./downstream-client-metadata.js";
7
7
  import { authMethods } from "./native.js";
8
8
  import { OAUTH_REFRESH_SKEW_MS } from "./oauth-grant.js";
9
- import { consentMethods } from "./downstream-oauth/consent.js";
9
+ import { consentMethods, withoutUrlSecrets } from "./downstream-oauth/consent.js";
10
10
  import { ACTIVE_EPOCH_PREFIX, GRANT, grantStoreMethods, isDisconnectedEpoch, } from "./downstream-oauth/grant-store.js";
11
11
  import { aborted, tokenResponse, } from "./downstream-oauth/refresh-coordinator.js";
12
12
  import { refreshRecordMethods } from "./downstream-oauth/refresh-records.js";
@@ -32,8 +32,7 @@ export function authorizingContext(ctx) {
32
32
  return deriveContext(ctx, { allowAuthorization: true });
33
33
  }
34
34
  /** A local destination guard proved that no OAuth HTTP request left. */
35
- export class OAuthRequestNotSentError extends ConnectorCallError {
36
- }
35
+ export { OAuthRequestNotSentError } from "./oauth-request-not-sent.js";
37
36
  /**
38
37
  * OAuthClientProvider over one owner's grant record and its consents' flow
39
38
  * records, in one connector's namespace.
@@ -106,6 +105,8 @@ export class KvOAuthProvider {
106
105
  * only: no credential beyond the access token it already handed out.
107
106
  */
108
107
  observed;
108
+ /** The same grant's tokens, which a catalog's identity digests. Memory only, like the grant they came from. */
109
+ observedTokens;
109
110
  constructor(connectorId, storage, redirectUri, refreshCoordinator, allowAuthorization = true,
110
111
  /** Present when the deployment's vault can seal; the grant is then ciphertext. */
111
112
  sealer,
@@ -154,9 +155,11 @@ export class KvOAuthProvider {
154
155
  }
155
156
  observe(grant) {
156
157
  const { tokens, expiresAt } = grant.body;
158
+ this.observedTokens = tokens;
157
159
  this.observed = tokens
158
160
  ? {
159
161
  epoch: grant.epoch,
162
+ revision: grant.tokenRevision ?? 0,
160
163
  accessToken: tokens.access_token,
161
164
  refreshable: typeof tokens.refresh_token === "string" && tokens.refresh_token !== "",
162
165
  ...(expiresAt !== undefined ? { expiresAt } : {}),
@@ -195,7 +198,7 @@ export class KvOAuthProvider {
195
198
  if (!this.callback ||
196
199
  (issuer === null
197
200
  ? this.callback.flow.binding?.issRequired === true
198
- : issuer !== this.callback.flow.binding?.issuer)) {
201
+ : this.flowValue(issuer) !== this.flowValue(this.callback.flow.binding?.issuer))) {
199
202
  throw new ConnectorCallError("connector_call_failed", `Connector "${this.connectorId}" authorization callback issuer does not match its consent; nothing was exchanged.`);
200
203
  }
201
204
  }
@@ -203,6 +206,14 @@ export class KvOAuthProvider {
203
206
  flowLabel(key, epoch) {
204
207
  return `${key}:${epoch}`;
205
208
  }
209
+ /** The sealed record's own label, so a verifier's ciphertext cannot stand in for it. */
210
+ flowRecordLabel(key, epoch) {
211
+ return `${this.flowLabel(key, epoch)}:record`;
212
+ }
213
+ /** A binding value as this provider's flow records hold it, for comparison. */
214
+ flowValue(value) {
215
+ return this.sealer ? value : withoutUrlSecrets(value);
216
+ }
206
217
  isOperatorDisconnectedEpoch(epoch) {
207
218
  return isDisconnectedEpoch(epoch);
208
219
  }
@@ -0,0 +1,4 @@
1
+ import { ConnectorCallError } from "../errors.js";
2
+ /** A local destination guard proved that no OAuth HTTP request left. */
3
+ export class OAuthRequestNotSentError extends ConnectorCallError {
4
+ }
@@ -397,25 +397,31 @@ export function staticOAuth(id, config) {
397
397
  return new StaticOAuthProvider(id, ctx, coordinatorFor(ctx), settings, requestOwned);
398
398
  };
399
399
  /**
400
- * The owned credential transport, with the configured token-request
401
- * headers laid over the SDK's own. With discovery and registration answered
402
- * from configuration the token endpoint is the only URL the SDK fetches here;
403
- * the check keeps the headers on it even if that ever changes.
400
+ * The token request as sent, with the configured token-request headers laid
401
+ * over the SDK's own, and every credential it carries registered. With
402
+ * discovery and registration answered from configuration the token endpoint is
403
+ * the only URL the SDK fetches here; the check keeps the headers on it even if
404
+ * that ever changes.
404
405
  */
406
+ const registeredTokenRequest = (secrets, input, init = {}) => {
407
+ const url = new URL(input);
408
+ const request = { ...init, redirect: "manual" };
409
+ if (settings.tokenRequestHeaders.length && `${url.origin}${url.pathname}` === settings.identity) {
410
+ const headers = new Headers(init.headers);
411
+ for (const [name, value] of settings.tokenRequestHeaders)
412
+ headers.set(name, value);
413
+ for (const [name, value] of settings.tokenRequestHeaders)
414
+ if (!PUBLIC_TOKEN_REQUEST_HEADERS.get(name.toLowerCase())?.has(value))
415
+ secrets.header(value);
416
+ request.headers = headers;
417
+ }
418
+ trackRemoteClientRequest(secrets, input, request);
419
+ return request;
420
+ };
421
+ /** The owned credential transport. */
405
422
  const tokenEndpointFetch = authMethod((secrets, input, init = {}) => {
406
423
  return authFlow(function* () {
407
- const url = new URL(input);
408
- const request = { ...init, redirect: "manual" };
409
- if (settings.tokenRequestHeaders.length && `${url.origin}${url.pathname}` === settings.identity) {
410
- const headers = new Headers(init.headers);
411
- for (const [name, value] of settings.tokenRequestHeaders)
412
- headers.set(name, value);
413
- for (const [name, value] of settings.tokenRequestHeaders)
414
- if (!PUBLIC_TOKEN_REQUEST_HEADERS.get(name.toLowerCase())?.has(value))
415
- secrets.header(value);
416
- request.headers = headers;
417
- }
418
- trackRemoteClientRequest(secrets, input, request);
424
+ const request = registeredTokenRequest(secrets, input, init);
419
425
  // The coordinator or code-exchange wrapper authorizes this send. Refresh
420
426
  // claim submission may outlive the caller; this transport uses no
421
427
  // request-owned capability and changes no closed resource-call ledger.
@@ -425,7 +431,6 @@ export function staticOAuth(id, config) {
425
431
  provider: id,
426
432
  method: request.method ?? "GET",
427
433
  };
428
- secrets.request(input, request);
429
434
  const response = (yield authCall(sendCredentialPost, [
430
435
  input,
431
436
  request,
@@ -446,6 +451,7 @@ export function staticOAuth(id, config) {
446
451
  early) => {
447
452
  return authFlow(function* () {
448
453
  const secrets = sentSecretsFor(ctx);
454
+ const owner = viewSignal(ctx, requestOwned || exchange !== undefined);
449
455
  const coordinated = coordinatorFor(ctx).coordinatedFetch(provider, authMethod((input, init) => {
450
456
  return authFlow(function* () {
451
457
  if (exchange) {
@@ -478,8 +484,10 @@ export function staticOAuth(id, config) {
478
484
  }
479
485
  return (yield authCall(tokenEndpointFetch, [secrets, input, init]));
480
486
  });
481
- }), viewSignal(ctx, requestOwned || exchange !== undefined), ctx.defer, undefined, () => {
487
+ }), owner, ctx.defer, undefined, (input, init) => {
482
488
  guardedTransport(true);
489
+ if (!owner.aborted)
490
+ registeredTokenRequest(secrets, input, init);
483
491
  });
484
492
  return (yield authCall(auth, [
485
493
  provider,
@@ -547,12 +555,14 @@ export function staticOAuth(id, config) {
547
555
  // A token that lives under a minute is due again at once: the invocation's
548
556
  // check renews it, and the handler sends it while it is unexpired.
549
557
  const earlyRenewed = new EarlyRenewals();
550
- /**
551
- * One call's grant use; submitted refresh work keeps the coordinator's owner.
552
- * `renewAgain` is the invocation's check, which renews any due token,
553
- * even one this request's earlier renewal produced.
554
- */
555
558
  const grantFor = (ctx, requestOwned = false, renewAgain = false) => {
559
+ const observations = new Map();
560
+ const capture = (provider, token) => {
561
+ const seen = provider.observed;
562
+ if (seen?.accessToken === token)
563
+ observations.set(token, { epoch: seen.epoch, revision: seen.revision });
564
+ return token;
565
+ };
556
566
  let provider;
557
567
  const use = authMethod((work) => {
558
568
  return authView(ctx, authMethod((signal) => {
@@ -611,9 +621,9 @@ export function staticOAuth(id, config) {
611
621
  true,
612
622
  ]));
613
623
  earlyRenewed.record(scope, tokens.access_token, fresh, provider.observedExpiry(fresh));
614
- return fresh;
624
+ return capture(provider, fresh);
615
625
  }
616
- return tokens.access_token;
626
+ return capture(provider, tokens.access_token);
617
627
  });
618
628
  }),
619
629
  ]);
@@ -621,10 +631,21 @@ export function staticOAuth(id, config) {
621
631
  refresh: authMethod((stale) => {
622
632
  return authCall(use, [
623
633
  authMethod((provider, live) => {
624
- return authCall(renewedToken, [provider, live, stale, requestOwned]);
634
+ return authFlow(function* () {
635
+ const token = (yield authCall(renewedToken, [provider, live, stale, requestOwned]));
636
+ return capture(provider, token);
637
+ });
625
638
  }),
626
639
  ]);
627
640
  }),
641
+ observe: authMethod((token, outcome) => {
642
+ const snapshot = observations.get(token);
643
+ if (!snapshot)
644
+ return Effect.void;
645
+ return authCall(use, [
646
+ authMethod((provider) => authInvoke(provider, "recordTokenOutcome", [snapshot, outcome])),
647
+ ]).pipe(Effect.asVoid, Effect.catch(() => Effect.void));
648
+ }),
628
649
  rejected: authMethod(() => {
629
650
  return authView(ctx, authMethod(() => {
630
651
  return authValue(() => {
@@ -735,6 +756,10 @@ export function staticOAuth(id, config) {
735
756
  const grant = grantFor(ctx, auxiliary);
736
757
  const stale = (yield authInvoke(grant, "token", []));
737
758
  const { response: first, dispatch } = (yield authCall(send, [stale]));
759
+ if (first.status === 401 || (first.status >= 200 && first.status < 300))
760
+ yield authInvoke(grant, "observe", [stale, first.status === 401 ? "refused" : "accepted"]);
761
+ if (signal.aborted)
762
+ throw signal.reason;
738
763
  if (first.status !== 401)
739
764
  return first;
740
765
  try {
@@ -773,6 +798,10 @@ export function staticOAuth(id, config) {
773
798
  throw signal.reason;
774
799
  renewedAfterUnauthorized(dispatch, stale, accessToken);
775
800
  const { response: second } = (yield authCall(send, [accessToken]));
801
+ if (second.status === 401 || (second.status >= 200 && second.status < 300))
802
+ yield authInvoke(grant, "observe", [accessToken, second.status === 401 ? "refused" : "accepted"]);
803
+ if (signal.aborted)
804
+ throw signal.reason;
776
805
  if (second.status !== 401)
777
806
  return second;
778
807
  void second.body?.cancel().catch(() => { });
@@ -800,7 +829,7 @@ export function staticOAuth(id, config) {
800
829
  }), auxiliary));
801
830
  });
802
831
  });
803
- const statusOf = authMethod((ctx, provider) => {
832
+ const statusOf = authMethod((ctx, provider, forCall = false) => {
804
833
  return authFlow(function* () {
805
834
  if (rejectedScopes.has(scopeOf(ctx))) {
806
835
  return {
@@ -808,16 +837,18 @@ export function staticOAuth(id, config) {
808
837
  message: "Authorization required \u2014 the downstream rejected this connector's OAuth grant.",
809
838
  };
810
839
  }
811
- // A status read never starts authorization: this provider cannot, and
812
- // nothing here calls the SDK. A stored grant for the configured token
813
- // endpoint is healthy until a call says otherwise — the downstream is
814
- // asked nothing.
815
- if ((yield authInvoke(provider, "operatorDisconnected", []))) {
840
+ // Peek opens the current grant without migration writes or authorization.
841
+ const grant = (yield authInvoke(provider, "peekGrant", []));
842
+ if (provider.isOperatorDisconnectedEpoch(grant.epoch))
816
843
  return { state: "auth_required", message: disconnectedMessage };
817
- }
818
- return (yield authInvoke(provider, "tokens", [issuerContext]))
819
- ? { state: "ok" }
820
- : { state: "auth_required", message: AUTH_REQUIRED_MESSAGE };
844
+ if (!grant.tokens || grant.issuer !== settings.identity)
845
+ return { state: "auth_required", message: AUTH_REQUIRED_MESSAGE };
846
+ return !forCall && grant.observation?.outcome === "refused"
847
+ ? {
848
+ state: "auth_required",
849
+ message: "Authorization required — the downstream rejected this connector's OAuth grant.",
850
+ }
851
+ : { state: "ok" };
821
852
  });
822
853
  });
823
854
  const status = authMethod((ctx) => {
@@ -832,7 +863,9 @@ export function staticOAuth(id, config) {
832
863
  const prepareCall = authMethod((ctx) => {
833
864
  return authFlow(function* () {
834
865
  const provider = providerFor(ctx);
835
- const state = (yield authCall(statusOf, [ctx, provider]));
866
+ // A persisted API refusal is diagnostic, not proof the next request will fail.
867
+ // Keep the request-local latch, but let a later invocation observe recovery.
868
+ const state = (yield authCall(statusOf, [ctx, provider, true]));
836
869
  if (state.state === "ok" && provider.renewalDue() !== undefined)
837
870
  (yield authInvoke(grantFor(ctx, false, true), "token", []));
838
871
  return state;
@@ -849,8 +882,13 @@ export function staticOAuth(id, config) {
849
882
  : (yield authInvoke(provider, "operatorDisconnected", []));
850
883
  if (ctx.signal?.aborted)
851
884
  throw ctx.signal.reason;
852
- if (opts?.force || disconnected) {
853
- (yield authInvoke(provider, "resetAuthorization", []));
885
+ const observed = (yield authInvoke(provider, "peekGrant", []));
886
+ const refused = observed.observation?.outcome === "refused";
887
+ if (opts?.force || disconnected || refused || rejectedScopes.has(scopeOf(original))) {
888
+ const fence = !opts?.force && !disconnected && refused
889
+ ? { epoch: observed.epoch, revision: observed.observation.revision }
890
+ : undefined;
891
+ (yield authInvoke(provider, "resetAuthorization", [false, false, fence]));
854
892
  rejectedScopes.delete(scopeOf(original));
855
893
  }
856
894
  else {
@@ -3,7 +3,7 @@ import { RequestScope, requestOwner } from "./runtime/request-scope.js";
3
3
  import { upwardStep } from "./upward.js";
4
4
  import { authAwait, authCall, authInvoke, authFlow, authMethod, registerAuthMethod, authOwnMethod, } from "./auth/native.js";
5
5
  import { registryProgram } from "./runtime/registry.js";
6
- import { enableAuthorizationHandoffs } from "./authorization-handoff.js";
6
+ import { authorizationHandoff, connectLinkFields, enableAuthorizationHandoffs } from "./authorization-handoff.js";
7
7
  import { createRequestStateCodec, inputRequired, ProtocolError, INVALID_PARAMS, PROTOCOL_VERSION_META_KEY, CLIENT_CAPABILITIES_META_KEY, } from "@modelcontextprotocol/server";
8
8
  import { Effect } from "effect";
9
9
  import { runEdge, withDeadlineEffect } from "./runtime/run.js";
@@ -17,6 +17,23 @@ import { DownstreamElicitation, MAX_RELAY_STATE_CHARS, } from "./downstream-inpu
17
17
  import { canonicalState as canonical, requestDigest as digest, stateObject as object, invalidRequestState as invalidState, REQUEST_STATE_TTL_MS as TTL_MS, MAX_INPUT_ROUNDS as MAX_ROUNDS, } from "./request-state.js";
18
18
  const INPUT_KEY = "connecta_auth";
19
19
  const MESSAGE = "Connect this service in your browser, then retry the request.";
20
+ // A client may answer without showing anything, and the response cannot prove
21
+ // otherwise. Neither text claims a person saw the prompt, and neither names a
22
+ // route: an agent told to call again would only reach the same refusal.
23
+ const REFUSED = {
24
+ decline: {
25
+ code: "auth_declined",
26
+ message: "The client declined the connection prompt, possibly automatically, so the user may not have seen it. Nothing was retried.",
27
+ },
28
+ cancel: {
29
+ code: "auth_cancelled",
30
+ message: "The client cancelled the connection prompt, possibly automatically, so the user may not have seen it. Nothing was retried.",
31
+ },
32
+ };
33
+ // Only a repeat can recover a refusal whose link failed, and it asks for a new
34
+ // prompt, so a person's refusal stays final. Nothing on the server limits it to one.
35
+ const REFUSED_NO_LINK = " No connection link could be issued. If the user has not refused this connection, repeat this request once for a new link, then give it to them. Stop if that repeat also fails.";
36
+ const refusedPurpose = (recipient) => `Give this link to the user, unchanged, ${recipient}once, then stop. Do not prompt again, retry, or reach this service another way until they confirm. If they already refused this connection in this conversation, only mention that the link is available.`;
20
37
  function failure(code, message) {
21
38
  return toolError({ code, message, retryable: false }, { ok: false });
22
39
  }
@@ -45,14 +62,26 @@ export class AuthElicitation {
45
62
  context,
46
63
  ]));
47
64
  }
48
- browserProgress(state) {
49
- return runEdge(authOwnMethod(AuthElicitation.prototype, this, "browserProgress", [
50
- state,
51
- ]));
65
+ browserCompleted(state) {
66
+ return runEdge(authOwnMethod(AuthElicitation.prototype, this, "browserCompleted", [state]));
52
67
  }
53
68
  connected(id, context) {
54
69
  return runEdge(authOwnMethod(AuthElicitation.prototype, this, "connected", [id, context]));
55
70
  }
71
+ refused(action, state, force) {
72
+ return runEdge(authOwnMethod(AuthElicitation.prototype, this, "refused", [action, state, force]));
73
+ }
74
+ /** The one gate before any recovery link: this caller may connect this connector here. */
75
+ mayConnect(id) {
76
+ const { registry, principal, publicUrl, canManage, unavailable, credentialUi } = this.options;
77
+ const connector = typeof id === "string" ? registry.getConnector(id) : undefined;
78
+ return (typeof id === "string" &&
79
+ /^[a-z0-9_-]+$/.test(id) &&
80
+ Boolean(connector?.startAuth || (connector?.credential && credentialUi)) &&
81
+ Boolean(principal && publicUrl) &&
82
+ !unavailable &&
83
+ canManage(id));
84
+ }
56
85
  bind(context) {
57
86
  this.downstream.bind(context);
58
87
  }
@@ -114,12 +143,8 @@ export class AuthElicitation {
114
143
  !(yield* upwardStep(authInvoke(self.options, "claimRetry", [previous.connector, previous.browserNonces.at(-1)])))) {
115
144
  return failure("invalid_request_state", "Invalid or expired requestState");
116
145
  }
117
- if (previous && object(response)) {
118
- if (response.action === "decline")
119
- return failure("auth_declined", "Connection was declined. The request was not retried.");
120
- if (response.action === "cancel")
121
- return failure("auth_cancelled", "Connection was cancelled. The request was not retried.");
122
- }
146
+ if (previous && object(response) && (response.action === "decline" || response.action === "cancel"))
147
+ return yield* upwardStep(authInvoke(self, "refused", [response.action, previous, args.force]));
123
148
  if (previous?.reads.length) {
124
149
  const catalog = new CatalogService(self.options.registry, self.options.publicUrl, {
125
150
  requestScope: self.options.requestScope,
@@ -156,18 +181,12 @@ export class AuthElicitation {
156
181
  }
157
182
  bindReplayReads(self.options.requestScope, previous.reads);
158
183
  }
159
- const browser = previous && tool === "authorize_connector"
160
- ? yield* upwardStep(authInvoke(self, "browserProgress", [previous]))
161
- : undefined;
162
- if (browser?.completed && previous) {
163
- // Retire unspent links before the status read. A browser claim racing
164
- // these CASes either wins and is observed as pending, or is refused.
165
- yield* upwardStep(authInvoke(self.options, "closeLinks", [previous.connector, previous.browserNonces]));
166
- if ((yield* upwardStep(authInvoke(self, "browserProgress", [previous]))).completed &&
167
- (yield* upwardStep(authInvoke(self, "connected", [previous.connector, context])))) {
168
- const structuredContent = { connector: previous.connector, status: "ok" };
169
- return { structuredContent, content: [{ type: "text", text: JSON.stringify(structuredContent) }] };
170
- }
184
+ if (previous &&
185
+ tool === "authorize_connector" &&
186
+ (yield* upwardStep(authInvoke(self, "browserCompleted", [previous]))) &&
187
+ (yield* upwardStep(authInvoke(self, "connected", [previous.connector, context])))) {
188
+ const structuredContent = { connector: previous.connector, status: "ok" };
189
+ return { structuredContent, content: [{ type: "text", text: JSON.stringify(structuredContent) }] };
171
190
  }
172
191
  const envelope = context.mcpReq.envelope;
173
192
  const capabilities = envelope?.[CLIENT_CAPABILITIES_META_KEY];
@@ -216,16 +235,8 @@ export class AuthElicitation {
216
235
  content: [{ type: "text", text: JSON.stringify(structuredContent) }],
217
236
  };
218
237
  }
219
- const { registry, vault, principal, publicUrl, canManage, unavailable } = self.options;
220
- const connector = typeof id === "string" ? registry.getConnector(id) : undefined;
221
- const recoverable = connector?.startAuth || (connector?.credential && self.options.credentialUi);
222
- if (typeof id !== "string" ||
223
- !/^[a-z0-9_-]+$/.test(id) ||
224
- !recoverable ||
225
- !principal ||
226
- !publicUrl ||
227
- unavailable ||
228
- !canManage(id))
238
+ const { vault, principal } = self.options;
239
+ if (!self.mayConnect(id) || !principal)
229
240
  return result;
230
241
  // Incapable hosts already received the shared explicit-authorization handoff.
231
242
  if (!capable)
@@ -237,9 +248,9 @@ export class AuthElicitation {
237
248
  ...result.structuredContent,
238
249
  error: {
239
250
  ...result.structuredContent?.error,
240
- authorizationUrl: error.authorizationUrl ??
251
+ ...connectLinkFields(error, id, error.authorizationUrl ??
241
252
  (yield* upwardStep(authInvoke(self.options, "connectLink", [id])))
242
- .url,
253
+ .url),
243
254
  },
244
255
  };
245
256
  return {
@@ -250,13 +261,13 @@ export class AuthElicitation {
250
261
  }
251
262
  if (previous && previous.round >= MAX_ROUNDS)
252
263
  return failure("auth_round_limit", "Connection is still required after three prompts. Connect explicitly, then start a new request.");
253
- // Once a verified browser has started this attempt, retries continue it.
254
- // A pending consent must never be reset by another force=true prompt.
264
+ // Accept is consent to open the link, not proof it opened, so every round's
265
+ // link stays live and they share one reset (claimOAuthConnectStart).
255
266
  const forced = explicit && args.force === true;
256
267
  const link = yield* upwardStep(authInvoke(self.options, "connectLink", [
257
268
  id,
258
- forced && !browser?.predecessor,
259
- forced ? browser?.predecessor : undefined,
269
+ forced,
270
+ forced ? previous?.browserNonces[0] : undefined,
260
271
  ]));
261
272
  const state = {
262
273
  version: 1,
@@ -285,7 +296,7 @@ export class AuthElicitation {
285
296
  ...result.structuredContent,
286
297
  error: {
287
298
  ...result.structuredContent?.error,
288
- authorizationUrl: link.url,
299
+ ...connectLinkFields(result.structuredContent?.error, id, link.url),
289
300
  },
290
301
  };
291
302
  return {
@@ -305,6 +316,59 @@ export class AuthElicitation {
305
316
  });
306
317
  }), this.options.requestSignal).pipe(Effect.provideService(RequestScope, requestOwner(this.options.requestScope)));
307
318
  }, AuthElicitation.prototype.run);
319
+ registerAuthMethod(AuthElicitation.prototype, "refused", function (action, state, force) {
320
+ const self = this;
321
+ return authAwait(authFlow(function* () {
322
+ const { code, message } = REFUSED[action];
323
+ const { connector: id, tool } = state;
324
+ if (!self.mayConnect(id))
325
+ return failure(code, message);
326
+ const { registry, publicUrl, canManage, unavailable, credentialHandoffUrl, routes, requestScope } = self.options;
327
+ try {
328
+ // The signed state bound these arguments, so this is the restart the
329
+ // caller asked for, sharing the prompt's reset if that prompt was opened.
330
+ const forced = tool === "authorize_connector" && force === true;
331
+ // The ordinary handoff, except that the link is the sealed form this
332
+ // client was already shown: the agent sees no principal in it.
333
+ const handoff = yield* upwardStep(authCall(authorizationHandoff, [
334
+ registry,
335
+ publicUrl,
336
+ requestScope,
337
+ {
338
+ canManageAuth: canManage,
339
+ credentialHandoffUrl,
340
+ oauthConnectUnavailable: unavailable,
341
+ routes,
342
+ oauthConnectUrl: authMethod(() => authInvoke(self.options, "connectLink", [
343
+ id,
344
+ forced,
345
+ forced ? state.browserNonces[0] : undefined,
346
+ ]).pipe(Effect.map((link) => link.url))),
347
+ },
348
+ { connector: id },
349
+ ]));
350
+ const url = handoff.authorizationUrl ?? handoff.operatorUrl;
351
+ if (!url)
352
+ return failure(code, message);
353
+ if (handoff.authorizationUrl)
354
+ sentSecretsForRequest(requestScope).mintedState(handoff.authorizationUrl);
355
+ return toolError({
356
+ ...handoff,
357
+ code,
358
+ message,
359
+ retryable: false,
360
+ nextAction: {
361
+ url,
362
+ purpose: refusedPurpose(handoff.authorizationUrl ? "" : "for the operator to open, "),
363
+ },
364
+ }, { ok: false });
365
+ }
366
+ catch {
367
+ // A signing or storage failure cannot replace the refusal itself.
368
+ return failure(code, message + REFUSED_NO_LINK);
369
+ }
370
+ }), this.options.requestSignal).pipe(Effect.provideService(RequestScope, requestOwner(this.options.requestScope)));
371
+ }, AuthElicitation.prototype.refused);
308
372
  registerAuthMethod(AuthElicitation.prototype, "connected", function (id, context) {
309
373
  const self = this;
310
374
  return authAwait(authFlow(function* () {
@@ -331,29 +395,16 @@ export class AuthElicitation {
331
395
  }
332
396
  }), this.options.requestSignal).pipe(Effect.provideService(RequestScope, requestOwner(this.options.requestScope)));
333
397
  }, AuthElicitation.prototype.connected);
334
- registerAuthMethod(AuthElicitation.prototype, "browserProgress", function (state) {
398
+ registerAuthMethod(AuthElicitation.prototype, "browserCompleted", function (state) {
335
399
  const self = this;
336
400
  return authAwait(authFlow(function* () {
337
- let started;
338
- let claimed;
339
- let completed = false;
340
- let pending = false;
341
- for (const nonce of state.browserNonces) {
342
- const progress = yield* upwardStep(authInvoke(self.options, "linkProgress", [
343
- state.connector,
344
- nonce,
345
- ]));
346
- if (progress === "started")
347
- started = nonce;
348
- if (progress === "claimed")
349
- claimed = nonce;
350
- completed ||= progress === "started";
351
- pending ||= progress === "claimed";
352
- }
353
- const predecessor = started ?? claimed;
354
- return { ...(predecessor ? { predecessor } : {}), completed: completed && !pending };
401
+ // A reset never leaves "started", so no reset can be running behind this link.
402
+ for (const nonce of state.browserNonces)
403
+ if (yield* upwardStep(authInvoke(self.options, "linkStarted", [state.connector, nonce])))
404
+ return true;
405
+ return false;
355
406
  }), this.options.requestSignal).pipe(Effect.provideService(RequestScope, requestOwner(this.options.requestScope)));
356
- }, AuthElicitation.prototype.browserProgress);
407
+ }, AuthElicitation.prototype.browserCompleted);
357
408
  registerAuthMethod(AuthElicitation.prototype, "verify", function (wire, context) {
358
409
  const self = this;
359
410
  return authAwait(authFlow(function* () {
@@ -380,7 +431,13 @@ export class AuthElicitation {
380
431
  !Number.isInteger(state.round) ||
381
432
  state.round < 1 ||
382
433
  state.round > MAX_ROUNDS ||
383
- !["call_tool", "call_destructive_tool", "execute_code", "authorize_connector"].includes(String(state.tool)) ||
434
+ ![
435
+ "call_tool",
436
+ "call_destructive_tool",
437
+ "execute_code",
438
+ "execute_read_code",
439
+ "authorize_connector",
440
+ ].includes(String(state.tool)) ||
384
441
  typeof state.wireTool !== "string" ||
385
442
  typeof state.digest !== "string" ||
386
443
  !/^[a-f0-9]{64}$/.test(state.digest) ||
@@ -1,4 +1,4 @@
1
- import { routePhrases } from "./agent-routes.js";
1
+ import { handoffInHand, routePhrases } from "./agent-routes.js";
2
2
  import { upwardStep } from "./upward.js";
3
3
  import { authCall, authInvoke, authFlow, registerAuth } from "./auth/native.js";
4
4
  import { Context, Deferred, Effect, Scheduler } from "effect";
@@ -15,6 +15,38 @@ function outcomeAware(build) {
15
15
  afterUnknownWrite.set(handoff, build(true));
16
16
  return handoff;
17
17
  }
18
+ /**
19
+ * What to do with a handoff the error already carries. Passing it on is the
20
+ * whole step: calling authorize_connector would only return the same thing.
21
+ * After a write of unknown outcome the follow-up reconciles, never retries.
22
+ */
23
+ function handoffAction(handoff, unknownWrite) {
24
+ const then = unknownWrite
25
+ ? "After they confirm, re-read the write's target before any fresh call."
26
+ : "Retry after they confirm.";
27
+ const url = handoff.authorizationUrl ?? handoff.operatorUrl;
28
+ if (!url)
29
+ return {
30
+ action: "contact_operator",
31
+ purpose: `Tell the user the operator must fix this connector, then stop. ${then}`,
32
+ };
33
+ return {
34
+ url,
35
+ purpose: `Give this link to the user, unchanged, ${handoff.authorizationUrl ? "" : "for the operator to open, "}then stop. ${then}`,
36
+ };
37
+ }
38
+ const unknownWriteError = (error) => error.writeOutcome === "unknown" || error.reconciliationRequired === true;
39
+ /**
40
+ * The fields for an auth error given only a connect link, where a capable host's
41
+ * prompt could not carry it: the same direct step as a full handoff.
42
+ */
43
+ export function connectLinkFields(error, connector, authorizationUrl) {
44
+ return {
45
+ ...(typeof error.message === "string" ? { message: handoffInHand(error.message, connector) } : {}),
46
+ authorizationUrl,
47
+ nextAction: handoffAction({ connector, recovery: "oauth", authorizationUrl }, unknownWriteError(error)),
48
+ };
49
+ }
18
50
  function oauthFollowUp(connectorId, unknownWrite, opts) {
19
51
  const then = `Then ${unknownWrite ? RECONCILE : "retry the original call"}`;
20
52
  // Without programs, no connecta.search can confirm the catalog.
@@ -95,7 +127,7 @@ const authorizationHandoffNative = /* @__PURE__ */ registerAuth(authorizationHan
95
127
  recovery: "oauth",
96
128
  status: "auth_required",
97
129
  authorizationUrl,
98
- instructions: "Copy authorizationUrl exactly as returned; do not decode or re-encode it. Open it in a browser and sign in as the user who requested this connection. Connecta verifies your identity and permission before starting consent. " +
130
+ instructions: "For the person opening this link: sign in as the user who requested this connection; Connecta verifies identity and permission before starting consent. For the agent: pass authorizationUrl on exactly as returned. " +
99
131
  oauthFollowUp(connector.id, unknownWrite, opts),
100
132
  }));
101
133
  });
@@ -180,7 +212,8 @@ export function withAuthorizationHandoffEffect(scope, error) {
180
212
  return {
181
213
  ...error,
182
214
  ...value,
183
- message: error.message,
215
+ message: handoffInHand(error.message, connector),
216
+ nextAction: handoffAction(value, unknownWriteError(error)),
184
217
  ...(value.message ? { instructions: value.message } : {}),
185
218
  };
186
219
  }),