@zackbart/connecta 0.24.4 → 0.25.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 (107) hide show
  1. package/AGENTS.md +40 -10
  2. package/CHANGELOG.md +253 -0
  3. package/dist/advertised-schema.js +53 -0
  4. package/dist/auth/downstream-oauth.js +490 -106
  5. package/dist/call-admission.js +139 -113
  6. package/dist/catalog-service.js +141 -105
  7. package/dist/catalog.js +258 -33
  8. package/dist/concurrency.js +24 -23
  9. package/dist/connector-scope.js +6 -37
  10. package/dist/connectors/api.d.ts +2 -2
  11. package/dist/connectors/remote-mcp.d.ts +6 -5
  12. package/dist/connectors/remote-mcp.js +317 -241
  13. package/dist/credential-contract.d.ts +9 -0
  14. package/dist/credential-rules.js +2 -2
  15. package/dist/credentials.d.ts +6 -1
  16. package/dist/credentials.js +45 -3
  17. package/dist/execute.js +363 -257
  18. package/dist/executor-admission.js +67 -50
  19. package/dist/executors/quickjs-runtime.js +4 -2
  20. package/dist/executors/quickjs.js +251 -210
  21. package/dist/fix-prompt.js +37 -0
  22. package/dist/index.d.ts +10 -1
  23. package/dist/index.js +51 -20
  24. package/dist/invocation.js +324 -285
  25. package/dist/meta-tools.js +206 -92
  26. package/dist/oauth-callback-outcome.js +94 -0
  27. package/dist/oauth-sealing.js +29 -0
  28. package/dist/operator-ui/fix-prompts.js +87 -0
  29. package/dist/operator-ui/generated.js +2 -2
  30. package/dist/operator-ui/model.d.ts +46 -1
  31. package/dist/operator-ui/setup-commands.js +53 -0
  32. package/dist/operator-ui/view.js +38 -2
  33. package/dist/program-source.js +205 -0
  34. package/dist/providers/notion.js +2 -2
  35. package/dist/providers/revenuecat.js +32 -5
  36. package/dist/providers/stripe.d.ts +3 -2
  37. package/dist/providers/vercel.js +1 -1
  38. package/dist/registry.d.ts +1 -1
  39. package/dist/registry.js +305 -260
  40. package/dist/routes/activity.js +120 -137
  41. package/dist/routes/credentials.js +174 -163
  42. package/dist/routes/mcp.js +129 -70
  43. package/dist/routes/oauth-management.js +60 -71
  44. package/dist/routes/oauth.js +44 -16
  45. package/dist/routes/operator.js +82 -0
  46. package/dist/routes/shared.d.ts +2 -0
  47. package/dist/routes/ui-data.js +210 -0
  48. package/dist/routes/ui.js +64 -32
  49. package/dist/runtime/admission.js +43 -0
  50. package/dist/runtime/call-admission.js +67 -0
  51. package/dist/runtime/connector-scope.js +72 -0
  52. package/dist/runtime/run.js +174 -0
  53. package/dist/runtime/services.js +130 -0
  54. package/dist/runtime/storage.js +66 -0
  55. package/dist/server.js +111 -89
  56. package/dist/skills.js +1 -1
  57. package/dist/storage/file.js +39 -2
  58. package/dist/storage/memory.js +28 -16
  59. package/dist/timeout.js +17 -47
  60. package/dist/types.d.ts +27 -7
  61. package/dist/ui.d.ts +16 -2
  62. package/dist/ui.js +50 -166
  63. package/dist/url-safety.js +157 -0
  64. package/dist/version.d.ts +1 -1
  65. package/dist/version.js +1 -1
  66. package/documentation/architecture.md +184 -10
  67. package/documentation/auth.md +80 -0
  68. package/documentation/code-mode.md +69 -24
  69. package/documentation/meta-tools.md +164 -27
  70. package/ethos.md +44 -47
  71. package/examples/worker/README.md +51 -5
  72. package/examples/worker/src/d1-storage.ts +116 -0
  73. package/examples/worker/src/index.ts +6 -6
  74. package/examples/worker/wrangler.jsonc +16 -4
  75. package/package.json +11 -3
  76. package/templates/node/package.json +1 -1
  77. package/dist/auth/downstream-oauth.d.ts +0 -200
  78. package/dist/catalog-fingerprint.d.ts +0 -19
  79. package/dist/catalog-limits.d.ts +0 -9
  80. package/dist/catalog-service.d.ts +0 -263
  81. package/dist/catalog.d.ts +0 -87
  82. package/dist/concurrency.d.ts +0 -3
  83. package/dist/connectors/guarded-fetch.d.ts +0 -96
  84. package/dist/credential-rules.d.ts +0 -85
  85. package/dist/execute.d.ts +0 -154
  86. package/dist/executor-result.d.ts +0 -10
  87. package/dist/executors/quickjs-child.d.ts +0 -1
  88. package/dist/executors/quickjs-protocol.d.ts +0 -67
  89. package/dist/favicon.d.ts +0 -1
  90. package/dist/identity.d.ts +0 -4
  91. package/dist/invocation.d.ts +0 -74
  92. package/dist/mcp-result.d.ts +0 -8
  93. package/dist/meta-tools.d.ts +0 -129
  94. package/dist/operator-ui/generated.d.ts +0 -2
  95. package/dist/operator-ui/view.d.ts +0 -188
  96. package/dist/result-shapes.d.ts +0 -13
  97. package/dist/retired-toolkits.d.ts +0 -2
  98. package/dist/routes/activity.d.ts +0 -2
  99. package/dist/routes/credentials.d.ts +0 -2
  100. package/dist/routes/mcp.d.ts +0 -10
  101. package/dist/routes/oauth-management.d.ts +0 -2
  102. package/dist/routes/oauth.d.ts +0 -2
  103. package/dist/routes/ui.d.ts +0 -2
  104. package/dist/server.d.ts +0 -9
  105. package/dist/skills.d.ts +0 -44
  106. package/dist/timeout.d.ts +0 -21
  107. package/dist/tool-safety.d.ts +0 -9
