@zackbart/connecta 0.21.1 → 0.22.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 (66) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/README.md +7 -0
  3. package/dist/access-tokens.d.ts +2 -2
  4. package/dist/access-tokens.js +14 -2
  5. package/dist/auth/downstream-oauth.d.ts +65 -2
  6. package/dist/auth/downstream-oauth.js +408 -20
  7. package/dist/connectors/api.d.ts +2 -0
  8. package/dist/connectors/api.js +1 -0
  9. package/dist/connectors/remote-mcp.d.ts +2 -0
  10. package/dist/connectors/remote-mcp.js +14 -4
  11. package/dist/credentials.d.ts +6 -6
  12. package/dist/credentials.js +25 -21
  13. package/dist/executors/quickjs.js +4 -0
  14. package/dist/identity.d.ts +4 -0
  15. package/dist/identity.js +17 -0
  16. package/dist/index.d.ts +16 -2
  17. package/dist/index.js +6 -1
  18. package/dist/meta-tools.js +7 -2
  19. package/dist/operator-ui/generated.js +1 -1
  20. package/dist/operator-ui/model.d.ts +4 -2
  21. package/dist/operator-ui/view.js +1 -1
  22. package/dist/providers/cloudflare.d.ts +2 -0
  23. package/dist/providers/cloudflare.js +1 -0
  24. package/dist/providers/linear.d.ts +2 -0
  25. package/dist/providers/linear.js +1 -0
  26. package/dist/providers/mixpanel.d.ts +2 -0
  27. package/dist/providers/mixpanel.js +1 -0
  28. package/dist/providers/notion.d.ts +2 -0
  29. package/dist/providers/notion.js +1 -0
  30. package/dist/providers/revenuecat.d.ts +2 -0
  31. package/dist/providers/revenuecat.js +1 -0
  32. package/dist/providers/stripe.d.ts +2 -0
  33. package/dist/providers/stripe.js +1 -0
  34. package/dist/registry.d.ts +25 -0
  35. package/dist/registry.js +200 -4
  36. package/dist/routes/access-tokens.js +2 -2
  37. package/dist/routes/activity.js +4 -1
  38. package/dist/routes/credentials.js +31 -12
  39. package/dist/routes/mcp.js +17 -2
  40. package/dist/routes/oauth.js +55 -11
  41. package/dist/routes/shared.d.ts +20 -4
  42. package/dist/routes/shared.js +92 -24
  43. package/dist/routes/ui.js +32 -13
  44. package/dist/types.d.ts +28 -2
  45. package/dist/ui.d.ts +3 -3
  46. package/dist/ui.js +18 -5
  47. package/dist/version.d.ts +1 -1
  48. package/dist/version.js +1 -1
  49. package/documentation/architecture.md +31 -8
  50. package/documentation/auth.md +90 -10
  51. package/documentation/code-mode.md +4 -4
  52. package/documentation/connectors.md +13 -0
  53. package/documentation/meta-tools.md +4 -3
  54. package/documentation/operations.md +5 -3
  55. package/documentation/operator-ui.md +13 -4
  56. package/documentation/request-admission.md +2 -1
  57. package/documentation/storage-and-credentials.md +77 -4
  58. package/documentation/upgrading.md +38 -7
  59. package/ethos.md +8 -8
  60. package/examples/worker/AGENTS.md +44 -0
  61. package/examples/worker/README.md +63 -14
  62. package/examples/worker/src/index.ts +26 -22
  63. package/package.json +1 -1
  64. package/templates/node/README.md +7 -0
  65. package/templates/node/package.json +1 -1
  66. package/templates/node/src/index.ts +13 -4
@@ -26,6 +26,312 @@ const OAUTH_VALUE_KEYS = [
26
26
  "oauth:state",
27
27
  ];
28
28
  const MAX_CLEANUP_BACKLOG = 1_000;
