@zackbart/connecta 0.24.3 → 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 (145) hide show
  1. package/AGENTS.md +58 -30
  2. package/CHANGELOG.md +317 -1
  3. package/README.md +5 -6
  4. package/dist/advertised-schema.js +53 -0
  5. package/dist/auth/downstream-oauth.js +490 -106
  6. package/dist/branding.d.ts +31 -2
  7. package/dist/branding.js +116 -8
  8. package/dist/call-admission.js +139 -113
  9. package/dist/catalog-service.js +141 -105
  10. package/dist/catalog.js +258 -33
  11. package/dist/concurrency.js +24 -23
  12. package/dist/connector-scope.js +6 -37
  13. package/dist/connectors/api.d.ts +3 -3
  14. package/dist/connectors/api.js +10 -2
  15. package/dist/connectors/guarded-fetch.js +34 -4
  16. package/dist/connectors/remote-mcp.d.ts +6 -5
  17. package/dist/connectors/remote-mcp.js +325 -245
  18. package/dist/credential-contract.d.ts +9 -0
  19. package/dist/credential-rules.js +2 -2
  20. package/dist/credentials.d.ts +6 -1
  21. package/dist/credentials.js +45 -3
  22. package/dist/errors.d.ts +11 -3
  23. package/dist/errors.js +2 -1
  24. package/dist/execute.js +363 -257
  25. package/dist/executor-admission.js +67 -50
  26. package/dist/executors/quickjs-runtime.js +4 -2
  27. package/dist/executors/quickjs.js +251 -210
  28. package/dist/fix-prompt.js +37 -0
  29. package/dist/index.d.ts +11 -2
  30. package/dist/index.js +63 -21
  31. package/dist/invocation.js +324 -285
  32. package/dist/meta-tools.js +304 -114
  33. package/dist/oauth-callback-outcome.js +94 -0
  34. package/dist/oauth-sealing.js +29 -0
  35. package/dist/operator-ui/fix-prompts.js +87 -0
  36. package/dist/operator-ui/generated.js +2 -2
  37. package/dist/operator-ui/model.d.ts +46 -1
  38. package/dist/operator-ui/setup-commands.js +53 -0
  39. package/dist/operator-ui/view.js +109 -2
  40. package/dist/program-source.js +205 -0
  41. package/dist/providers/cloudflare.d.ts +14 -2
  42. package/dist/providers/cloudflare.js +107 -16
  43. package/dist/providers/linear.d.ts +26 -4
  44. package/dist/providers/linear.js +19 -4
  45. package/dist/providers/mixpanel.d.ts +16 -3
  46. package/dist/providers/mixpanel.js +13 -2
  47. package/dist/providers/notion.d.ts +8 -1
  48. package/dist/providers/notion.js +85 -12
  49. package/dist/providers/revenuecat.d.ts +30 -4
  50. package/dist/providers/revenuecat.js +70 -5
  51. package/dist/providers/stripe.d.ts +10 -3
  52. package/dist/providers/stripe.js +30 -4
  53. package/dist/providers/vercel.js +12 -2
  54. package/dist/registry.d.ts +13 -5
  55. package/dist/registry.js +317 -258
  56. package/dist/routes/activity.js +120 -137
  57. package/dist/routes/credentials.js +174 -163
  58. package/dist/routes/mcp.js +129 -70
  59. package/dist/routes/oauth-management.js +60 -71
  60. package/dist/routes/oauth.js +44 -16
  61. package/dist/routes/operator.js +82 -0
  62. package/dist/routes/shared.d.ts +2 -0
  63. package/dist/routes/ui-data.js +210 -0
  64. package/dist/routes/ui.js +64 -32
  65. package/dist/runtime/admission.js +43 -0
  66. package/dist/runtime/call-admission.js +67 -0
  67. package/dist/runtime/connector-scope.js +72 -0
  68. package/dist/runtime/run.js +174 -0
  69. package/dist/runtime/services.js +130 -0
  70. package/dist/runtime/storage.js +66 -0
  71. package/dist/server.js +111 -89
  72. package/dist/skills.js +1 -1
  73. package/dist/storage/file.js +39 -2
  74. package/dist/storage/memory.js +28 -16
  75. package/dist/timeout.js +17 -47
  76. package/dist/types.d.ts +64 -7
  77. package/dist/ui.d.ts +16 -2
  78. package/dist/ui.js +68 -176
  79. package/dist/url-safety.js +157 -0
  80. package/dist/version.d.ts +1 -1
  81. package/dist/version.js +1 -1
  82. package/documentation/architecture.md +368 -182
  83. package/documentation/auth.md +277 -176
  84. package/documentation/code-mode.md +479 -329
  85. package/documentation/meta-tools.md +494 -417
  86. package/ethos.md +44 -47
  87. package/examples/worker/AGENTS.md +2 -1
  88. package/examples/worker/README.md +62 -14
  89. package/examples/worker/src/d1-storage.ts +116 -0
  90. package/examples/worker/src/index.ts +18 -21
  91. package/examples/worker/wrangler.jsonc +16 -4
  92. package/package.json +11 -4
  93. package/templates/node/.env.example +3 -3
  94. package/templates/node/AGENTS.md +5 -4
  95. package/templates/node/README.md +2 -1
  96. package/templates/node/package.json +1 -1
  97. package/templates/node/src/index.ts +23 -22
  98. package/dist/auth/downstream-oauth.d.ts +0 -200
  99. package/dist/catalog-fingerprint.d.ts +0 -19
  100. package/dist/catalog-limits.d.ts +0 -9
  101. package/dist/catalog-service.d.ts +0 -263
  102. package/dist/catalog.d.ts +0 -87
  103. package/dist/concurrency.d.ts +0 -3
  104. package/dist/connectors/guarded-fetch.d.ts +0 -92
  105. package/dist/credential-rules.d.ts +0 -85
  106. package/dist/execute.d.ts +0 -154
  107. package/dist/executor-result.d.ts +0 -10
  108. package/dist/executors/quickjs-child.d.ts +0 -1
  109. package/dist/executors/quickjs-protocol.d.ts +0 -67
  110. package/dist/favicon.d.ts +0 -1
  111. package/dist/identity.d.ts +0 -4
  112. package/dist/invocation.d.ts +0 -74
  113. package/dist/mcp-result.d.ts +0 -8
  114. package/dist/meta-tools.d.ts +0 -129
  115. package/dist/operator-ui/generated.d.ts +0 -2
  116. package/dist/operator-ui/view.d.ts +0 -151
  117. package/dist/result-shapes.d.ts +0 -13
  118. package/dist/retired-toolkits.d.ts +0 -2
  119. package/dist/routes/activity.d.ts +0 -2
  120. package/dist/routes/credentials.d.ts +0 -2
  121. package/dist/routes/mcp.d.ts +0 -10
  122. package/dist/routes/oauth-management.d.ts +0 -2
  123. package/dist/routes/oauth.d.ts +0 -2
  124. package/dist/routes/ui.d.ts +0 -2
  125. package/dist/server.d.ts +0 -9
  126. package/dist/skills.d.ts +0 -44
  127. package/dist/timeout.d.ts +0 -21
  128. package/dist/tool-safety.d.ts +0 -9
  129. package/documentation/call-admission.md +0 -158
  130. package/documentation/cloudflare.md +0 -471
  131. package/documentation/connector-guides.md +0 -176
  132. package/documentation/connectors.md +0 -431
  133. package/documentation/linear.md +0 -193
  134. package/documentation/mixpanel.md +0 -160
  135. package/documentation/notion.md +0 -308
  136. package/documentation/operations.md +0 -359
  137. package/documentation/operator-ui.md +0 -135
  138. package/documentation/optional-modules-upgrade.md +0 -243
  139. package/documentation/provider-conventions.md +0 -729
  140. package/documentation/request-admission.md +0 -204
  141. package/documentation/revenuecat.md +0 -305
  142. package/documentation/storage-and-credentials.md +0 -254
  143. package/documentation/stripe.md +0 -262
  144. package/documentation/upgrading.md +0 -768
  145. package/documentation/vercel.md +0 -241