@@ -1,8 +1,12 @@
1
1
  import { Client, ProtocolError, SdkHttpError, isInputRequiredResult, specTypeSchemas, StreamableHTTPClientTransport, UnauthorizedError, } from "@modelcontextprotocol/client";
2
+ import { Deferred, Duration, Effect, Exit, Scope } from "effect";
2
3
  import { KvOAuthProvider, OAuthRefreshCoordinator, } from "../auth/downstream-oauth.js";
3
4
  import { MAX_CATALOG_TOOLS } from "../catalog-limits.js";
4
5
  import { boundedEchoText, ConnectorCallError, msg, unavailableCallError, } from "../errors.js";
5
6
  import { CONNECTA_VERSION } from "../version.js";
7
+ import { learnedUrlRefusal } from "../url-safety.js";
8
+ import { inheritOAuthSealer, oauthSealerFor } from "../oauth-sealing.js";
9
+ import { detach, runEdge } from "../runtime/run.js";
6
10
  /**
7
11
  * Apply a maintained provider's slot copy and header framing to credential
8
12
  * auth the deployment left bare.
@@ -33,6 +37,13 @@ export function withCredentialDefaults(auth, defaults) {
33
37
  * response, while callers continue to wait at most 100 ms.
34
38
  */
35
39
  const TERMINATE_SESSION_BUDGET_MS = 1_000;
40
+ /**
41
+ * How long the local close gets after the DELETE: the second of the two
42
+ * seconds the core's deferred scope-close tail allows (see
43
+ * `src/runtime/connector-scope.ts`). The SDK's own transports close at once;
44
+ * this bounds one that never does.
45
+ */
46
+ const LOCAL_CLOSE_BUDGET_MS = 1_000;
36
47
  /**
37
48
  * Absolute backstop on `tools/list` pages in one refresh — a runaway guard, not
38
49
  * the primary defense.
@@ -104,7 +115,7 @@ function isCursorShapeError(err) {
104
115
  * (405 is a legal answer), errors, or never replies all fall through to the
105
116
  * close with the session left to age out as it did before.
106
117
  */