29
+ function isRefreshTokenRequest(init) {
30
+ if ((init?.method ?? "GET").toUpperCase() !== "POST")
31
+ return false;
32
+ const body = init?.body;
33
+ if (body instanceof URLSearchParams) {
34
+ return body.get("grant_type") === "refresh_token";
35
+ }
36
+ if (typeof body !== "string")
37
+ return false;
38
+ return new URLSearchParams(body).get("grant_type") === "refresh_token";
39
+ }
40
+ function sdkAcceptsOAuthTokens(value) {
41
+ if (!value || typeof value !== "object" || Array.isArray(value))
42
+ return false;
43
+ const candidate = value;
44
+ if (typeof candidate.access_token !== "string" ||
45
+ typeof candidate.token_type !== "string") {
46
+ return false;
47
+ }
48
+ for (const key of ["id_token", "scope", "refresh_token"]) {
49
+ if (candidate[key] !== undefined && typeof candidate[key] !== "string") {
50
+ return false;
51
+ }
52
+ }
53
+ if (candidate.expires_in !== undefined) {
54
+ try {
55
+ if (!Number.isFinite(Number(candidate.expires_in)))
56
+ return false;
57
+ }
58
+ catch {
59
+ return false;
60
+ }
61
+ }
62
+ return true;
63
+ }
64
+ async function refreshResponseFailure(response) {
65
+ if (!response.ok) {
66
+ return new Error(`OAuth refresh failed with HTTP ${response.status}.`);
67
+ }
68
+ try {
69
+ return sdkAcceptsOAuthTokens(await response.clone().json())
70
+ ? undefined
71
+ : new Error("OAuth refresh response did not match the token schema.");
72
+ }
73
+ catch {
74
+ return new Error("OAuth refresh response did not contain JSON tokens.");
75
+ }
76
+ }
77
+ function refreshMutationPendingResponse() {
78
+ return Response.json({
79
+ error: "temporarily_unavailable",
80
+ error_description: "OAuth refresh is temporarily unavailable while previous credentials commit.",
81
+ }, { status: 503 });
82
+ }
83
+ function aborted(signal) {
84
+ return (signal.reason ?? new DOMException("This operation was aborted", "AbortError"));
85
+ }
86
+ async function waitForRefreshFlight(flight, signal) {
87
+ let outcome;
88
+ if (!signal) {
89
+ outcome = await flight.done;
90
+ }
91
+ else {
92
+ outcome = await new Promise((resolve, reject) => {
93
+ let settled = false;
94
+ const finish = (settle) => {
95
+ if (settled)
96
+ return;
97
+ settled = true;
98
+ signal.removeEventListener("abort", onAbort);
99
+ settle();
100
+ };
101
+ const onAbort = () => finish(() => reject(aborted(signal)));
102
+ signal.addEventListener("abort", onAbort, { once: true });
103
+ void flight.done.then((result) => finish(() => resolve(result)));
104
+ // Abort may land between the caller's check and listener registration.
105
+ if (signal.aborted)
106
+ onAbort();
107
+ });
108
+ }
109
+ if (outcome.status === "failed")
110
+ throw outcome.error;
111
+ return outcome;
112
+ }
113
+ /**
114
+ * Share one rotating-token redemption within one connector runtime and OAuth
115
+ * generation. The first request still owns the real fetch and response. Its
116
+ * abort signal has one bounded listener until the exact flight settles, so a
117
+ * cancellation after the response cannot strand waiters during token storage.
118
+ * Followers wait for that provider to save tokens, then re-read storage. The
119
+ * map never retains a token response or transport.
120
+ *
121
+ * This is intentionally runtime-local. KVStorage has no atomic coordination
122
+ * operation, so a second isolate can still race the same refresh token.
123
+ */
124
+ export class OAuthRefreshCoordinator {
125
+ flights = new Map();
126
+ /** Opaque identities only: no request promise, signal, callback, or response. */
127
+ pendingMutations = new Map();
128
+ /** One bounded latest-success slot, containing only generation + identity. */
129
+ successfulRefresh;
130
+ /** Replaced on every map mutation, closing flight/pending ABA across awaits. */
131
+ stateRevision = {};
132
+ advanceStateRevision() {
133
+ this.stateRevision = {};
134
+ }
135
+ observeAuthoritativeGeneration(generation) {
136
+ const staleGenerations = new Set([
137
+ ...this.flights.keys(),
138
+ ...this.pendingMutations.keys(),
139
+ ]);
140
+ for (const stale of staleGenerations) {
141
+ if (stale !== generation)
142
+ this.retire(stale);
143
+ }
144
+ if (this.successfulRefresh &&
145
+ this.successfulRefresh.generation !== generation) {
146
+ this.successfulRefresh = undefined;
147
+ this.advanceStateRevision();
148
+ }
149
+ }
150
+ settle(generation, flight, outcome) {
151
+ if (this.flights.get(generation) !== flight)
152
+ return;
153
+ this.flights.delete(generation);
154
+ this.advanceStateRevision();
155
+ flight.stopObservingOwnerAbort();
156
+ flight.release(outcome);
157
+ }
158
+ markMutationPending(generation, flight) {
159
+ if (this.flights.get(generation) !== flight)
160
+ return false;
161
+ this.pendingMutations.set(generation, flight.mutationId);
162
+ this.advanceStateRevision();
163
+ return true;
164
+ }
165
+ finishMutation(generation, flight) {
166
+ if (this.pendingMutations.get(generation) === flight.mutationId) {
167
+ this.pendingMutations.delete(generation);
168
+ this.advanceStateRevision();
169
+ return true;
170
+ }
171
+ return false;
172
+ }
173
+ /** @internal Opaque basis for issuer-aware provider token reads. */
174
+ successfulRefreshIdentity(generation) {
175
+ return this.successfulRefresh?.generation === generation
176
+ ? this.successfulRefresh.identity
177
+ : undefined;
178
+ }
179
+ coordinatedFetch(provider, baseFetch, requestSignal) {
180
+ return async (input, init) => {
181
+ // Await passthrough failures here so workerd associates the rejection
182
+ // with the fetch the SDK is already awaiting, rather than reporting the
183
+ // adopted inner promise as an unhandled rejection.
184
+ if (!isRefreshTokenRequest(init))
185
+ return await baseFetch(input, init);
186
+ const generation = await provider.flowGeneration();
187
+ const requestedRefreshToken = init?.body instanceof URLSearchParams
188
+ ? init.body.get("refresh_token")
189
+ : new URLSearchParams(String(init?.body ?? "")).get("refresh_token");
190
+ while (true) {
191
+ const revisionBeforeReads = this.stateRevision;
192
+ const activeGeneration = await provider.generation();
193
+ this.observeAuthoritativeGeneration(activeGeneration);
194
+ if (activeGeneration !== generation)
195
+ this.retire(generation);
196
+ const activeFlight = this.flights.get(generation);
197
+ const pendingMutation = this.pendingMutations.get(generation);
198
+ if (activeGeneration === generation &&
199
+ pendingMutation &&
200
+ activeFlight?.mutationId !== pendingMutation) {
201
+ return refreshMutationPendingResponse();
202
+ }
203
+ let currentTokens = activeGeneration === generation ? await provider.tokens() : undefined;
204
+ const latestGeneration = await provider.generation();
205
+ this.observeAuthoritativeGeneration(latestGeneration);
206
+ if (latestGeneration !== generation) {
207
+ this.retire(generation);
208
+ currentTokens = undefined;
209
+ }
210
+ if (this.stateRevision !== revisionBeforeReads)
211
+ continue;
212
+ // Re-check both identities after every storage await. An owner can
213
+ // abort while this caller reads tokens, leaving only the mutation
214
+ // marker; a reset can likewise retire this caller's captured epoch.
215
+ const existing = this.flights.get(generation);
216
+ const latestPendingMutation = this.pendingMutations.get(generation);
217
+ if (latestGeneration === generation &&
218
+ latestPendingMutation &&
219
+ existing?.mutationId !== latestPendingMutation) {
220
+ return refreshMutationPendingResponse();
221
+ }
222
+ // This flow may have read a token just before another flow saved its
223
+ // rotation. Replaying the retired token would recreate the race after
224
+ // the first network response. Give the SDK the already-saved rotating
225
+ // credential instead. A tokenless current value cannot do that: the
226
+ // SDK would merge the requested old refresh token back into it.
227
+ if (currentTokens?.refresh_token &&
228
+ (currentTokens.refresh_token !== requestedRefreshToken ||
229
+ provider.refreshBasisChanged(currentTokens, generation))) {
230
+ return Response.json(currentTokens);
231
+ }
232
+ if (currentTokens?.refresh_token !== requestedRefreshToken) {
233
+ return Response.json({
234
+ error: "invalid_grant",
235
+ error_description: "Refresh token is no longer active.",
236
+ }, { status: 400 });
237
+ }
238
+ if (existing) {
239
+ const outcome = await waitForRefreshFlight(existing, requestSignal);
240
+ if (outcome.status === "refreshed") {
241
+ const activeGeneration = await provider.generation();
242
+ const refreshedTokens = activeGeneration === generation
243
+ ? await provider.tokens()
244
+ : undefined;
245
+ if (refreshedTokens?.refresh_token) {
246
+ return Response.json(refreshedTokens);
247
+ }
248
+ }
249
+ continue;
250
+ }
251
+ let release;
252
+ const flight = {
253
+ done: new Promise((resolve) => {
254
+ release = resolve;
255
+ }),
256
+ release: (outcome) => release(outcome),
257
+ stopObservingOwnerAbort: () => { },
258
+ mutationId: {},
259
+ };
260
+ this.flights.set(generation, flight);
261
+ this.advanceStateRevision();
262
+ provider.captureRefreshFlight(generation, flight);
263
+ if (requestSignal) {
264
+ let observing = true;
265
+ const onOwnerAbort = () => {
266
+ this.fail(generation, flight, aborted(requestSignal));
267
+ };
268
+ flight.stopObservingOwnerAbort = () => {
269
+ if (!observing)
270
+ return;
271
+ observing = false;
272
+ requestSignal.removeEventListener("abort", onOwnerAbort);
273
+ };
274
+ requestSignal.addEventListener("abort", onOwnerAbort, { once: true });
275
+ // Abort may land between flight publication and listener registration.
276
+ if (requestSignal.aborted)
277
+ onOwnerAbort();
278
+ }
279
+ try {
280
+ const response = await baseFetch(input, requestSignal ? { ...init, signal: requestSignal } : init);
281
+ // These responses never reach a successful saveTokens callback. Give
282
+ // current waiters a bounded failure now while leaving the owner's
283
+ // response untouched for the SDK to parse and classify itself.
284
+ const failure = await refreshResponseFailure(response);
285
+ if (failure) {
286
+ this.fail(generation, flight, failure);
287
+ }
288
+ else if (requestSignal?.aborted ||
289
+ !this.markMutationPending(generation, flight)) {
290
+ throw requestSignal?.aborted
291
+ ? aborted(requestSignal)
292
+ : new Error("OAuth refresh ended before tokens could be saved.");
293
+ }
294
+ return response;
295
+ }
296
+ catch (error) {
297
+ this.fail(generation, flight, error);
298
+ throw error;
299
+ }
300
+ }
301
+ };
302
+ }
303
+ /** Publish one exact owner's successful save without disturbing a newer try. */
304
+ succeedMutation(generation, flight) {
305
+ if (this.finishMutation(generation, flight)) {
306
+ this.successfulRefresh = { generation, identity: {} };
307
+ this.advanceStateRevision();
308
+ }
309
+ this.settle(generation, flight, { status: "refreshed" });
310
+ }
311
+ /** Give joined callers a fetch/flow failure, without rejecting the gate. */
312
+ fail(generation, flight, error) {
313
+ this.settle(generation, flight, { status: "failed", error });
314
+ }
315
+ /** Finish an exact failed credential write, then publish its failure. */
316
+ failMutation(generation, flight, error) {
317
+ this.finishMutation(generation, flight);
318
+ this.settle(generation, flight, { status: "failed", error });
319
+ }
320
+ /** Force reauthorization fences and wakes every waiter on the retired epoch. */
321
+ retire(generation) {
322
+ if (this.pendingMutations.delete(generation)) {
323
+ this.advanceStateRevision();
324
+ }
325
+ if (this.successfulRefresh?.generation === generation) {
326
+ this.successfulRefresh = undefined;
327
+ this.advanceStateRevision();
328
+ }
329
+ const flight = this.flights.get(generation);
330
+ if (!flight)
331
+ return;
332
+ this.settle(generation, flight, { status: "retired" });
333
+ }
334
+ }
29
335
  function storedOAuthValue(value) {
30
336
  if (!value || typeof value !== "object")
31
337
  return false;
@@ -75,16 +381,21 @@ export class KvOAuthProvider {
75
381
  connectorId;
76
382
  storage;
77
383
  redirectUri;
384
+ refreshCoordinator;
78
385
  /**
79
386
  * The reset generation this provider's flow started under. Every OAuth value
80
387
  * it writes carries this epoch, so a late write can land after a reset without
81
388
  * becoming readable under the new generation.
82
389
  */
83
390
  capturedGeneration = null;
84
- constructor(connectorId, storage, redirectUri) {
391
+ refreshFlight;
392
+ /** Tokens this request's issuer-aware auth flow decided to refresh. */
393
+ refreshBasis;
394
+ constructor(connectorId, storage, redirectUri, refreshCoordinator) {
85
395
  this.connectorId = connectorId;
86
396
  this.storage = storage;
87
397
  this.redirectUri = redirectUri;
398
+ this.refreshCoordinator = refreshCoordinator;
88
399
  }
89
400
  /**
90
401
  * Stamp the force-reauth generation the current connect flow started under.
@@ -94,6 +405,43 @@ export class KvOAuthProvider {
94
405
  captureGeneration(gen) {
95
406
  this.capturedGeneration = gen;
96
407
  }
408
+ /** The generation captured for this flow, before a concurrent reset. */
409
+ async flowGeneration() {
410
+ return this.capturedGeneration ?? this.generation();
411
+ }
412
+ /** @internal Record the refresh attempt this provider owns. */
413
+ captureRefreshFlight(generation, flight) {
414
+ this.refreshFlight = { generation, flight };
415
+ }
416
+ succeedRefreshFlight() {
417
+ const owned = this.refreshFlight;
418
+ this.refreshFlight = undefined;
419
+ if (owned) {
420
+ this.refreshCoordinator?.succeedMutation(owned.generation, owned.flight);
421
+ }
422
+ }
423
+ failRefreshFlight(error, mutationFinished = false) {
424
+ const owned = this.refreshFlight;
425
+ this.refreshFlight = undefined;
426
+ if (owned) {
427
+ if (mutationFinished) {
428
+ this.refreshCoordinator?.failMutation(owned.generation, owned.flight, error);
429
+ }
430
+ else {
431
+ this.refreshCoordinator?.fail(owned.generation, owned.flight, error);
432
+ }
433
+ }
434
+ }
435
+ /** True when another request saved a refresh result after this flow's read. */
436
+ refreshBasisChanged(current, generation) {
437
+ const basis = this.refreshBasis;
438
+ return Boolean(basis &&
439
+ basis.generation === generation &&
440
+ (basis.accessToken !== current.access_token ||
441
+ basis.refreshToken !== current.refresh_token ||
442
+ basis.successIdentity !==
443
+ this.refreshCoordinator?.successfulRefreshIdentity(generation)));
444
+ }
97
445
  /**
98
446
  * The epoch this provider writes under. Direct unit/custom use lazily captures
99
447
  * the current generation; connector-driven connect and callback paths stamp it
@@ -274,10 +622,32 @@ export class KvOAuthProvider {
274
622
  await this.writeValue("oauth:client", info, (value) => JSON.stringify(value), ctx?.issuer);
275
623
  }
276
624
  async tokens(ctx) {
277
- return this.readIssuerBoundValue("oauth:tokens", (raw) => JSON.parse(raw), (value) => JSON.stringify(value), ctx);
625
+ const refreshGeneration = ctx ? await this.flowGeneration() : undefined;
626
+ const successIdentity = refreshGeneration !== undefined
627
+ ? this.refreshCoordinator?.successfulRefreshIdentity(refreshGeneration)
628
+ : undefined;
629
+ const tokens = await this.readIssuerBoundValue("oauth:tokens", (raw) => JSON.parse(raw), (value) => JSON.stringify(value), ctx);
630
+ if (ctx && tokens && refreshGeneration !== undefined) {
631
+ this.refreshBasis = {
632
+ accessToken: tokens.access_token,
633
+ generation: refreshGeneration,
634
+ ...(tokens.refresh_token !== undefined
635
+ ? { refreshToken: tokens.refresh_token }
636
+ : {}),
637
+ ...(successIdentity !== undefined ? { successIdentity } : {}),
638
+ };
639
+ }
640
+ return tokens;
278
641
  }
279
642
  async saveTokens(tokens, ctx) {
280
- await this.writeValue("oauth:tokens", tokens, (value) => JSON.stringify(value), ctx?.issuer);
643
+ try {
644
+ await this.writeValue("oauth:tokens", tokens, (value) => JSON.stringify(value), ctx?.issuer);
645
+ this.succeedRefreshFlight();
646
+ }
647
+ catch (error) {
648
+ this.failRefreshFlight(error, true);
649
+ throw error;
650
+ }
281
651
  }
282
652
  /**
283
653
  * OAuth `state`. The SDK calls this (when present) and appends the value to
@@ -317,7 +687,14 @@ export class KvOAuthProvider {
317
687
  return stored.value;
318
688
  }
319
689
  async redirectToAuthorization(authorizationUrl) {
320
- await this.writeValue("oauth:pending", authorizationUrl.toString(), (raw) => raw);
690
+ try {
691
+ await this.writeValue("oauth:pending", authorizationUrl.toString(), (raw) => raw);
692
+ this.failRefreshFlight(new Error("OAuth refresh required reauthorization before tokens were saved."));
693
+ }
694
+ catch (error) {
695
+ this.failRefreshFlight(error);
696
+ throw error;
697
+ }
321
698
  }
322
699
  /** The stored authorization URL, if a flow is pending. */
323
700
  async pendingAuthorizationUrl() {
@@ -385,6 +762,7 @@ export class KvOAuthProvider {
385
762
  // reset's epoch after their cleanup finishes out of order.
386
763
  try {
387
764
  await this.storage.set("oauth:generation", active);
765
+ this.refreshCoordinator?.retire(previous);
388
766
  }
389
767
  catch (error) {
390
768
  try {
@@ -414,23 +792,33 @@ export class KvOAuthProvider {
414
792
  throw firstError;
415
793
  }
416
794
  async invalidateCredentials(scope) {
417
- const generation = await this.writeGeneration();
418
- if (scope === "all") {
419
- await this.deleteAll([
420
- oauthValueStorageKey("oauth:client", generation),
421
- oauthValueStorageKey("oauth:tokens", generation),
422
- oauthValueStorageKey("oauth:verifier", generation),
423
- ]);
424
- return;
425
- }
426
- if (scope === "client") {
427
- await this.storage.delete(oauthValueStorageKey("oauth:client", generation));
428
- }
429
- if (scope === "tokens") {
430
- await this.storage.delete(oauthValueStorageKey("oauth:tokens", generation));
795
+ const endsRefresh = scope === "all" || scope === "tokens";
796
+ try {
797
+ const generation = await this.writeGeneration();
798
+ if (scope === "all") {
799
+ await this.deleteAll([
800
+ oauthValueStorageKey("oauth:client", generation),
801
+ oauthValueStorageKey("oauth:tokens", generation),
802
+ oauthValueStorageKey("oauth:verifier", generation),
803
+ ]);
804
+ }
805
+ else if (scope === "client") {
806
+ await this.storage.delete(oauthValueStorageKey("oauth:client", generation));
807
+ }
808
+ else if (scope === "tokens") {
809
+ await this.storage.delete(oauthValueStorageKey("oauth:tokens", generation));
810
+ }
811
+ else if (scope === "verifier") {
812
+ await this.storage.delete(oauthValueStorageKey("oauth:verifier", generation));
813
+ }
814
+ if (endsRefresh) {
815
+ this.failRefreshFlight(new Error("OAuth refresh invalidated credentials before tokens were saved."));
816
+ }
431
817
  }
432
- if (scope === "verifier") {
433
- await this.storage.delete(oauthValueStorageKey("oauth:verifier", generation));
818
+ catch (error) {
819
+ if (endsRefresh)
820
+ this.failRefreshFlight(error);
821
+ throw error;
434
822
  }
435
823
  }
436
824
  }
@@ -34,6 +34,8 @@ export interface ApiOptions {
34
34
  /** Human-readable display name; the connector id remains the address prefix. */
35
35
  title?: string;
36
36
  description?: string;
37
+ /** Downstream auth ownership. Defaults to one shared deployment grant. */
38
+ authScope?: "shared" | "personal";
37
39
  /**
38
40
  * Max inline result size (bytes) for this connector's tools before
39
41
  * call_tool truncates and stashes the full text for get_result
@@ -40,6 +40,7 @@ export function api(id, opts) {
40
40
  kind: "api",
41
41
  ...defined({
42
42
  description: opts.description,
43
+ authScope: opts.authScope,
43
44
  maxResultBytes: opts.maxResultBytes,
44
45
  callAdmission: opts.callAdmission,
45
46
  usageGuide: opts.usageGuide,
@@ -59,6 +59,8 @@ export interface RemoteMcpOptions {
59
59
  /** Human-readable display name; the connector id remains the address prefix. */
60
60
  title?: string;
61
61
  description?: string;
62
+ /** Downstream auth ownership. Defaults to one shared deployment grant. */
63
+ authScope?: "shared" | "personal";
62
64
  /**
63
65
  * Max inline result size (bytes) for this connector's tools before
64
66
  * call_tool truncates and stashes the full text for get_result
@@ -1,5 +1,5 @@
1
1
  import { Client, isInputRequiredResult, specTypeSchemas, StreamableHTTPClientTransport, UnauthorizedError, } from "@modelcontextprotocol/client";
2
- import { KvOAuthProvider } from "../auth/downstream-oauth.js";
2
+ import { KvOAuthProvider, OAuthRefreshCoordinator, } from "../auth/downstream-oauth.js";
3
3
  import { MAX_CATALOG_TOOLS } from "../catalog-limits.js";
4
4
  import { ConnectorCallError, msg } from "../errors.js";
5
5
  import { CONNECTA_VERSION } from "../version.js";
@@ -320,6 +320,11 @@ export function redirectSafeFetch(connectorId, policy = "none", baseFetch = fetc
320
320
  * server or hide other connectors).
321
321
  */
322
322
  export function remoteMcp(id, opts) {
323
+ if (opts.authScope === "personal" && opts.auth?.type === "headers") {
324
+ throw new Error(`[connecta] connector "${id}" cannot combine authScope "personal" ` +
325
+ "with static headers. Use credential or OAuth auth so each principal " +
326
+ "can own a different grant.");
327
+ }
323
328
  // Weak keys ensure a completed request does not leave its SDK client,
324
329
  // transport, response bodies, AbortSignals, or connection promise reachable
325
330
  // from the isolate singleton. Those are request-bound in Cloudflare Workers.
@@ -328,6 +333,9 @@ export function remoteMcp(id, opts) {
328
333
  // must not recreate an ownerless connection under the ended scope.
329
334
  const closedScopes = new WeakSet();
330
335
  const isOauth = opts.auth?.type === "oauth";
336
+ // Long-lived enough for distinct request scopes in this connector runtime to
337
+ // join one token redemption. It owns no client, transport, or request state.
338
+ const refreshCoordinator = new OAuthRefreshCoordinator();
331
339
  const logger = opts.logger ?? console;
332
340
  const credentialAuth = opts.auth?.type === "credential" ? opts.auth : undefined;
333
341
  if (credentialAuth?.credential?.fields?.length) {
@@ -497,7 +505,7 @@ export function remoteMcp(id, opts) {
497
505
  const newProvider = (ctx, state) => {
498
506
  if (state?.provider)
499
507
  return state.provider;
500
- const provider = new KvOAuthProvider(id, ctx.storage, `${ctx.baseUrl}/oauth/callback/${id}`);
508
+ const provider = new KvOAuthProvider(id, ctx.storage, `${ctx.baseUrl}/oauth/callback/${id}`, refreshCoordinator);
501
509
  if (state)
502
510
  state.provider = provider;
503
511
  return provider;
@@ -514,9 +522,10 @@ export function remoteMcp(id, opts) {
514
522
  const url = new URL(opts.url);
515
523
  const guardedFetch = redirectSafeFetch(id, opts.redirects);
516
524
  if (opts.auth?.type === "oauth") {
525
+ const oauthProvider = provider ?? newProvider(ctx);
517
526
  return new StreamableHTTPClientTransport(url, {
518
- authProvider: provider ?? newProvider(ctx),
519
- fetch: guardedFetch,
527
+ authProvider: oauthProvider,
528
+ fetch: refreshCoordinator.coordinatedFetch(oauthProvider, guardedFetch, ctx.signal),
520
529
  });
521
530
  }
522
531
  const headers = opts.auth?.type === "headers"
@@ -749,6 +758,7 @@ export function remoteMcp(id, opts) {
749
758
  ...(opts.description !== undefined
750
759
  ? { description: opts.description }
751
760
  : {}),
761
+ ...(opts.authScope !== undefined ? { authScope: opts.authScope } : {}),
752
762
  ...(opts.maxResultBytes !== undefined
753
763
  ? { maxResultBytes: opts.maxResultBytes }
754
764
  : {}),
@@ -107,11 +107,11 @@ export declare class CredentialVault {
107
107
  constructor(storage: KVStorage, encryptionKey: string);
108
108
  private additionalData;
109
109
  private read;
110
- get(connectorId: string, field?: string): Promise<string | null>;
111
- getAll(connectorId: string): Promise<ConnectorCredentialValues | null>;
112
- metadata(connectorId: string): Promise<CredentialMetadata | null>;
113
- set(connectorId: string, value: string, updatedBy: string): Promise<CredentialMetadata>;
114
- setAll(connectorId: string, values: ConnectorCredentialValues, updatedBy: string): Promise<CredentialMetadata>;
115
- delete(connectorId: string): Promise<void>;
110
+ get(connectorId: string, field?: string, owner?: string): Promise<string | null>;
111
+ getAll(connectorId: string, owner?: string): Promise<ConnectorCredentialValues | null>;
112
+ metadata(connectorId: string, owner?: string): Promise<CredentialMetadata | null>;
113
+ set(connectorId: string, value: string, updatedBy: string, owner?: string): Promise<CredentialMetadata>;
114
+ setAll(connectorId: string, values: ConnectorCredentialValues, updatedBy: string, owner?: string): Promise<CredentialMetadata>;
115
+ delete(connectorId: string, owner?: string): Promise<void>;
116
116
  }
117
117
  export {};