@@ -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,58 +115,65 @@ 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) {
108
- // See documentation/connectors.md#mcp-version-skew for the legacy DELETE
109
- // that Client.close() does not send.
118
+ function terminateSession(transport, logger, connectorId) {
119
+ // SDK v2's Client.close() does not send the legacy session DELETE on our
120
+ // behalf. Connecta's own endpoint creates no protocol session, but a stateful
121
+ // legacy downstream can still issue `Mcp-Session-Id`, and every path that
122
+ // abandons one — scope teardown, credential rotation, OAuth retirement, an
123
+ // abandoned connect — owes it this best-effort, one-second DELETE.
110
124
  const terminate = transport.terminateSession;
111
125
  if (typeof terminate !== "function")
112
- return;
113
- // The SDK issues no request at all when no `mcp-session-id` was captured, so
114
- // a stateless downstream never sees a spurious DELETE.
115
- const done = Promise.resolve().then(() => terminate.call(transport));
116
- await new Promise((resolve) => {
117
- let finished = false;
118
- const warn = (message, error) => {
119
- try {
120
- if (error === undefined)
121
- logger.warn(message);
122
- else
123
- logger.warn(message, error);
124
- }
125
- catch {
126
- // A diagnostic sink cannot make best-effort teardown observable to the
127
- // caller in the one way this contract forbids: by replacing its result.
128
- }
129
- };
130
- const timer = setTimeout(() => {
131
- if (finished)
132
- return;
133
- finished = true;
134
- warn(`[connecta] connector "${connectorId}" session termination was not ` +
135
- `acknowledged within ${TERMINATE_SESSION_BUDGET_MS} ms; the ` +
136
- "downstream may still finish the headers-only DELETE, otherwise " +
137
- "the session will remain until its provider timeout.");
138
- resolve();
139
- }, TERMINATE_SESSION_BUDGET_MS);
140
- done.then(() => {
141
- if (finished)
142
- return;
143
- finished = true;
144
- clearTimeout(timer);
145
- resolve();
146
- }, (error) => {
147
- // The rejection handler stays attached after the timer wins, so an
148
- // abort or other late failure is consumed without a duplicate warning.
149
- if (finished)
150
- return;
151
- finished = true;
152
- clearTimeout(timer);
153
- warn(`[connecta] connector "${connectorId}" session termination was ` +
154
- "refused or failed; the downstream session may remain until its " +
155
- "provider timeout.", error);
156
- resolve();
157
- });
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
+ }
158
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 });
159
177
  }