107
- async function terminateSession(transport, logger, connectorId) {
118
+ function terminateSession(transport, logger, connectorId) {
108
119
  // SDK v2's Client.close() does not send the legacy session DELETE on our
109
120
  // behalf. Connecta's own endpoint creates no protocol session, but a stateful
110
121
  // legacy downstream can still issue `Mcp-Session-Id`, and every path that
@@ -112,53 +123,57 @@ async function terminateSession(transport, logger, connectorId) {
112
123
  // abandoned connect — owes it this best-effort, one-second DELETE.
113
124
  const terminate = transport.terminateSession;
114
125
  if (typeof terminate !== "function")
115
- return;
116
- // The SDK issues no request at all when no `mcp-session-id` was captured, so
117
- // a stateless downstream never sees a spurious DELETE.
118
- const done = Promise.resolve().then(() => terminate.call(transport));
119
- await new Promise((resolve) => {
120
- let finished = false;
121
- const warn = (message, error) => {
122
- try {
123
- if (error === undefined)
124
- logger.warn(message);
125
- else
126
- logger.warn(message, error);
127
- }
128
- catch {
129
- // A diagnostic sink cannot make best-effort teardown observable to the
130
- // caller in the one way this contract forbids: by replacing its result.
131
- }
132
- };
133
- const timer = setTimeout(() => {
134
- if (finished)
135
- return;
136
- finished = true;
137
- warn(`[connecta] connector "${connectorId}" session termination was not ` +
138
- `acknowledged within ${TERMINATE_SESSION_BUDGET_MS} ms; the ` +
139
- "downstream may still finish the headers-only DELETE, otherwise " +
140
- "the session will remain until its provider timeout.");
141
- resolve();
142
- }, TERMINATE_SESSION_BUDGET_MS);
143
- done.then(() => {
144
- if (finished)
145
- return;
146
- finished = true;
147
- clearTimeout(timer);
148
- resolve();
149
- }, (error) => {
150
- // The rejection handler stays attached after the timer wins, so an
151
- // abort or other late failure is consumed without a duplicate warning.
152
- if (finished)
153
- return;
154
- finished = true;
155
- clearTimeout(timer);
156
- warn(`[connecta] connector "${connectorId}" session termination was ` +
157
- "refused or failed; the downstream session may remain until its " +
158
- "provider timeout.", error);
159
- resolve();
160
- });
126
+ return Effect.void;
127
+ const warn = (message, error) => Effect.sync(() => {
128
+ try {
129
+ if (error === undefined)
130
+ logger.warn(message);
131
+ else
132
+ logger.warn(message, error);
133
+ }
134
+ catch {
135
+ // A diagnostic sink cannot make best-effort teardown observable to the
136
+ // caller in the one way this contract forbids: by replacing its result.
137
+ }
161
138
  });
139
+ // Whichever finishes first wins and the other is interrupted: an answer
140
+ // clears the timer, and a failure that lands after the timeout is consumed
141
+ // by the interrupted wait rather than warned about a second time.
142
+ return Effect.raceAllFirst([
143
+ // The SDK issues no request at all when no `mcp-session-id` was captured,
144
+ // so a stateless downstream never sees a spurious DELETE.
145
+ promised(() => Promise.resolve(terminate.call(transport))).pipe(Effect.catch((error) => warn(`[connecta] connector "${connectorId}" session termination was ` +
146
+ "refused or failed; the downstream session may remain until its " +
147
+ "provider timeout.", error))),
148
+ Effect.sleep(Duration.millis(TERMINATE_SESSION_BUDGET_MS)).pipe(Effect.andThen(warn(`[connecta] connector "${connectorId}" session termination was not ` +
149
+ `acknowledged within ${TERMINATE_SESSION_BUDGET_MS} ms; the ` +
150
+ "downstream may still finish the headers-only DELETE, otherwise " +
151
+ "the session will remain until its provider timeout."))),
152
+ ]);
153
+ }
154
+ /**
155
+ * The local half of a close: the SDK client's close once a client owns the
156
+ * transport, the bare transport's until then. Never fails, and stops waiting
157
+ * after LOCAL_CLOSE_BUDGET_MS — a transport whose close never settles would
158
+ * otherwise hold every caller that awaits the scope close, the credential Test
159
+ * action among them.
160
+ */
161
+ function closeLocally(client, transport) {
162
+ if (!client && !transport)
163
+ return Effect.void;
164
+ return Effect.raceAllFirst([
165
+ promised(() => Promise.resolve(client ? client.close() : transport?.close())).pipe(Effect.ignore),
166
+ Effect.sleep(Duration.millis(LOCAL_CLOSE_BUDGET_MS)),
167
+ ]);
168
+ }
169
+ /**
170
+ * One Promise step of an Effect program. It fails with exactly what the
171
+ * promise rejected with, never a wrapper, because callers check the SDK's and
172
+ * connecta's own error classes. `evaluate` takes no signal, so the step
173
+ * allocates no AbortController.
174
+ */
175
+ function promised(evaluate) {
176
+ return Effect.tryPromise({ try: evaluate, catch: (error) => error });
162
177
  }
163
178
  /** Classify protocol/status facts; provider prose never decides retryability. */
164
179
  function downstreamCallError(error) {
@@ -280,7 +295,8 @@ function redirectedInit(init, status) {
280
295
  * Wrap fetch with explicit, bounded redirect handling.
281
296
  *
282
297
  * The starting URL of each fetch call is trusted by its caller (the configured
283
- * MCP endpoint, or an OAuth URL discovered by the pinned SDK). Only Location
298
+ * MCP endpoint, or an OAuth URL the pinned SDK discovered and
299
+ * `learnedUrlSafeFetch` already admitted). Only Location
284
300
  * values are policy-controlled here. No rejected target is ever fetched, so
285
301
  * arbitrary static header names receive the same protection as Authorization.
286
302
  */
@@ -342,6 +358,32 @@ export function redirectSafeFetch(connectorId, policy = "none", baseFetch = fetc
342
358
  }
343
359
  };
344
360
  }
361
+ class RemoteMcpDestinationError extends ConnectorCallError {
362
+ constructor(connectorId, reason) {
363
+ super("connector_call_failed", `Connector "${connectorId}" refused an OAuth URL the downstream advertised: ${reason}.`);
364
+ this.name = "RemoteMcpDestinationError";
365
+ }
366
+ }
367
+ /**
368
+ * Refuse, before any request leaves, a URL the downstream taught the OAuth
369
+ * flow that points somewhere connecta must not go (see `url-safety.ts`).
370
+ *
371
+ * The SDK learns authorization-server, token, and registration URLs from the
372
+ * downstream's own metadata and fetches them through the transport's fetch,
373
+ * so this sits on that fetch: above `redirectSafeFetch`, whose same-origin
374
+ * rule keeps every hop on the host checked here, and below the refresh
375
+ * coordinator, which already treats a non-retryable `ConnectorCallError` as
376
+ * connecta's own refusal rather than a token-endpoint verdict.
377
+ */
378
+ function learnedUrlSafeFetch(connectorId, configured, baseFetch) {
379
+ return async (input, init) => {
380
+ const reason = learnedUrlRefusal(configured, new URL(input));
381
+ if (reason !== undefined) {
382
+ throw new RemoteMcpDestinationError(connectorId, reason);
383
+ }
384
+ return await baseFetch(input, init);
385
+ };
386
+ }
345
387
  /**
346
388
  * Proxy a downstream remote MCP server. SDK clients and transports are scoped
347
389
  * to one inbound request: reused by calls within a batch/execute_code run, but
@@ -359,12 +401,11 @@ export function remoteMcp(id, opts) {
359
401
  "can own a different grant.");
360
402
  }
361
403
  // Weak keys ensure a completed request does not leave its SDK client,
362
- // transport, response bodies, AbortSignals, or connection promise reachable
404
+ // transport, response bodies, AbortSignals, or connect attempt reachable
363
405
  // from the isolate singleton. Those are request-bound in Cloudflare Workers.
406
+ // A closed scope keeps its (emptied) entry, so a late or future lookup finds
407
+ // it closed rather than recreating an ownerless connection under it.
364
408
  const states = new WeakMap();
365
- // Closing is terminal even after `states.delete`: a late or future lookup
366
- // must not recreate an ownerless connection under the ended scope.
367
- const closedScopes = new WeakSet();
368
409
  const isOauth = opts.auth?.type === "oauth";
369
410
  // Long-lived enough for distinct request scopes in this connector runtime to
370
411
  // join one token redemption. It owns no client, transport, or request state.
@@ -404,8 +445,8 @@ export function remoteMcp(id, opts) {
404
445
  throw new Error(`[connecta] connector "${id}" url ${opts.url} is not https:// (and not loopback) — refusing to connect (requireHttps).`);
405
446
  }
406
447
  // Both static shapes send a secret on every request; that an operator
407
- // typed one into /credentials rather than a deployment file changes who
408
- // owns it, not whether the wire carries it in the clear.
448
+ // typed one into the operator UI rather than a deployment file changes
449
+ // who owns it, not whether the wire carries it in the clear.
409
450
  if (opts.auth?.type === "headers" || credentialAuth) {
410
451
  logger.warn(`[connecta] connector "${id}" sends static credentials to ${opts.url} over a non-https:// connection — those tokens will be transmitted in cleartext.`);
411
452
  }
@@ -423,7 +464,7 @@ export function remoteMcp(id, opts) {
423
464
  * key to read it with). Deliberately the same `auth_required` code an absent
424
465
  * OAuth grant produces: the agent's next move is `authorize_connector`
425
466
  * either way, and that tool reads `connector.credential` to hand the
426
- * operator `/credentials` instead of a consent URL.
467
+ * operator the connection in the UI when available instead of a consent URL.
427
468
  */
428
469
  class CredentialRequiredError extends ConnectorCallError {
429
470
  constructor(message) {
@@ -438,8 +479,7 @@ export function remoteMcp(id, opts) {
438
479
  const readCredential = async (ctx) => {
439
480
  if (!ctx.credential) {
440
481
  throw new CredentialRequiredError(`Connector "${id}" needs an operator-managed credential, but ` +
441
- "credential storage is not configured. Set " +
442
- "credentials.encryptionKey and redeploy.");
482
+ "credential storage is not configured. Configure vault in deployment code and redeploy. Call authorize_connector for recovery options.");
443
483
  }
444
484
  // A stored-shape mismatch already arrives as a typed auth_required from
445
485
  // the registry's accessor; nothing to reclassify here.
@@ -456,8 +496,9 @@ export function remoteMcp(id, opts) {
456
496
  // to the agent. So the check lives here, and says only what is wrong.
457
497
  if (carriesIllegalHeaderChar(value)) {
458
498
  throw new CredentialRequiredError(`Connector "${id}"'s stored credential contains a character a header ` +
459
- "cannot carry (a line break or other control character) — re-enter " +
460
- "it on /credentials. The value is not shown or logged.");
499
+ "cannot carry (a line break or other control character). Call " +
500
+ "authorize_connector for recovery options. When available, re-enter " +
501
+ "it in this connection in the operator UI. The value is not shown or logged.");
461
502
  }
462
503
  return value;
463
504
  };
@@ -482,8 +523,9 @@ export function remoteMcp(id, opts) {
482
523
  seen.add(current);
483
524
  if (quoted.some((secret) => current instanceof Error && current.message.includes(secret))) {
484
525
  return new CredentialRequiredError(`Connector "${id}" could not send its stored credential as a ` +
485
- "header — re-enter it on /credentials. The value is not shown or " +
486
- "logged.");
526
+ "header. Call authorize_connector for recovery options. When available, " +
527
+ "re-enter it in this connection in the operator UI. " +
528
+ "The value is not shown or logged.");
487
529
  }
488
530
  current = current.cause;
489
531
  }
@@ -514,13 +556,14 @@ export function remoteMcp(id, opts) {
514
556
  throw new Error(`Connector "${id}" returned a tools/list page whose nextCursor is neither a string, null, nor absent — this catalog cannot be walked.`, { cause: err });
515
557
  }
516
558
  };
517
- const stateFor = (ctx) => {
518
- const scope = ctx.requestScope ?? ctx;
519
- if (closedScopes.has(scope))
520
- throw scopeEndedError();
521
- let state = states.get(scope);
559
+ /** This request scope's entry, open or closed, created on first sight. */
560
+ const entryFor = (ctx) => {
561
+ const key = ctx.requestScope ?? ctx;
562
+ let state = states.get(key);
522
563
  if (!state) {
523
564
  state = {
565
+ scope: Scope.makeUnsafe(),
566
+ lease: null,
524
567
  client: null,
525
568
  transport: null,
526
569
  toolDefinitions: new Map(),
@@ -529,16 +572,22 @@ export function remoteMcp(id, opts) {
529
572
  provider: null,
530
573
  connectedGeneration: null,
531
574
  credentialDigest: null,
532
- closed: false,
533
575
  };
534
- states.set(scope, state);
576
+ states.set(key, state);
535
577
  }
536
578
  return state;
537
579
  };
580
+ const isClosed = (state) => state.scope.state._tag === "Closed";
581
+ const stateFor = (ctx) => {
582
+ const state = entryFor(ctx);
583
+ if (isClosed(state))
584
+ throw scopeEndedError();
585
+ return state;
586
+ };
538
587
  const newProvider = (ctx, state) => {
539
588
  if (state?.provider)
540
589
  return state.provider;
541
- const provider = new KvOAuthProvider(id, ctx.storage, `${ctx.baseUrl}/oauth/callback/${id}`, refreshCoordinator, ctx.allowAuthorization === true);
590
+ const provider = new KvOAuthProvider(id, ctx.storage, `${ctx.baseUrl}/oauth/callback/${id}`, refreshCoordinator, ctx.allowAuthorization === true, oauthSealerFor(ctx));
542
591
  if (state)
543
592
  state.provider = provider;
544
593
  return provider;
@@ -558,7 +607,7 @@ export function remoteMcp(id, opts) {
558
607
  const oauthProvider = provider ?? newProvider(ctx);
559
608
  return new StreamableHTTPClientTransport(url, {
560
609
  authProvider: oauthProvider,
561
- fetch: refreshCoordinator.coordinatedFetch(oauthProvider, guardedFetch, ctx.signal),
610
+ fetch: refreshCoordinator.coordinatedFetch(oauthProvider, learnedUrlSafeFetch(id, url, guardedFetch), ctx.signal),
562
611
  });
563
612
  }
564
613
  const headers = opts.auth?.type === "headers"
@@ -572,6 +621,7 @@ export function remoteMcp(id, opts) {
572
621
  });
573
622
  };
574
623
  const reset = (state) => {
624
+ state.lease = null;
575
625
  state.client = null;
576
626
  state.transport = null;
577
627
  state.toolDefinitions.clear();
@@ -580,40 +630,35 @@ export function remoteMcp(id, opts) {
580
630
  state.provider = null;
581
631
  state.connectedGeneration = null;
582
632
  state.credentialDigest = null;
583
- // `closed` is deliberately not cleared — see ConnectionState.
633
+ // `scope` is deliberately left as it is — see ConnectionState.
584
634
  };
585
635
  // The context has no deferred-work hook. Detached exits start this bounded
586
636
  // best-effort tail immediately; closeScope awaits its own tail so the core
587
- // can pass it to the runtime's deferred channel.
637
+ // can pass it to the runtime's deferred channel. Terminating and closing are
638
+ // each bounded, so no close waits more than two seconds in all.
588
639
  const closingSessions = new WeakMap();
589
- const closeConnection = (client, transport, logger) => {
640
+ const closeConnection = (client, transport, logger) => Effect.suspend(() => {
590
641
  const previous = transport && closingSessions.get(transport);
591
642
  if (previous)
592
- return previous;
593
- const closing = (async () => {
594
- try {
595
- if (transport)
596
- await terminateSession(transport, logger, id);
597
- if (client)
598
- await client.close();
599
- else
600
- await transport?.close();
601
- }
602
- catch {
603
- // Local close cannot replace the operation's result.
604
- }
605
- })();
643
+ return Deferred.await(previous);
644
+ const closed = Deferred.makeUnsafe();
606
645
  // A connect can acquire a session after an early close. Deduplicate only
607
646
  // once that session exists, so its late abandonment still sends DELETE.
608
647
  if (transport?.sessionId)
609
- closingSessions.set(transport, closing);
610
- return closing;
611
- };
612
- const closeHalf = (state, ctx) => {
613
- const client = state.client;
614
- const transport = state.transport;
648
+ closingSessions.set(transport, closed);
649
+ return (transport ? terminateSession(transport, logger, id) : Effect.void).pipe(Effect.andThen(closeLocally(client, transport)), Effect.ensuring(Deferred.done(closed, Exit.void)));
650
+ });
651
+ /**
652
+ * Forget the live connection and close its lease without waiting. A connect
653
+ * still in flight is unpublished with it, and sees at its next check that it
654
+ * has been abandoned.
655
+ */
656
+ const closeHalf = (state) => {
657
+ const lease = state.lease;
615
658
  reset(state);
616
- void closeConnection(client, transport, ctx.logger);
659
+ const release = lease && Scope.closeUnsafe(lease, Exit.void);
660
+ if (release)
661
+ detach(release);
617
662
  };
618
663
  const ensureConnected = async (ctx, state) => {
619
664
  // A 401 after connect is a verdict for the whole request scope, not merely
@@ -631,9 +676,7 @@ export function remoteMcp(id, opts) {
631
676
  const provider = newProvider(ctx, state);
632
677
  oauthGeneration = await provider.generation();
633
678
  if (provider.isOperatorDisconnectedGeneration(oauthGeneration)) {
634
- const connecting = state.connecting;
635
- void connecting?.catch(() => { });
636
- closeHalf(state, ctx);
679
+ closeHalf(state);
637
680
  throw operatorDisconnectedError();
638
681
  }
639
682
  }
@@ -641,16 +684,16 @@ export function remoteMcp(id, opts) {
641
684
  // wiped credentials. This request's cached client still speaks the old
642
685
  // token — drop it so the next connect runs against current state.
643
686
  if (state.client && oauthGeneration !== undefined && state.connectedGeneration !== null) {
644
- if (state.closed)
687
+ if (isClosed(state))
645
688
  throw scopeEndedError();
646
689
  if (oauthGeneration !== state.connectedGeneration) {
647
- closeHalf(state, ctx);
690
+ closeHalf(state);
648
691
  }
649
692
  }
650
693
  // The static-credential counterpart of the epoch read above, and
651
694
  // deliberately beside it: the vault is read before any cached client is
652
- // trusted, so an operator's rotation on /credentials takes effect on the
653
- // next call rather than the next deploy. Compared by digest — the
695
+ // trusted, so an operator's rotation in the UI takes effect on the next
696
+ // call rather than the next deploy. Compared by digest — the
654
697
  // plaintext lives in this function's scope and never reaches `state`.
655
698
  let credentialValue = null;
656
699
  let credentialFramed = null;
@@ -666,134 +709,167 @@ export function remoteMcp(id, opts) {
666
709
  if ((state.client || state.connecting) &&
667
710
  state.credentialDigest !== null &&
668
711
  state.credentialDigest !== credentialDigest) {
669
- if (state.closed)
712
+ if (isClosed(state))
670
713
  throw scopeEndedError();
671
- const connecting = state.connecting;
672
- void connecting?.catch(() => { });
673
- closeHalf(state, ctx);
714
+ closeHalf(state);
674
715
  }
675
716
  }
676
- if (state.closed)
717
+ if (isClosed(state))
677
718
  throw scopeEndedError();
678
719
  if (state.client)
679
- return;
680
- if (!state.connecting) {
681
- let attempt;
682
- attempt = (async () => {
683
- const ownsAttempt = () => state.connecting === attempt && !state.closed;
684
- const abandon = (client, transport) => {
685
- void closeConnection(client, transport, ctx.logger);
686
- throw scopeEndedError();
687
- };
688
- // Let the assignment immediately below this async IIFE publish
689
- // `state.connecting = attempt` before ownership is checked. OAuth's
690
- // generation read naturally yields; unauthenticated transports do not.
691
- await Promise.resolve();
692
- // A provider belongs to exactly one connect attempt. A force reset can
693
- // abandon that attempt while its transport still holds the provider;
694
- // the replacement must never mutate the abandoned provider's epoch.
695
- const provider = isOauth ? newProvider(ctx) : null;
696
- const genAtStart = provider ? await provider.generation() : "";
697
- if (!ownsAttempt())
698
- throw scopeEndedError();
699
- if (provider?.isOperatorDisconnectedGeneration(genAtStart)) {
700
- throw operatorDisconnectedError();
701
- }
702
- provider?.captureGeneration(genAtStart);
703
- // SDK v2 selects its validator by runtime export condition: AJV on
704
- // Node and @cfworker/json-schema under workerd. The Workers-safe path
705
- // no longer needs Connecta-specific wiring.
706
- const c = new Client({ name: "connecta", version: CONNECTA_VERSION }, {
707
- versionNegotiation: {
708
- mode: opts.versionNegotiation ?? "auto",
709
- },
710
- // Connecta has no interactive relay. Surface the result manually
711
- // below as one structured, non-retryable connector failure.
712
- inputRequired: { autoFulfill: false },
713
- });
714
- const t = buildTransport(ctx, provider, credentialFramed);
715
- if (!ownsAttempt())
716
- abandon(null, t);
717
- state.transport = t;
718
- try {
719
- await c.connect(t);
720
- // A probe deadline can end its scope while connect is still in flight.
721
- // The transport is closed immediately by closeScope; if connect wins
722
- // that race anyway, close the resulting client rather than
723
- // resurrecting a session in the detached state object.
724
- if (!ownsAttempt())
725
- abandon(c, t);
726
- // A force re-auth that landed WHILE we were connecting wiped the
727
- // credentials this client just bound to. Discard it rather than
728
- // cache a stale-isolate connection.
729
- if (provider) {
730
- const generation = await provider.generation();
731
- // closeScope can land while the generation read is pending, after
732
- // connect succeeded but before this client is cached. Discard the
733
- // client on that side of the await too.
734
- if (!ownsAttempt())
735
- abandon(c, t);
736
- if (generation !== genAtStart) {
737
- void closeConnection(c, t, ctx.logger);
738
- throw new UnauthorizedError("Connector was re-authorized during connect; reconnect required.");
739
- }
720
+ return state.client;
721
+ const attempt = state.connecting ??
722
+ startConnect(ctx, state, credentialValue, credentialFramed, credentialDigest);
723
+ // A Promise edge of its own, as architecture.md's "Effect inside" requires
724
+ // of every shared wait: whatever this caller does next with the client is
725
+ // its own I/O, so it resumes in its own continuation rather than inside the
726
+ // call that completed the attempt.
727
+ const client = await runEdge(Deferred.await(attempt));
728
+ // Teardown can land between the attempt completing and this caller
729
+ // resuming. The client it would hand back is being closed, and this scope
730
+ // is over.
731
+ if (isClosed(state))
732
+ throw scopeEndedError();
733
+ return client;
734
+ };
735
+ /**
736
+ * Start this scope's connect attempt, publishing it before any of it runs.
737
+ * The attempt completes its Deferred itself, and every caller — the one that
738
+ * started it included — waits on that; nothing awaits the run directly,
739
+ * which is why handing it to `detach` orphans nothing.
740
+ */
741
+ const startConnect = (ctx, state, credentialValue, credentialFramed, credentialDigest) => {
742
+ const attempt = Deferred.makeUnsafe();
743
+ state.connecting = attempt;
744
+ // Published with the attempt, not with its result: a rotation that lands
745
+ // while this connect is in flight has to be visible to the next caller,
746
+ // which would otherwise wait on a client bound to the older key.
747
+ state.credentialDigest = credentialDigest;
748
+ detach(connect(ctx, state, attempt, credentialValue, credentialFramed).pipe(Effect.onExit((exit) => Effect.sync(() => {
749
+ // Force reset may have abandoned this attempt and installed a new
750
+ // one in the same request scope. An old completion must not erase
751
+ // the new attempt and allow a third concurrent connect.
752
+ if (state.connecting === attempt)
753
+ state.connecting = null;
754
+ // Last: a Deferred resumes its waiters inside this call.
755
+ Deferred.doneUnsafe(attempt, exit);
756
+ }))));
757
+ return attempt;
758
+ };
759
+ const connect = (ctx, state, attempt, credentialValue, credentialFramed) => {
760
+ // Force reset, rotation, and teardown all abandon an attempt the same way:
761
+ // they unpublish it and close the lease it holds, if it holds one yet.
762
+ const owned = () => state.connecting === attempt && !isClosed(state);
763
+ return Effect.gen(function* () {
764
+ // A provider belongs to exactly one connect attempt. A force reset can
765
+ // abandon that attempt while its transport still holds the provider;
766
+ // the replacement must never mutate the abandoned provider's epoch.
767
+ const provider = isOauth ? newProvider(ctx) : null;
768
+ const genAtStart = provider ? yield* promised(() => provider.generation()) : "";
769
+ if (!owned())
770
+ return yield* Effect.fail(scopeEndedError());
771
+ if (provider?.isOperatorDisconnectedGeneration(genAtStart)) {
772
+ return yield* Effect.fail(operatorDisconnectedError());
773
+ }
774
+ provider?.captureGeneration(genAtStart);
775
+ // SDK v2 selects its validator by runtime export condition: AJV on
776
+ // Node and @cfworker/json-schema under workerd. The Workers-safe path
777
+ // no longer needs Connecta-specific wiring.
778
+ const c = new Client({ name: "connecta", version: CONNECTA_VERSION }, {
779
+ versionNegotiation: {
780
+ mode: opts.versionNegotiation ?? "auto",
781
+ },
782
+ // Connecta has no interactive relay. Surface the result manually
783
+ // below as one structured, non-retryable connector failure.
784
+ inputRequired: { autoFulfill: false },
785
+ });
786
+ const t = buildTransport(ctx, provider, credentialFramed);
787
+ if (!owned()) {
788
+ detach(closeConnection(null, t, ctx.logger));
789
+ return yield* Effect.fail(scopeEndedError());
790
+ }
791
+ // From here the connection has a lease on the scope, and closing it —
792
+ // force reset, rotation, teardown — closes this transport: through the
793
+ // client once one is cached, the bare transport until then, which is
794
+ // what aborts a connect still in flight.
795
+ const lease = Scope.forkUnsafe(state.scope);
796
+ const held = { client: null };
797
+ yield* Scope.addFinalizer(lease, Effect.suspend(() => closeConnection(held.client, t, ctx.logger)));
798
+ state.lease = lease;
799
+ state.transport = t;
800
+ return yield* Effect.gen(function* () {
801
+ yield* promised(() => c.connect(t));
802
+ // A probe deadline can end its scope while connect is still in flight.
803
+ // The transport is closed immediately by closeScope; if connect wins
804
+ // that race anyway, close the resulting client rather than resurrecting
805
+ // a session in the detached state object.
806
+ if (!owned())
807
+ return yield* Effect.fail(scopeEndedError());
808
+ // A force re-auth that landed WHILE we were connecting wiped the
809
+ // credentials this client just bound to. Discard it rather than cache
810
+ // a stale-isolate connection.
811
+ if (provider) {
812
+ const generation = yield* promised(() => provider.generation());
813
+ // closeScope can land while the generation read is pending, after
814
+ // connect succeeded but before this client is cached. Discard the
815
+ // client on that side of the await too.
816
+ if (!owned())
817
+ return yield* Effect.fail(scopeEndedError());
818
+ if (generation !== genAtStart) {
819
+ return yield* Effect.fail(new UnauthorizedError("Connector was re-authorized during connect; reconnect required."));
740
820
  }
741
- if (!ownsAttempt())
742
- abandon(c, t);
743
- state.client = c;
744
- state.connectedGeneration = genAtStart;
745
- state.authRequired = false;
746
821
  }
747
- catch (err) {
748
- void closeConnection(c, t, ctx.logger);
749
- if (ownsAttempt())
750
- state.transport = null;
751
- // Only a real 401/UnauthorizedError means auth is the problem — a
752
- // network error on an oauth connector must surface as "error", not
753
- // "auth_required".
754
- if (err instanceof UnauthorizedError && ownsAttempt()) {
822
+ held.client = c;
823
+ state.client = c;
824
+ state.connectedGeneration = genAtStart;
825
+ state.authRequired = false;
826
+ return c;
827
+ }).pipe(Effect.catch((err) => {
828
+ // Close the failed connection through its client, without waiting.
829
+ held.client = c;
830
+ if (owned()) {
831
+ state.lease = null;
832
+ state.transport = null;
833
+ // Only a real 401/UnauthorizedError means auth is the problem —
834
+ // a network error on an oauth connector must surface as "error",
835
+ // not "auth_required".
836
+ if (err instanceof UnauthorizedError)
755
837
  state.authRequired = true;
756
- }
757
- if (err instanceof UnauthorizedError) {
758
- throw authRequiredError(err);
759
- }
760
- // Defense in depth for the one error class that can quote the
761
- // credential: a runtime refusing the assembled header. `readCredential`
762
- // already rejects a value that cannot ride one, so reaching this is a
763
- // gap in that check rather than a routine outcome.
764
- throw withoutCredential(err, credentialValue, credentialFramed);
838
+ const release = Scope.closeUnsafe(lease, Exit.void);
839
+ if (release)
840
+ detach(release);
765
841
  }
766
- finally {
767
- // Force reset may have abandoned this attempt and installed a new one
768
- // in the same request scope. An old completion must not erase the new
769
- // promise and allow a third concurrent connect.
770
- if (state.connecting === attempt)
771
- state.connecting = null;
842
+ else {
843
+ // Whoever abandoned this attempt closed the lease already, while
844
+ // the connect was in flight. A session it acquired after that
845
+ // close still owes its DELETE.
846
+ detach(closeConnection(c, t, ctx.logger));
772
847
  }
773
- })();
774
- state.connecting = attempt;
775
- // Published with the attempt, not with its result: a rotation that lands
776
- // while this connect is in flight has to be visible to the next caller,
777
- // which would otherwise wait on a client bound to the older key.
778
- state.credentialDigest = credentialDigest;
779
- }
780
- return state.connecting;
848
+ if (err instanceof UnauthorizedError) {
849
+ return Effect.fail(authRequiredError(err));
850
+ }
851
+ // Defense in depth for the one error class that can quote the
852
+ // credential: a runtime refusing the assembled header.
853
+ // `readCredential` already rejects a value that cannot ride one, so
854
+ // reaching this is a gap in that check rather than a routine
855
+ // outcome.
856
+ return Effect.fail(withoutCredential(err, credentialValue, credentialFramed));
857
+ }));
858
+ });
781
859
  };
782
860
  const disconnectAuthorization = async (ctx, state, operatorDisconnected = false) => {
783
861
  const provider = newProvider(ctx, state);
784
862
  // Publish the replacement epoch before waiting on or closing any
785
863
  // request-local transport. A hung connect therefore cannot delay the
786
864
  // fence, and every late OAuth write stays in the older namespace.
787
- const connecting = state.connecting;
788
865
  try {
789
866
  await provider.resetAuthorization(operatorDisconnected);
790
867
  }
791
868
  finally {
792
- // Consume the abandoned connect and close whichever half of the
869
+ // Abandon any connect in flight and close whichever half of the
793
870
  // client/transport exists. Reset is unconditional because KV may already
794
871
  // be fenced behind a newer epoch after a cleanup error.
795
- void connecting?.catch(() => { });
796
- closeHalf(state, ctx);
872
+ closeHalf(state);
797
873
  }
798
874
  };
799
875
  const connector = {
@@ -812,8 +888,8 @@ export function remoteMcp(id, opts) {
812
888
  : {}),
813
889
  ...(opts.usageGuide !== undefined ? { usageGuide: opts.usageGuide } : {}),
814
890
  // Declaring the slot is what makes the rest of the operator surface work:
815
- // /credentials renders it, the shape check compares against it, and
816
- // authorize_connector reads it to return the operator handoff rather than
891
+ // the connection's credential form renders it, the shape check compares
892
+ // against it, and authorize_connector returns the operator handoff rather than
817
893
  // an OAuth URL this connector has none of.
818
894
  ...(credentialAuth
819
895
  ? {
@@ -874,15 +950,12 @@ export function remoteMcp(id, opts) {
874
950
  // failure. Follow every page to the end of the cursor chain, preserve
875
951
  // schemas and annotations, and never cache or serve a partial walk.
876
952
  const state = stateFor(ctx);
877
- await ensureConnected(ctx, state);
878
- // Bind the client once so the whole walk provably rides one session — a
879
- // cursor is only meaningful to the connection that issued it, and a
880
- // re-read could in principle pick up a different one. It is NOT guarding
881
- // against closeScope nulling state.client mid-loop: closeScope sets
882
- // `closed` and nulls `client` in one synchronous run, and the loop
883
- // re-checks `closed` before every page, so the nulled client is
884
- // unreachable from here.
885
- const client = state.client;
953
+ // The client this call connected or joined, bound once so the whole walk
954
+ // provably rides one session — a cursor is only meaningful to the
955
+ // connection that issued it. Never re-read from `state`: a force reset
956
+ // or rotation can null it before this call resumes, and teardown is
957
+ // caught instead by the `closed` check before every page.
958
+ const client = await ensureConnected(ctx, state);
886
959
  // Raw SDK tools, not ToolDefs: the metadata re-prime below needs fields
887
960
  // (task support) that a ToolDef deliberately does not carry.
888
961
  const listed = [];
@@ -896,7 +969,7 @@ export function remoteMcp(id, opts) {
896
969
  for (let page = 0; page < MAX_TOOL_PAGES; page++) {
897
970
  // The scope can end between pages (probe timeout, teardown). Stop
898
971
  // rather than keep paging into a transport that is being closed.
899
- if (state.closed)
972
+ if (isClosed(state))
900
973
  throw scopeEndedError();
901
974
  // A discovery deadline uses the same signal for the whole chain.
902
975
  // Check it before issuing each page as well as passing it to the
@@ -993,8 +1066,7 @@ export function remoteMcp(id, opts) {
993
1066
  },
994
1067
  async callTool(name, args, ctx) {
995
1068
  const state = stateFor(ctx);
996
- await ensureConnected(ctx, state);
997
- const client = state.client;
1069
+ const client = await ensureConnected(ctx, state);
998
1070
  try {
999
1071
  const toolDefinition = state.toolDefinitions.get(name);
1000
1072
  if (toolDefinition?.execution?.taskSupport === "required") {
@@ -1026,20 +1098,17 @@ export function remoteMcp(id, opts) {
1026
1098
  }
1027
1099
  },
1028
1100
  async closeScope(ctx) {
1029
- const scope = ctx.requestScope ?? ctx;
1030
- // Tombstone before any lookup or await. This also makes close-before-use
1031
- // terminal rather than allowing the scope to spring into existence later.
1032
- closedScopes.add(scope);
1033
- const state = states.get(scope);
1034
- if (!state)
1035
- return;
1036
- // Delete before awaiting: a duplicate teardown is a no-op.
1037
- states.delete(scope);
1038
- state.closed = true;
1039
- const client = state.client;
1040
- const transport = state.transport;
1101
+ // A scope closed before its first use gets an entry too, closed at once,
1102
+ // so it cannot spring into existence later.
1103
+ const state = entryFor(ctx);
1104
+ // Closed before any await, and before the finalizers run: every check
1105
+ // from here on sees the scope ended. A duplicate teardown finds it
1106
+ // closed and has nothing to run.
1107
+ const release = Scope.closeUnsafe(state.scope, Exit.void);
1041
1108
  reset(state);
1042
- await closeConnection(client, transport, ctx.logger);
1109
+ // Runs the live connection's lease finalizer, if there is one.
1110
+ if (release)
1111
+ await runEdge(release);
1043
1112
  },
1044
1113
  async status(ctx) {
1045
1114
  const state = stateFor(ctx);
@@ -1056,8 +1125,8 @@ export function remoteMcp(id, opts) {
1056
1125
  }
1057
1126
  if (state.authRequired) {
1058
1127
  // Only an OAuth connector has a pending consent URL to offer. A
1059
- // credential connector's downstream 401 is repaired on /credentials,
1060
- // so do not reach into OAuth storage to look for one.
1128
+ // credential connector's downstream 401 is repaired in the operator
1129
+ // UI connection, so do not reach into OAuth storage to look for one.
1061
1130
  return {
1062
1131
  state: "auth_required",
1063
1132
  message: credentialAuth
@@ -1077,7 +1146,10 @@ export function remoteMcp(id, opts) {
1077
1146
  // verifyState ran on this request-scoped provider first and captured the
1078
1147
  // pending flow's generation. If force reset races the exchange, any late
1079
1148
  // token write remains tagged with that older generation and is unreadable.
1080
- const t = (state.transport ??
1149
+ // A transport an attempt built belongs to that attempt's lease; one built
1150
+ // here for the exchange alone belongs to this call, which closes it.
1151
+ const leased = state.transport;
1152
+ const t = (leased ??
1081
1153
  buildTransport(ctx, provider));
1082
1154
  if (callbackParams !== undefined) {
1083
1155
  await t.finishAuth(callbackParams);
@@ -1087,9 +1159,9 @@ export function remoteMcp(id, opts) {
1087
1159
  }
1088
1160
  await provider.clearPending();
1089
1161
  // Reset so the next use reconnects with the freshly stored tokens.
1090
- if (!state.transport)
1091
- state.transport = t;
1092
- closeHalf(state, ctx);
1162
+ closeHalf(state);
1163
+ if (!leased)
1164
+ detach(closeConnection(null, t, ctx.logger));
1093
1165
  },
1094
1166
  };
1095
1167
  if (opts.auth?.type === "oauth") {
@@ -1101,7 +1173,11 @@ export function remoteMcp(id, opts) {
1101
1173
  await disconnectAuthorization(ctx, stateFor(ctx), true);
1102
1174
  };
1103
1175
  connector.startAuth = async (ctx, startOpts) => {
1104
- ctx = { ...ctx, requestScope: ctx.requestScope ?? ctx, allowAuthorization: true };
1176
+ ctx = inheritOAuthSealer(ctx, {
1177
+ ...ctx,
1178
+ requestScope: ctx.requestScope ?? ctx,
1179
+ allowAuthorization: true,
1180
+ });
1105
1181
  const state = stateFor(ctx);
1106
1182
  state.provider = null;
1107
1183
  const p = newProvider(ctx, state);