160
178
  /** Classify protocol/status facts; provider prose never decides retryability. */
161
179
  function downstreamCallError(error) {
@@ -277,7 +295,8 @@ function redirectedInit(init, status) {
277
295
  * Wrap fetch with explicit, bounded redirect handling.
278
296
  *
279
297
  * The starting URL of each fetch call is trusted by its caller (the configured
280
- * 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
281
300
  * values are policy-controlled here. No rejected target is ever fetched, so
282
301
  * arbitrary static header names receive the same protection as Authorization.
283
302
  */
@@ -339,6 +358,32 @@ export function redirectSafeFetch(connectorId, policy = "none", baseFetch = fetc
339
358
  }
340
359
  };
341
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
+ }
342
387
  /**
343
388
  * Proxy a downstream remote MCP server. SDK clients and transports are scoped
344
389
  * to one inbound request: reused by calls within a batch/execute_code run, but
@@ -356,12 +401,11 @@ export function remoteMcp(id, opts) {
356
401
  "can own a different grant.");
357
402
  }
358
403
  // Weak keys ensure a completed request does not leave its SDK client,
359
- // transport, response bodies, AbortSignals, or connection promise reachable
404
+ // transport, response bodies, AbortSignals, or connect attempt reachable
360
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.
361
408
  const states = new WeakMap();
362
- // Closing is terminal even after `states.delete`: a late or future lookup
363
- // must not recreate an ownerless connection under the ended scope.
364
- const closedScopes = new WeakSet();
365
409
  const isOauth = opts.auth?.type === "oauth";
366
410
  // Long-lived enough for distinct request scopes in this connector runtime to
367
411
  // join one token redemption. It owns no client, transport, or request state.
@@ -401,8 +445,8 @@ export function remoteMcp(id, opts) {
401
445
  throw new Error(`[connecta] connector "${id}" url ${opts.url} is not https:// (and not loopback) — refusing to connect (requireHttps).`);
402
446
  }
403
447
  // Both static shapes send a secret on every request; that an operator
404
- // typed one into /credentials rather than a deployment file changes who
405
- // 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.
406
450
  if (opts.auth?.type === "headers" || credentialAuth) {
407
451
  logger.warn(`[connecta] connector "${id}" sends static credentials to ${opts.url} over a non-https:// connection — those tokens will be transmitted in cleartext.`);
408
452
  }
@@ -420,7 +464,7 @@ export function remoteMcp(id, opts) {
420
464
  * key to read it with). Deliberately the same `auth_required` code an absent
421
465
  * OAuth grant produces: the agent's next move is `authorize_connector`
422
466
  * either way, and that tool reads `connector.credential` to hand the
423
- * operator `/credentials` instead of a consent URL.
467
+ * operator the connection in the UI when available instead of a consent URL.
424
468
  */
425
469
  class CredentialRequiredError extends ConnectorCallError {
426
470
  constructor(message) {
@@ -435,8 +479,7 @@ export function remoteMcp(id, opts) {
435
479
  const readCredential = async (ctx) => {
436
480
  if (!ctx.credential) {
437
481
  throw new CredentialRequiredError(`Connector "${id}" needs an operator-managed credential, but ` +
438
- "credential storage is not configured. Set " +
439
- "credentials.encryptionKey and redeploy.");
482
+ "credential storage is not configured. Configure vault in deployment code and redeploy. Call authorize_connector for recovery options.");
440
483
  }
441
484
  // A stored-shape mismatch already arrives as a typed auth_required from
442
485
  // the registry's accessor; nothing to reclassify here.
@@ -453,8 +496,9 @@ export function remoteMcp(id, opts) {
453
496
  // to the agent. So the check lives here, and says only what is wrong.
454
497
  if (carriesIllegalHeaderChar(value)) {
455
498
  throw new CredentialRequiredError(`Connector "${id}"'s stored credential contains a character a header ` +
456
- "cannot carry (a line break or other control character) — re-enter " +
457
- "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.");
458
502
  }
459
503
  return value;
460
504
  };
@@ -479,8 +523,9 @@ export function remoteMcp(id, opts) {
479
523
  seen.add(current);
480
524
  if (quoted.some((secret) => current instanceof Error && current.message.includes(secret))) {
481
525
  return new CredentialRequiredError(`Connector "${id}" could not send its stored credential as a ` +
482
- "header — re-enter it on /credentials. The value is not shown or " +
483
- "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.");
484
529
  }
485
530
  current = current.cause;
486
531
  }
@@ -511,13 +556,14 @@ export function remoteMcp(id, opts) {
511
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 });
512
557
  }
513
558
  };
514
- const stateFor = (ctx) => {
515
- const scope = ctx.requestScope ?? ctx;
516
- if (closedScopes.has(scope))
517
- throw scopeEndedError();
518
- 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);
519
563
  if (!state) {
520
564
  state = {
565
+ scope: Scope.makeUnsafe(),
566
+ lease: null,
521
567
  client: null,
522
568
  transport: null,
523
569
  toolDefinitions: new Map(),
@@ -526,16 +572,22 @@ export function remoteMcp(id, opts) {
526
572
  provider: null,
527
573
  connectedGeneration: null,
528
574
  credentialDigest: null,
529
- closed: false,
530
575
  };
531
- states.set(scope, state);
576
+ states.set(key, state);
532
577
  }
533
578
  return state;
534
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
+ };
535
587
  const newProvider = (ctx, state) => {
536
588
  if (state?.provider)
537
589
  return state.provider;
538
- 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));
539
591
  if (state)
540
592
  state.provider = provider;
541
593
  return provider;
@@ -555,7 +607,7 @@ export function remoteMcp(id, opts) {
555
607
  const oauthProvider = provider ?? newProvider(ctx);
556
608
  return new StreamableHTTPClientTransport(url, {
557
609
  authProvider: oauthProvider,
558
- fetch: refreshCoordinator.coordinatedFetch(oauthProvider, guardedFetch, ctx.signal),
610
+ fetch: refreshCoordinator.coordinatedFetch(oauthProvider, learnedUrlSafeFetch(id, url, guardedFetch), ctx.signal),
559
611
  });
560
612
  }
561
613
  const headers = opts.auth?.type === "headers"
@@ -569,6 +621,7 @@ export function remoteMcp(id, opts) {
569
621
  });
570
622
  };
571
623
  const reset = (state) => {
624
+ state.lease = null;
572
625
  state.client = null;
573
626
  state.transport = null;
574
627
  state.toolDefinitions.clear();
@@ -577,40 +630,35 @@ export function remoteMcp(id, opts) {
577
630
  state.provider = null;
578
631
  state.connectedGeneration = null;
579
632
  state.credentialDigest = null;
580
- // `closed` is deliberately not cleared — see ConnectionState.
633
+ // `scope` is deliberately left as it is — see ConnectionState.
581
634
  };
582
635
  // The context has no deferred-work hook. Detached exits start this bounded
583
636
  // best-effort tail immediately; closeScope awaits its own tail so the core
584
- // 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.
585
639
  const closingSessions = new WeakMap();
586
- const closeConnection = (client, transport, logger) => {
640
+ const closeConnection = (client, transport, logger) => Effect.suspend(() => {
587
641
  const previous = transport && closingSessions.get(transport);
588
642
  if (previous)
589
- return previous;
590
- const closing = (async () => {
591
- try {
592
- if (transport)
593
- await terminateSession(transport, logger, id);
594
- if (client)
595
- await client.close();
596
- else
597
- await transport?.close();
598
- }
599
- catch {
600
- // Local close cannot replace the operation's result.
601
- }
602
- })();
643
+ return Deferred.await(previous);
644
+ const closed = Deferred.makeUnsafe();
603
645
  // A connect can acquire a session after an early close. Deduplicate only
604
646
  // once that session exists, so its late abandonment still sends DELETE.
605
647
  if (transport?.sessionId)
606
- closingSessions.set(transport, closing);
607
- return closing;
608
- };
609
- const closeHalf = (state, ctx) => {
610
- const client = state.client;
611
- 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;
612
658
  reset(state);
613
- void closeConnection(client, transport, ctx.logger);
659
+ const release = lease && Scope.closeUnsafe(lease, Exit.void);
660
+ if (release)
661
+ detach(release);
614
662
  };
615
663
  const ensureConnected = async (ctx, state) => {
616
664
  // A 401 after connect is a verdict for the whole request scope, not merely
@@ -628,9 +676,7 @@ export function remoteMcp(id, opts) {
628
676
  const provider = newProvider(ctx, state);
629
677
  oauthGeneration = await provider.generation();
630
678
  if (provider.isOperatorDisconnectedGeneration(oauthGeneration)) {
631
- const connecting = state.connecting;
632
- void connecting?.catch(() => { });
633
- closeHalf(state, ctx);
679
+ closeHalf(state);
634
680
  throw operatorDisconnectedError();
635
681
  }
636
682
  }
@@ -638,16 +684,16 @@ export function remoteMcp(id, opts) {
638
684
  // wiped credentials. This request's cached client still speaks the old
639
685
  // token — drop it so the next connect runs against current state.
640
686
  if (state.client && oauthGeneration !== undefined && state.connectedGeneration !== null) {
641
- if (state.closed)
687
+ if (isClosed(state))
642
688
  throw scopeEndedError();
643
689
  if (oauthGeneration !== state.connectedGeneration) {
644
- closeHalf(state, ctx);
690
+ closeHalf(state);
645
691
  }
646
692
  }
647
693
  // The static-credential counterpart of the epoch read above, and
648
694
  // deliberately beside it: the vault is read before any cached client is
649
- // trusted, so an operator's rotation on /credentials takes effect on the
650
- // 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
651
697
  // plaintext lives in this function's scope and never reaches `state`.
652
698
  let credentialValue = null;
653
699
  let credentialFramed = null;
@@ -663,134 +709,167 @@ export function remoteMcp(id, opts) {
663
709
  if ((state.client || state.connecting) &&
664
710
  state.credentialDigest !== null &&
665
711
  state.credentialDigest !== credentialDigest) {
666
- if (state.closed)
712
+ if (isClosed(state))
667
713
  throw scopeEndedError();
668
- const connecting = state.connecting;
669
- void connecting?.catch(() => { });
670
- closeHalf(state, ctx);
714
+ closeHalf(state);
671
715
  }
672
716
  }
673
- if (state.closed)
717
+ if (isClosed(state))
674
718
  throw scopeEndedError();
675
719
  if (state.client)
676
- return;
677
- if (!state.connecting) {
678
- let attempt;
679
- attempt = (async () => {
680
- const ownsAttempt = () => state.connecting === attempt && !state.closed;
681
- const abandon = (client, transport) => {
682
- void closeConnection(client, transport, ctx.logger);
683
- throw scopeEndedError();
684
- };
685
- // Let the assignment immediately below this async IIFE publish
686
- // `state.connecting = attempt` before ownership is checked. OAuth's
687
- // generation read naturally yields; unauthenticated transports do not.
688
- await Promise.resolve();
689
- // A provider belongs to exactly one connect attempt. A force reset can
690
- // abandon that attempt while its transport still holds the provider;
691
- // the replacement must never mutate the abandoned provider's epoch.
692
- const provider = isOauth ? newProvider(ctx) : null;
693
- const genAtStart = provider ? await provider.generation() : "";
694
- if (!ownsAttempt())
695
- throw scopeEndedError();
696
- if (provider?.isOperatorDisconnectedGeneration(genAtStart)) {
697
- throw operatorDisconnectedError();
698
- }
699
- provider?.captureGeneration(genAtStart);
700
- // SDK v2 selects its validator by runtime export condition: AJV on
701
- // Node and @cfworker/json-schema under workerd. The Workers-safe path
702
- // no longer needs Connecta-specific wiring.
703
- const c = new Client({ name: "connecta", version: CONNECTA_VERSION }, {
704
- versionNegotiation: {
705
- mode: opts.versionNegotiation ?? "auto",
706
- },
707
- // Connecta has no interactive relay. Surface the result manually
708
- // below as one structured, non-retryable connector failure.
709
- inputRequired: { autoFulfill: false },
710
- });
711
- const t = buildTransport(ctx, provider, credentialFramed);
712
- if (!ownsAttempt())
713
- abandon(null, t);
714
- state.transport = t;
715
- try {
716
- await c.connect(t);
717
- // A probe deadline can end its scope while connect is still in flight.
718
- // The transport is closed immediately by closeScope; if connect wins
719
- // that race anyway, close the resulting client rather than
720
- // resurrecting a session in the detached state object.
721
- if (!ownsAttempt())
722
- abandon(c, t);
723
- // A force re-auth that landed WHILE we were connecting wiped the
724
- // credentials this client just bound to. Discard it rather than
725
- // cache a stale-isolate connection.
726
- if (provider) {
727
- const generation = await provider.generation();
728
- // closeScope can land while the generation read is pending, after
729
- // connect succeeded but before this client is cached. Discard the
730
- // client on that side of the await too.
731
- if (!ownsAttempt())
732
- abandon(c, t);
733
- if (generation !== genAtStart) {
734
- void closeConnection(c, t, ctx.logger);
735
- throw new UnauthorizedError("Connector was re-authorized during connect; reconnect required.");
736
- }
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."));
737
820
  }
738
- if (!ownsAttempt())
739
- abandon(c, t);
740
- state.client = c;
741
- state.connectedGeneration = genAtStart;
742
- state.authRequired = false;
743
821
  }
744
- catch (err) {
745
- void closeConnection(c, t, ctx.logger);
746
- if (ownsAttempt())
747
- state.transport = null;
748
- // Only a real 401/UnauthorizedError means auth is the problem — a
749
- // network error on an oauth connector must surface as "error", not
750
- // "auth_required".
751
- 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)
752
837
  state.authRequired = true;
753
- }
754
- if (err instanceof UnauthorizedError) {
755
- throw authRequiredError(err);
756
- }
757
- // Defense in depth for the one error class that can quote the
758
- // credential: a runtime refusing the assembled header. `readCredential`
759
- // already rejects a value that cannot ride one, so reaching this is a
760
- // gap in that check rather than a routine outcome.
761
- throw withoutCredential(err, credentialValue, credentialFramed);
838
+ const release = Scope.closeUnsafe(lease, Exit.void);
839
+ if (release)
840
+ detach(release);
762
841
  }
763
- finally {
764
- // Force reset may have abandoned this attempt and installed a new one
765
- // in the same request scope. An old completion must not erase the new
766
- // promise and allow a third concurrent connect.
767
- if (state.connecting === attempt)
768
- 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));
769
847
  }
770
- })();
771
- state.connecting = attempt;
772
- // Published with the attempt, not with its result: a rotation that lands
773
- // while this connect is in flight has to be visible to the next caller,
774
- // which would otherwise wait on a client bound to the older key.
775
- state.credentialDigest = credentialDigest;
776
- }
777
- 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
+ });
778
859
  };
779
860
  const disconnectAuthorization = async (ctx, state, operatorDisconnected = false) => {
780
861
  const provider = newProvider(ctx, state);
781
862
  // Publish the replacement epoch before waiting on or closing any
782
863
  // request-local transport. A hung connect therefore cannot delay the
783
864
  // fence, and every late OAuth write stays in the older namespace.
784
- const connecting = state.connecting;
785
865
  try {
786
866
  await provider.resetAuthorization(operatorDisconnected);
787
867
  }
788
868
  finally {
789
- // Consume the abandoned connect and close whichever half of the
869
+ // Abandon any connect in flight and close whichever half of the
790
870
  // client/transport exists. Reset is unconditional because KV may already
791
871
  // be fenced behind a newer epoch after a cleanup error.
792
- void connecting?.catch(() => { });
793
- closeHalf(state, ctx);
872
+ closeHalf(state);
794
873
  }
795
874
  };
796
875
  const connector = {
@@ -809,8 +888,8 @@ export function remoteMcp(id, opts) {
809
888
  : {}),
810
889
  ...(opts.usageGuide !== undefined ? { usageGuide: opts.usageGuide } : {}),
811
890
  // Declaring the slot is what makes the rest of the operator surface work:
812
- // /credentials renders it, the shape check compares against it, and
813
- // 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
814
893
  // an OAuth URL this connector has none of.
815
894
  ...(credentialAuth
816
895
  ? {
@@ -867,18 +946,16 @@ export function remoteMcp(id, opts) {
867
946
  // the accumulator is returned rather than stored: a cursor is opaque and
868
947
  // session-bound, so nothing here may outlive this call.
869
948
  async listTools(ctx) {
870
- // The complete-catalog rule is documented at
871
- // documentation/connectors.md#catalog-contract.
949
+ // The catalog contract: a downstream catalog is complete or it is a
950
+ // failure. Follow every page to the end of the cursor chain, preserve
951
+ // schemas and annotations, and never cache or serve a partial walk.
872
952
  const state = stateFor(ctx);
873
- await ensureConnected(ctx, state);
874
- // Bind the client once so the whole walk provably rides one session — a
875
- // cursor is only meaningful to the connection that issued it, and a
876
- // re-read could in principle pick up a different one. It is NOT guarding
877
- // against closeScope nulling state.client mid-loop: closeScope sets
878
- // `closed` and nulls `client` in one synchronous run, and the loop
879
- // re-checks `closed` before every page, so the nulled client is
880
- // unreachable from here.
881
- 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);
882
959
  // Raw SDK tools, not ToolDefs: the metadata re-prime below needs fields
883
960
  // (task support) that a ToolDef deliberately does not carry.
884
961
  const listed = [];
@@ -892,7 +969,7 @@ export function remoteMcp(id, opts) {
892
969
  for (let page = 0; page < MAX_TOOL_PAGES; page++) {
893
970
  // The scope can end between pages (probe timeout, teardown). Stop
894
971
  // rather than keep paging into a transport that is being closed.
895
- if (state.closed)
972
+ if (isClosed(state))
896
973
  throw scopeEndedError();
897
974
  // A discovery deadline uses the same signal for the whole chain.
898
975
  // Check it before issuing each page as well as passing it to the
@@ -989,8 +1066,7 @@ export function remoteMcp(id, opts) {
989
1066
  },
990
1067
  async callTool(name, args, ctx) {
991
1068
  const state = stateFor(ctx);
992
- await ensureConnected(ctx, state);
993
- const client = state.client;
1069
+ const client = await ensureConnected(ctx, state);
994
1070
  try {
995
1071
  const toolDefinition = state.toolDefinitions.get(name);
996
1072
  if (toolDefinition?.execution?.taskSupport === "required") {
@@ -1022,20 +1098,17 @@ export function remoteMcp(id, opts) {
1022
1098
  }
1023
1099
  },
1024
1100
  async closeScope(ctx) {
1025
- const scope = ctx.requestScope ?? ctx;
1026
- // Tombstone before any lookup or await. This also makes close-before-use
1027
- // terminal rather than allowing the scope to spring into existence later.
1028
- closedScopes.add(scope);
1029
- const state = states.get(scope);
1030
- if (!state)
1031
- return;
1032
- // Delete before awaiting: a duplicate teardown is a no-op.
1033
- states.delete(scope);
1034
- state.closed = true;
1035
- const client = state.client;
1036
- 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);
1037
1108
  reset(state);
1038
- 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);
1039
1112
  },
1040
1113
  async status(ctx) {
1041
1114
  const state = stateFor(ctx);
@@ -1052,8 +1125,8 @@ export function remoteMcp(id, opts) {
1052
1125
  }
1053
1126
  if (state.authRequired) {
1054
1127
  // Only an OAuth connector has a pending consent URL to offer. A
1055
- // credential connector's downstream 401 is repaired on /credentials,
1056
- // 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.
1057
1130
  return {
1058
1131
  state: "auth_required",
1059
1132
  message: credentialAuth
@@ -1073,7 +1146,10 @@ export function remoteMcp(id, opts) {
1073
1146
  // verifyState ran on this request-scoped provider first and captured the
1074
1147
  // pending flow's generation. If force reset races the exchange, any late
1075
1148
  // token write remains tagged with that older generation and is unreadable.
1076
- 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 ??
1077
1153
  buildTransport(ctx, provider));
1078
1154
  if (callbackParams !== undefined) {
1079
1155
  await t.finishAuth(callbackParams);
@@ -1083,9 +1159,9 @@ export function remoteMcp(id, opts) {
1083
1159
  }
1084
1160
  await provider.clearPending();
1085
1161
  // Reset so the next use reconnects with the freshly stored tokens.
1086
- if (!state.transport)
1087
- state.transport = t;
1088
- closeHalf(state, ctx);
1162
+ closeHalf(state);
1163
+ if (!leased)
1164
+ detach(closeConnection(null, t, ctx.logger));
1089
1165
  },
1090
1166
  };
1091
1167
  if (opts.auth?.type === "oauth") {
@@ -1097,7 +1173,11 @@ export function remoteMcp(id, opts) {
1097
1173
  await disconnectAuthorization(ctx, stateFor(ctx), true);
1098
1174
  };
1099
1175
  connector.startAuth = async (ctx, startOpts) => {
1100
- ctx = { ...ctx, requestScope: ctx.requestScope ?? ctx, allowAuthorization: true };
1176
+ ctx = inheritOAuthSealer(ctx, {
1177
+ ...ctx,
1178
+ requestScope: ctx.requestScope ?? ctx,
1179
+ allowAuthorization: true,
1180
+ });
1101
1181
  const state = stateFor(ctx);
1102
1182
  state.provider = null;
1103
1183
  const p = newProvider(ctx, state);