@zackbart/connecta 0.37.1 → 0.39.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 (265) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/README.md +5 -4
  3. package/bin/connecta.mjs +1 -39
  4. package/bin/version.mjs +1 -1
  5. package/dist/access-tokens.js +2 -4
  6. package/dist/agent-identity.js +1 -1
  7. package/dist/agent-routes.js +231 -0
  8. package/dist/auth/clerk-sdk/generated.js +2 -1
  9. package/dist/auth/downstream-oauth/consent.js +519 -0
  10. package/dist/auth/downstream-oauth/grant-store.js +439 -0
  11. package/dist/auth/downstream-oauth/refresh-coordinator.js +562 -0
  12. package/dist/auth/downstream-oauth/refresh-records.js +447 -0
  13. package/dist/auth/downstream-oauth/sdk-hooks.js +242 -0
  14. package/dist/auth/downstream-oauth.js +66 -2516
  15. package/dist/auth/downstream-revocation.js +1 -1
  16. package/dist/auth/native.js +27 -0
  17. package/dist/auth/oauth-grant.js +24 -0
  18. package/dist/auth/oauth-token-response.js +1 -1
  19. package/dist/auth/static-oauth.js +153 -131
  20. package/dist/auth/vercel-agent.js +2 -1
  21. package/dist/auth-elicitation.js +9 -3
  22. package/dist/authorization-handoff.js +11 -5
  23. package/dist/branding.d.ts +1 -15
  24. package/dist/branding.js +2 -2
  25. package/dist/bytes.js +42 -0
  26. package/dist/call-admission-context.js +2 -5
  27. package/dist/call-recovery.js +0 -3
  28. package/dist/catalog-cache.js +130 -43
  29. package/dist/catalog-fingerprint.js +2 -18
  30. package/dist/catalog-policy.js +18 -0
  31. package/dist/catalog-service.js +285 -48
  32. package/dist/catalog.js +18 -2
  33. package/dist/config-defaults.d.ts +1 -0
  34. package/dist/config-defaults.js +1 -0
  35. package/dist/config-schema.d.ts +1 -1
  36. package/dist/config-schema.js +6 -2
  37. package/dist/config.d.ts +57 -25
  38. package/dist/config.js +30 -77
  39. package/dist/connector-caller.d.ts +1 -1
  40. package/dist/connector-health.js +59 -0
  41. package/dist/connector-status.js +117 -0
  42. package/dist/connectors/api-connector.d.ts +7 -2
  43. package/dist/connectors/api-connector.js +11 -7
  44. package/dist/connectors/guarded-fetch.d.ts +0 -6
  45. package/dist/connectors/guarded-fetch.js +1 -11
  46. package/dist/connectors/negotiation-cache.js +22 -4
  47. package/dist/connectors/remote-mcp/call.js +164 -0
  48. package/dist/connectors/remote-mcp/catalog.js +278 -0
  49. package/dist/connectors/remote-mcp/config.js +263 -0
  50. package/dist/connectors/remote-mcp/connection.js +671 -0
  51. package/dist/connectors/remote-mcp/errors.js +488 -0
  52. package/dist/connectors/remote-mcp/oauth.js +469 -0
  53. package/dist/connectors/remote-mcp/resources.js +473 -0
  54. package/dist/connectors/remote-mcp/runtime.js +1 -0
  55. package/dist/connectors/remote-mcp/transport.js +544 -0
  56. package/dist/connectors/remote-mcp.js +61 -2898
  57. package/dist/connectors/resource-uri.js +0 -4
  58. package/dist/credential-contract.d.ts +23 -0
  59. package/dist/credential-reads.js +17 -0
  60. package/dist/credential-verification.js +53 -0
  61. package/dist/credentials.d.ts +7 -0
  62. package/dist/credentials.js +60 -25
  63. package/dist/d1.d.ts +0 -89
  64. package/dist/d1.js +0 -34
  65. package/dist/describe-config.d.ts +12 -2
  66. package/dist/describe-config.js +2 -0
  67. package/dist/discovery-health.js +199 -0
  68. package/dist/downstream-input.js +4 -2
  69. package/dist/errors.d.ts +55 -17
  70. package/dist/errors.js +95 -41
  71. package/dist/execute.js +172 -125
  72. package/dist/executor-result.js +5 -2
  73. package/dist/executors/quickjs-runtime.js +2 -25
  74. package/dist/executors/quickjs.js +1 -1
  75. package/dist/guest-functions.js +83 -0
  76. package/dist/guest-runtime.js +63 -20
  77. package/dist/guest-types.d.ts +24 -10
  78. package/dist/http/argument-problems.js +35 -0
  79. package/dist/http/client.js +1 -1
  80. package/dist/http/errors.js +7 -6
  81. package/dist/http/fields.js +192 -13
  82. package/dist/http/hatch-response.js +73 -0
  83. package/dist/http/hatch.js +88 -23
  84. package/dist/http/json-schema.js +1 -1
  85. package/dist/http/node-transport.js +0 -1
  86. package/dist/http/oauth.js +4 -3
  87. package/dist/http/paging.js +6 -9
  88. package/dist/http/provider.js +33 -5
  89. package/dist/http/safety.js +9 -1
  90. package/dist/http/services.js +1 -8
  91. package/dist/http/tools.js +45 -30
  92. package/dist/http/vendor.js +7 -7
  93. package/dist/identity.js +2 -6
  94. package/dist/index.d.ts +1 -1
  95. package/dist/index.js +9 -2
  96. package/dist/invocation-auth.js +2 -1
  97. package/dist/invocation.js +21 -6
  98. package/dist/membership.d.ts +13 -5
  99. package/dist/membership.js +34 -2
  100. package/dist/meta-tools.js +50 -35
  101. package/dist/native-recovery.js +4 -16
  102. package/dist/native-tools.js +6 -2
  103. package/dist/oauth-handoff.js +2 -2
  104. package/dist/oauth-sealing.js +10 -0
  105. package/dist/operation-vocabulary.js +136 -14
  106. package/dist/operator-record.d.ts +2 -2
  107. package/dist/operator-record.js +2 -49
  108. package/dist/operator-ui/contract.d.ts +12 -2
  109. package/dist/operator-ui/generated.js +3 -3
  110. package/dist/operator-ui/model.d.ts +22 -1
  111. package/dist/program-writes.js +3 -8
  112. package/dist/provider-call.js +0 -7
  113. package/dist/provider.d.ts +1 -1
  114. package/dist/providers/_shared/google/kit.js +1 -6
  115. package/dist/providers/_shared/google/service-account.js +1 -3
  116. package/dist/providers/_shared/google/workspace.d.ts +1 -1
  117. package/dist/providers/_shared/guide.js +3 -0
  118. package/dist/providers/_shared/json.js +12 -0
  119. package/dist/providers/_shared/rest/compact-index.js +1 -1
  120. package/dist/providers/_shared/rest/kit.js +1 -3
  121. package/dist/providers/_shared/rest/operation-index.js +49 -53
  122. package/dist/providers/_shared/rest/value-safety.js +106 -28
  123. package/dist/providers/breeze/api.js +9 -8
  124. package/dist/providers/breeze/index.js +4 -2
  125. package/dist/providers/breeze/skill.generated.js +1 -1
  126. package/dist/providers/breeze/tools.js +7 -7
  127. package/dist/providers/ccb/api.js +3 -7
  128. package/dist/providers/ccb/index.js +7 -1
  129. package/dist/providers/ccb/skill.generated.js +1 -1
  130. package/dist/providers/ccb/tools.js +1 -6
  131. package/dist/providers/cloudflare/api.js +10 -3
  132. package/dist/providers/cloudflare/index.js +2 -1
  133. package/dist/providers/cloudflare/safety.js +3 -3
  134. package/dist/providers/cloudflare/skill.generated.js +1 -1
  135. package/dist/providers/docs/lib/schemas.js +24 -22
  136. package/dist/providers/docs/safety.js +2 -0
  137. package/dist/providers/docs/skill.generated.js +1 -1
  138. package/dist/providers/docs/tools.js +4 -14
  139. package/dist/providers/drive/api.js +1 -0
  140. package/dist/providers/drive/lib/contracts.js +8 -5
  141. package/dist/providers/drive/skill.generated.js +1 -1
  142. package/dist/providers/drive/tools.js +23 -41
  143. package/dist/providers/forms/api.js +1 -0
  144. package/dist/providers/forms/skill.generated.js +1 -1
  145. package/dist/providers/forms/tools.js +11 -16
  146. package/dist/providers/github/api.js +1 -0
  147. package/dist/providers/github/lib/discovery.js +1 -1
  148. package/dist/providers/github/skill.generated.js +1 -1
  149. package/dist/providers/github/tools.js +61 -17
  150. package/dist/providers/gmail/lib/schemas.js +60 -50
  151. package/dist/providers/gmail/skill.generated.js +1 -1
  152. package/dist/providers/gmail/tools.js +12 -26
  153. package/dist/providers/granola/index.js +2 -0
  154. package/dist/providers/granola/skill.generated.js +1 -1
  155. package/dist/providers/overflow/api.js +8 -4
  156. package/dist/providers/overflow/index.js +2 -1
  157. package/dist/providers/overflow/skill.generated.js +1 -1
  158. package/dist/providers/overflow/tools.js +3 -7
  159. package/dist/providers/planning-center/api.js +5 -2
  160. package/dist/providers/planning-center/lib/constants.d.ts +1 -1
  161. package/dist/providers/planning-center/lib/json-api.js +1 -9
  162. package/dist/providers/planning-center/skill.generated.js +1 -1
  163. package/dist/providers/planning-center/tools.js +2 -1
  164. package/dist/providers/quickbooks/safety.js +80 -39
  165. package/dist/providers/quickbooks/skill.generated.js +1 -1
  166. package/dist/providers/sheets/api.js +1 -0
  167. package/dist/providers/sheets/skill.generated.js +1 -1
  168. package/dist/providers/sheets/tools.js +14 -23
  169. package/dist/providers/slides/skill.generated.js +1 -1
  170. package/dist/providers/slides/tools.js +19 -26
  171. package/dist/providers/stripe/index.js +2 -1
  172. package/dist/providers/stripe/safety.js +2 -2
  173. package/dist/providers/stripe/skill.generated.js +1 -1
  174. package/dist/providers/tithely/api.js +5 -8
  175. package/dist/providers/tithely/index.js +3 -2
  176. package/dist/providers/tithely/skill.generated.js +1 -1
  177. package/dist/providers/tithely/tools.js +7 -5
  178. package/dist/providers/vercel/api.js +2 -7
  179. package/dist/providers/vercel/lib/pin.js +1 -1
  180. package/dist/providers/vercel/safety.js +1 -1
  181. package/dist/providers/vercel/skill.generated.js +1 -1
  182. package/dist/providers/vercel/tools.js +6 -4
  183. package/dist/redaction-marker.js +2 -2
  184. package/dist/registry.d.ts +11 -5
  185. package/dist/registry.js +46 -20
  186. package/dist/request-principal.js +1 -1
  187. package/dist/request-state.js +2 -2
  188. package/dist/routes/access-tokens.js +1 -1
  189. package/dist/routes/activity-disclosure.js +1 -1
  190. package/dist/routes/connect.js +2 -2
  191. package/dist/routes/credentials.js +130 -61
  192. package/dist/routes/dispatch.js +5 -1
  193. package/dist/routes/mcp.js +34 -17
  194. package/dist/routes/oauth-management.js +1 -1
  195. package/dist/routes/operator.js +6 -5
  196. package/dist/routes/shared.d.ts +13 -8
  197. package/dist/routes/shared.js +15 -9
  198. package/dist/routes/ui-config.js +3 -3
  199. package/dist/routes/ui-data.js +126 -20
  200. package/dist/routes/ui.js +17 -1
  201. package/dist/runtime/call-admission.js +1 -1
  202. package/dist/runtime/call.js +8 -3
  203. package/dist/runtime/native-storage.js +9 -0
  204. package/dist/runtime/request-reads.js +182 -0
  205. package/dist/runtime/request-scope.js +16 -0
  206. package/dist/runtime/settlement.js +1 -1
  207. package/dist/runtime/shared-read.js +2 -15
  208. package/dist/runtime/storage.js +12 -0
  209. package/dist/sent-secrets.d.ts +1 -2
  210. package/dist/sent-secrets.js +3 -8
  211. package/dist/server.js +4 -4
  212. package/dist/skills.js +44 -54
  213. package/dist/sqlite.d.ts +0 -19
  214. package/dist/sqlite.js +2 -83
  215. package/dist/storage/keys.js +69 -128
  216. package/dist/storage/memory.js +8 -2
  217. package/dist/storage/sql-native.js +27 -101
  218. package/dist/storage/sql.d.ts +1 -24
  219. package/dist/storage/sql.js +13 -14
  220. package/dist/storage-digest.js +32 -0
  221. package/dist/timeout.js +8 -0
  222. package/dist/tool-error.js +4 -0
  223. package/dist/types.d.ts +33 -0
  224. package/dist/url-safety.js +0 -4
  225. package/dist/usage-guide.js +18 -37
  226. package/dist/validate.d.ts +3 -0
  227. package/dist/validate.js +220 -158
  228. package/dist/version.d.ts +1 -1
  229. package/dist/version.js +1 -1
  230. package/dist/worker.js +1 -1
  231. package/dist/write-outcome.js +0 -3
  232. package/documentation/README.md +19 -55
  233. package/documentation/architecture.md +436 -515
  234. package/documentation/auth.md +229 -88
  235. package/documentation/code-mode.md +201 -94
  236. package/documentation/deploying.md +25 -43
  237. package/documentation/integrating.md +18 -19
  238. package/documentation/meta-tools.md +225 -51
  239. package/documentation/migration-0.33.md +10 -6
  240. package/documentation/migration-0.34.md +5 -5
  241. package/documentation/migration-retired-layouts.md +74 -0
  242. package/documentation/operator-tests.md +20 -120
  243. package/documentation/operator-ui.md +108 -16
  244. package/documentation/pool-membership-upgrade.md +44 -0
  245. package/documentation/provider-auth-migration.md +4 -10
  246. package/documentation/provider-migration-0.29.md +4 -15
  247. package/documentation/providers.md +105 -0
  248. package/examples/worker/AGENTS.md +14 -16
  249. package/examples/worker/README.md +24 -118
  250. package/examples/worker/src/connecta.config.ts +1 -0
  251. package/guides/connecting-agents.md +9 -31
  252. package/guides/how-it-works.md +6 -4
  253. package/guides/safety-and-trust.md +4 -1
  254. package/package.json +19 -19
  255. package/templates/node/AGENTS.md +13 -12
  256. package/templates/node/README.md +9 -26
  257. package/templates/node/package.json +1 -1
  258. package/templates/node/src/connecta.config.ts +1 -0
  259. package/dist/auth/oauth-v2-migration.js +0 -135
  260. package/dist/provider-call-facts.d.ts +0 -115
  261. package/dist/provider-call.d.ts +0 -112
  262. package/dist/storage/d1-copy.js +0 -255
  263. package/dist/storage/sql-schema.d.ts +0 -6
  264. package/examples/worker/kv-to-d1.wrangler.jsonc +0 -26
  265. package/examples/worker/scripts/copy-kv-to-d1.mjs +0 -162
@@ -0,0 +1,671 @@
1
+ // The per-request-scope connection state machine: one connect attempt per
2
+ // scope, credential and OAuth epoch checks before reuse, and a cached catalog
3
+ // served without connecting (INV-10).
4
+ import { routePhrases } from "../../agent-routes.js";
5
+ import { authView } from "../../auth/native-call.js";
6
+ import { authFlow, authCall, authInvoke, authMethod } from "../../auth/native.js";
7
+ import { openRequest, viewServices, viewSignal } from "../../runtime/call.js";
8
+ import { secretDigest } from "../../storage-digest.js";
9
+ import { downstreamClientMetadata, downstreamRedirectUri, remoteClientAuthMethod, selfHostedClientUrl, } from "../../auth/downstream-client-metadata.js";
10
+ import { Client, SdkErrorCode, SdkHttpError, UnauthorizedError } from "@modelcontextprotocol/client";
11
+ import { Deferred, Effect, Exit, Scope } from "effect";
12
+ import { KvOAuthProvider, refreshCoordinatorsByPartition } from "../../auth/downstream-oauth.js";
13
+ import { trackOAuthStartReset } from "../../auth/oauth-start-reset.js";
14
+ import { sentSecretsFor } from "../../sent-secrets.js";
15
+ import { catalogClientOptions, observeCatalogChange } from "../../catalog-cache.js";
16
+ import { ConnectorCallError, resendAdvice } from "../../errors.js";
17
+ import { carryFailureFacts, failureRecord, logFailure } from "../../operator-record.js";
18
+ import { CONNECTA_VERSION } from "../../version.js";
19
+ import { oauthSealerFor } from "../../oauth-sealing.js";
20
+ import { RequestScope } from "../../runtime/request-scope.js";
21
+ import { Context } from "effect";
22
+ import { downstreamInputCapabilities } from "../../downstream-input-context.js";
23
+ import { detach, runEdge } from "../../runtime/run.js";
24
+ import { requestPrincipalFor } from "../../request-principal.js";
25
+ import { callerOf } from "../../connector-caller.js";
26
+ import { forgetNegotiation, readNegotiation, storeNegotiation } from "../negotiation-cache.js";
27
+ import { carriesIllegalHeaderChar, credentialHeaderValue } from "./config.js";
28
+ import { AUTH_OR_THROTTLE, promised, recordWireErrors, requiresAuthorization } from "./errors.js";
29
+ import { MAX_TOOL_PAGES } from "./catalog.js";
30
+ import { SKILLS_EXTENSION } from "./resources.js";
31
+ /** One connector's connection states, credential reads and connect attempts. */
32
+ export function remoteMcpConnection(rt) {
33
+ const { requestAuth, forwarding, id, staticClient, clientMetadataUrl, opts, oauthScope, authorizationParams, destination, isOauth, extraHeaders, credentialHeader, credentialScheme, skillsEnabled, principalPolicy, credentialAuth, } = rt;
34
+ // Weak keys ensure a completed request does not leave its SDK client,
35
+ // transport, response bodies, AbortSignals, or connect attempt reachable
36
+ // from the isolate singleton. Those are request-bound in Cloudflare Workers.
37
+ // A closed scope keeps its (emptied) entry, so a late or future lookup finds
38
+ // it closed rather than recreating an ownerless connection under it.
39
+ const states = new WeakMap();
40
+ // Post-connect auth checks keep the exact provider that bound this client,
41
+ // even if another attempt has since replaced the request scope's state.
42
+ const connectedProviders = new WeakMap();
43
+ const cacheOwner = {};
44
+ const connectingWaiters = new WeakMap();
45
+ // Long-lived enough for distinct request scopes in this connector runtime to
46
+ // join one token redemption. It owns no client, transport, or request state.
47
+ const refreshCoordinatorFor = refreshCoordinatorsByPartition();
48
+ /**
49
+ * Read this request's credential. Called before every connect and before
50
+ * trusting any cached client, so an operator's replacement is picked up
51
+ * without a redeploy. The value stays in the caller's local scope.
52
+ */
53
+ const readCredential = authMethod((ctx, principal) => authFlow(function* () {
54
+ if (requestAuth) {
55
+ let value;
56
+ try {
57
+ // A fresh frozen input per resolution: one resolver cannot alter another's.
58
+ value = (yield authInvoke(forwarding ?? requestAuth, "token", principal ? [ctx, Object.freeze({ principal })] : [ctx]));
59
+ }
60
+ catch (error) {
61
+ if (error instanceof ConnectorCallError)
62
+ throw error;
63
+ throw new rt.UnresolvedTokenError();
64
+ }
65
+ ctx.signal?.throwIfAborted();
66
+ if (typeof value !== "string" ||
67
+ !value.trim() ||
68
+ Array.from(value).some((char) => {
69
+ const code = char.charCodeAt(0);
70
+ return code <= 32 || (code >= 127 && code <= 159);
71
+ })) {
72
+ throw new ConnectorCallError("auth_required", "The request token is empty or cannot be sent as a Bearer header.");
73
+ }
74
+ return value;
75
+ }
76
+ if (!ctx.credential) {
77
+ throw new rt.CredentialRequiredError(`Connector "${id}" needs an operator-managed credential, but ` +
78
+ `credential storage is not configured. Configure vault in deployment code and redeploy. ${routePhrases(ctx).authorizeFor("recovery options")}.`);
79
+ }
80
+ const value = (yield authInvoke(ctx.credential, "get", []))?.trim();
81
+ if (!value) {
82
+ throw new rt.CredentialRequiredError(`Connector "${id}" has no stored credential — ${routePhrases(ctx).authorizeAndFollow(id)}.`);
83
+ }
84
+ if (carriesIllegalHeaderChar(value)) {
85
+ throw new rt.CredentialRequiredError(`Connector "${id}"'s stored credential contains a character a header ` +
86
+ "cannot carry (a line break or other control character). " +
87
+ `${routePhrases(ctx).authorizeFor("recovery options")}. When available, re-enter ` +
88
+ "it in this connection in the operator UI. The value is not shown or logged.");
89
+ }
90
+ return value;
91
+ }));
92
+ const requestOptions = (ctx) => ctx.timeoutMs || ctx.signal
93
+ ? {
94
+ ...(ctx.timeoutMs ? { timeout: ctx.timeoutMs } : {}),
95
+ ...(ctx.signal ? { signal: ctx.signal } : {}),
96
+ }
97
+ : undefined;
98
+ /** This request scope's entry, open or closed, created on first sight. */
99
+ const entryFor = (ctx) => {
100
+ const owner = Context.get(viewServices(ctx), RequestScope);
101
+ const key = owner;
102
+ let state = states.get(key);
103
+ if (!state) {
104
+ state = {
105
+ scope: Scope.makeUnsafe(),
106
+ lease: null,
107
+ client: null,
108
+ transport: null,
109
+ toolDefinitions: new Map(),
110
+ dispatches: new Map(),
111
+ connecting: null,
112
+ authRequired: false,
113
+ provider: null,
114
+ connectedGeneration: null,
115
+ attemptProvider: null,
116
+ credentialDigest: null,
117
+ };
118
+ states.set(key, state);
119
+ const owned = state;
120
+ owner.own(() => {
121
+ const closing = Scope.closeUnsafe(owned.scope, Exit.void);
122
+ reset(owned);
123
+ if (closing)
124
+ detach(closing);
125
+ });
126
+ }
127
+ return state;
128
+ };
129
+ const isClosed = (state) => state.scope.state._tag === "Closed";
130
+ const stateFor = (ctx) => {
131
+ const state = entryFor(ctx);
132
+ if (isClosed(state))
133
+ throw rt.scopeEndedError();
134
+ return state;
135
+ };
136
+ const newProvider = (ctx, state, signal = viewSignal(ctx, true)) => {
137
+ if (state?.provider)
138
+ return state.provider;
139
+ const metadataUrl = staticClient ? undefined : (clientMetadataUrl ?? selfHostedClientUrl(ctx.publicUrl, id));
140
+ const redirectUri = downstreamRedirectUri(ctx.baseUrl, ctx.publicUrl, id);
141
+ const provider = new KvOAuthProvider(id, ctx.storage, redirectUri, refreshCoordinatorFor(ctx), ctx.allowAuthorization === true, oauthSealerFor(ctx), signal, JSON.stringify({
142
+ url: new URL(opts.url).href,
143
+ redirectUri,
144
+ clientMetadata: downstreamClientMetadata(redirectUri, oauthScope, ctx.oauthClientName, staticClient ? remoteClientAuthMethod(staticClient) : "none"),
145
+ ...(authorizationParams.length ? { authorizationParams } : {}),
146
+ authScope: opts.authScope ?? "shared",
147
+ versionNegotiation: opts.versionNegotiation ?? "auto",
148
+ redirects: opts.redirects ?? "none",
149
+ clientMetadataUrl: metadataUrl,
150
+ ...(staticClient
151
+ ? {
152
+ client: {
153
+ issuer: staticClient.issuer,
154
+ clientId: staticClient.clientId,
155
+ tokenEndpointAuthMethod: staticClient.tokenEndpointAuthMethod,
156
+ },
157
+ }
158
+ : {}),
159
+ }), (reset) => trackOAuthStartReset(ctx.requestScope ?? ctx, reset), metadataUrl, oauthScope, undefined, {
160
+ name: ctx.oauthClientName,
161
+ client: staticClient,
162
+ secrets: sentSecretsFor(ctx),
163
+ authorizationParams,
164
+ resourceOrigin: destination.origin,
165
+ });
166
+ if (state)
167
+ state.provider = provider;
168
+ return provider;
169
+ };
170
+ const reset = (state) => {
171
+ state.lease = null;
172
+ state.client = null;
173
+ state.transport = null;
174
+ state.toolDefinitions.clear();
175
+ state.connecting = null;
176
+ state.authRequired = false;
177
+ state.provider = null;
178
+ state.connectedGeneration = null;
179
+ state.credentialDigest = null;
180
+ // `scope` is deliberately left as it is — see ConnectionState.
181
+ };
182
+ /**
183
+ * Forget the live connection and close its lease without waiting. A connect
184
+ * still in flight is unpublished with it, and sees at its next check that it
185
+ * has been abandoned.
186
+ */
187
+ const closeHalf = (state) => {
188
+ const lease = state.lease;
189
+ reset(state);
190
+ const release = lease && Scope.closeUnsafe(lease, Exit.void);
191
+ if (release)
192
+ detach(release);
193
+ };
194
+ const waitForConnection = (attempt, signal) => runEdge(Deferred.await(attempt), signal ? { signal } : {});
195
+ const oauthConfigFor = (ctx) => isOauth
196
+ ? {
197
+ client: staticClient,
198
+ clientMetadataUrl: staticClient ? undefined : (clientMetadataUrl ?? selfHostedClientUrl(ctx.publicUrl, id)),
199
+ redirectUri: downstreamRedirectUri(ctx.baseUrl, ctx.publicUrl, id),
200
+ clientName: ctx.oauthClientName,
201
+ }
202
+ : undefined;
203
+ // Stored keys see a connector's configuration only through the keyed digest,
204
+ // whole: every configured URL (endpoint, client metadata document, static
205
+ // client issuer, redirect URI), header values and the static client's secret.
206
+ // The catalog cache's configuration, partition and sharing are derived the
207
+ // same way for a connect and for a pre-connect cache read, so both address
208
+ // exactly the same entries.
209
+ const catalogConfigFor = (ctx) => secretDigest(ctx, JSON.stringify({
210
+ url: new URL(opts.url).href,
211
+ auth: opts.auth?.type ?? null,
212
+ headers: opts.auth?.type === "headers" ? opts.auth.headers : requestAuth?.headers,
213
+ ...(extraHeaders ? { extraHeaders } : {}),
214
+ credentialHeader,
215
+ credentialScheme,
216
+ authScope: opts.authScope ?? "shared",
217
+ redirects: opts.redirects ?? "none",
218
+ versionNegotiation: opts.versionNegotiation ?? "auto",
219
+ oauthConfig: oauthConfigFor(ctx),
220
+ oauthScope,
221
+ }));
222
+ const catalogSharing = requestAuth || opts.authScope === "personal" ? "private" : "shared";
223
+ const catalogIdentityFor = (ctx, provider) => authMethod(() => authFlow(function* () {
224
+ const tokens = yield authInvoke(provider, "tokens", []);
225
+ return yield authCall(secretDigest, [ctx, JSON.stringify(tokens)]);
226
+ }));
227
+ // The verdict's key: everything that decides how this connector's handshake
228
+ // negotiates, through the keyed digest.
229
+ const negotiationKey = (ctx, credentialDigest, epoch) => secretDigest(ctx, JSON.stringify([
230
+ opts.url,
231
+ opts.versionNegotiation ?? "auto",
232
+ opts.auth?.type,
233
+ opts.auth?.type === "headers" ? opts.auth.headers : requestAuth?.headers,
234
+ ...(extraHeaders ? [extraHeaders] : []),
235
+ credentialHeader,
236
+ credentialScheme,
237
+ opts.authScope ?? "shared",
238
+ opts.redirects ?? "none",
239
+ oauthConfigFor(ctx),
240
+ oauthScope,
241
+ credentialDigest,
242
+ epoch,
243
+ callerOf(ctx),
244
+ skillsEnabled === true,
245
+ ]));
246
+ /**
247
+ * Serve a complete, unexpired cached catalog without connecting. The read
248
+ * derives the partition a connect would bind — the owner's live OAuth epoch
249
+ * and tokens, or the digest of this caller's credential — from storage reads
250
+ * alone: no transport, no handshake, no authorization started and no auth or
251
+ * capability state changed (INV-10). Only the catalog cache's own complete
252
+ * entries are served (INV-8); a miss, an expired or `ttlMs: 0` catalog, an
253
+ * operator-disconnected or token-less grant, or a failed cache read all
254
+ * return no tools, and the caller connects. A downstream whose stored
255
+ * negotiation verdict is modern connects without a handshake and reads the
256
+ * same cache through the SDK, so it skips this read. What was already read
257
+ * is handed to that connect, so a resolver runs once per listing.
258
+ */
259
+ const cachedCatalog = authMethod((ctx) => authFlow(function* () {
260
+ const principal = principalPolicy
261
+ ? requestPrincipalFor(principalPolicy, ctx, (reason) => new rt.CredentialRequiredError(`Connector "${id}" ${reason}.`))
262
+ : undefined;
263
+ const preread = {};
264
+ let credentialDigest = null;
265
+ if (credentialAuth || requestAuth) {
266
+ preread.credential = (yield authCall(readCredential, [ctx, principal]));
267
+ credentialDigest = (yield authCall(secretDigest, [
268
+ ctx,
269
+ principal ? JSON.stringify([principal, preread.credential]) : preread.credential,
270
+ ]));
271
+ }
272
+ try {
273
+ let epoch = "";
274
+ let identity;
275
+ if (isOauth) {
276
+ // Read-only grant snapshots: no in-place sealing, epoch binding or
277
+ // refresh. Every identity check during cache acceptance re-reads the
278
+ // grant and refuses unless both the epoch this partition was derived
279
+ // from and the token digest still hold, as a bound connect would.
280
+ const provider = newProvider(ctx);
281
+ const snapshot = (yield authInvoke(provider, "peekGrant", []));
282
+ epoch = snapshot.epoch;
283
+ if (provider.isOperatorDisconnectedEpoch(epoch) || !snapshot.tokens)
284
+ return preread;
285
+ const bound = epoch;
286
+ identity = authMethod(() => authFlow(function* () {
287
+ const current = (yield authInvoke(provider, "peekGrant", []));
288
+ // A distinct value per refusal never matches the listing's identity.
289
+ if (current.epoch !== bound || !current.tokens)
290
+ return `superseded:${crypto.randomUUID()}`;
291
+ return yield authCall(secretDigest, [ctx, JSON.stringify(current.tokens)]);
292
+ }));
293
+ }
294
+ if (opts.versionNegotiation !== "legacy") {
295
+ const digest = (yield authCall(negotiationKey, [ctx, credentialDigest, epoch]));
296
+ const prior = (yield authCall(readNegotiation, [ctx, digest]));
297
+ preread.negotiation = { digest, prior };
298
+ if (prior?.kind === "modern")
299
+ return preread;
300
+ }
301
+ const cache = (yield authCall(catalogClientOptions, [
302
+ ctx,
303
+ id,
304
+ (yield authCall(catalogConfigFor, [ctx])),
305
+ JSON.stringify([epoch, credentialDigest]),
306
+ undefined,
307
+ cacheOwner,
308
+ catalogSharing,
309
+ identity,
310
+ true,
311
+ ]));
312
+ const result = (yield authInvoke(cache, "cachedTools", [ctx]));
313
+ if (result)
314
+ return { tools: result.tools };
315
+ }
316
+ catch (error) {
317
+ if (viewSignal(ctx).aborted)
318
+ throw error;
319
+ logFailure(ctx.logger, "catalog read failed", failureRecord({ connector: id }, error));
320
+ }
321
+ return preread;
322
+ }));
323
+ const ensureConnected = authMethod((ctx, state, preread) => authFlow(function* () {
324
+ let joined = state.connecting;
325
+ if (joined)
326
+ connectingWaiters.set(joined, (connectingWaiters.get(joined) ?? 0) + 1);
327
+ const leave = () => {
328
+ if (!joined)
329
+ return;
330
+ const remaining = (connectingWaiters.get(joined) ?? 1) - 1;
331
+ if (remaining > 0)
332
+ connectingWaiters.set(joined, remaining);
333
+ else {
334
+ connectingWaiters.delete(joined);
335
+ if (ctx.signal?.aborted && state.connecting === joined)
336
+ closeHalf(state);
337
+ }
338
+ };
339
+ try {
340
+ if (state.authRequired) {
341
+ throw rt.authRequiredError(ctx);
342
+ }
343
+ let oauthGeneration;
344
+ if (isOauth && (state.client || state.connecting)) {
345
+ const provider = newProvider(ctx, state, openRequest(ctx.requestScope ?? ctx).signal);
346
+ oauthGeneration = (yield authView(ctx, authMethod(() => authInvoke(provider, "liveEpoch", []))));
347
+ if (provider.isOperatorDisconnectedEpoch(oauthGeneration)) {
348
+ closeHalf(state);
349
+ throw rt.operatorDisconnectedError();
350
+ }
351
+ }
352
+ if (state.client && oauthGeneration !== undefined && state.connectedGeneration !== null) {
353
+ if (isClosed(state))
354
+ throw rt.scopeEndedError();
355
+ if (oauthGeneration !== state.connectedGeneration) {
356
+ closeHalf(state);
357
+ }
358
+ }
359
+ let credentialValue = null;
360
+ let credentialFramed = null;
361
+ let credentialDigest = null;
362
+ // Only the admitted caller on this operation's context, never an argument.
363
+ // Refused before the resolver runs, so no token is minted for anyone else.
364
+ const principal = principalPolicy
365
+ ? requestPrincipalFor(principalPolicy, ctx, (reason) => new rt.CredentialRequiredError(`Connector "${id}" ${reason}.`))
366
+ : undefined;
367
+ if (credentialAuth || requestAuth) {
368
+ credentialValue = preread?.credential ?? (yield authCall(readCredential, [ctx, principal]));
369
+ credentialFramed = credentialHeaderValue(credentialScheme, credentialValue);
370
+ // The principal joins the client's identity: another principal never
371
+ // reuses this client, even if its resolver returns the same token.
372
+ credentialDigest = (yield authCall(secretDigest, [
373
+ ctx,
374
+ principal ? JSON.stringify([principal, credentialValue]) : credentialValue,
375
+ ]));
376
+ if ((state.client || state.connecting) &&
377
+ state.credentialDigest !== null &&
378
+ state.credentialDigest !== credentialDigest) {
379
+ const observedConnection = state.client ?? state.connecting;
380
+ const currentCredential = (yield authCall(readCredential, [ctx, principal]));
381
+ if (isClosed(state))
382
+ throw rt.scopeEndedError();
383
+ if (currentCredential !== credentialValue || (state.client ?? state.connecting) !== observedConnection) {
384
+ throw resendAdvice(new ConnectorCallError("connector_call_failed", "The downstream credential changed while connecting.", {
385
+ retryable: true,
386
+ }), "; retry the operation");
387
+ }
388
+ closeHalf(state);
389
+ }
390
+ }
391
+ viewSignal(ctx).throwIfAborted();
392
+ if (isClosed(state))
393
+ throw rt.scopeEndedError();
394
+ if (state.client && rt.eraRefused.has(state.client))
395
+ closeHalf(state);
396
+ if (state.client)
397
+ return state.client;
398
+ const attempt = state.connecting ??
399
+ startConnect(ctx, state, credentialValue, credentialFramed, credentialDigest, principal, preread?.negotiation);
400
+ if (joined !== attempt) {
401
+ leave();
402
+ joined = attempt;
403
+ connectingWaiters.set(attempt, (connectingWaiters.get(attempt) ?? 0) + 1);
404
+ }
405
+ const client = (yield authCall(waitForConnection, [attempt, ctx.signal]));
406
+ if (isClosed(state))
407
+ throw rt.scopeEndedError();
408
+ return client;
409
+ }
410
+ finally {
411
+ leave();
412
+ }
413
+ }));
414
+ /**
415
+ * Start this scope's connect attempt, publishing it before any of it runs.
416
+ * The attempt completes its Deferred itself, and every caller — the one that
417
+ * started it included — waits on that; nothing awaits the run directly,
418
+ * which is why handing it to `detach` orphans nothing.
419
+ */
420
+ const startConnect = (ctx, state, credentialValue, credentialFramed, credentialDigest, principal, negotiation) => {
421
+ const attempt = Deferred.makeUnsafe();
422
+ state.connecting = attempt;
423
+ // Published with the attempt, not with its result: a rotation that lands
424
+ // while this connect is in flight has to be visible to the next caller,
425
+ // which would otherwise wait on a client bound to the older key.
426
+ state.credentialDigest = credentialDigest;
427
+ detach(connect(ctx, state, attempt, credentialValue, credentialFramed, principal, negotiation).pipe(
428
+ // The connection belongs to this request, without the first waiter's
429
+ // call/credential/budget authority. Refresh coordinators stay separate.
430
+ Effect.provideService(RequestScope, Context.get(viewServices(ctx), RequestScope)), Effect.onExit((exit) => Effect.sync(() => {
431
+ // Force reset may have abandoned this attempt and installed a new
432
+ // one in the same request scope. An old completion must not erase
433
+ // the new attempt and allow a third concurrent connect.
434
+ if (state.connecting === attempt)
435
+ state.connecting = null;
436
+ // Last: a Deferred resumes its waiters inside this call.
437
+ Deferred.doneUnsafe(attempt, exit);
438
+ }))));
439
+ return attempt;
440
+ };
441
+ const connect = (ctx, state, attempt, credentialValue, credentialFramed, principal, negotiation) => {
442
+ // Force reset, rotation, and teardown all abandon an attempt the same way:
443
+ // they unpublish it and close the lease it holds, if it holds one yet.
444
+ const owned = () => state.connecting === attempt && !isClosed(state);
445
+ return Effect.gen(function* () {
446
+ // The transport and its OAuth provider are shared by every call in this
447
+ // scope. A call's deadline signal is aborted even on success, so retaining
448
+ // the first caller's signal here would abort its siblings and later calls.
449
+ // The provider uses the connection's lifetime. Each challenged response
450
+ // separately binds renewal preparation to the call that initiated it.
451
+ const connectionAbort = new AbortController();
452
+ // Own cancellation before beginFlow's storage reads.
453
+ // The lease first owns just the signal, then the transport it creates.
454
+ const lease = Scope.forkUnsafe(state.scope);
455
+ const handshakeAbort = new AbortController();
456
+ const held = {
457
+ client: null,
458
+ transport: null,
459
+ };
460
+ yield* Scope.addFinalizer(lease, Effect.sync(() => handshakeAbort.abort()).pipe(Effect.andThen(Effect.suspend(() => {
461
+ if (!held.transport)
462
+ connectionAbort.abort();
463
+ return rt.closeConnection(held.client, held.transport, ctx.logger);
464
+ })),
465
+ // Let session termination use the OAuth fetch wrapper before ending
466
+ // its lifetime. Local transport close aborts all active MCP fetches.
467
+ Effect.ensuring(Effect.sync(() => connectionAbort.abort()))));
468
+ state.lease = lease;
469
+ return yield* Effect.gen(function* () {
470
+ // A provider belongs to exactly one connect attempt. A force reset can
471
+ // abandon that attempt while its transport still holds the provider;
472
+ // the replacement must never mutate the abandoned provider's epoch.
473
+ const provider = isOauth ? newProvider(ctx, undefined, connectionAbort.signal) : null;
474
+ // Every OAuth run of this attempt — a 401's refresh or consent, a
475
+ // step-up — happens inside the SDK, bound to the epoch live now: a
476
+ // reset meanwhile fails the attempt rather than handing it the next
477
+ // epoch's grant.
478
+ const genAtStart = provider
479
+ ? yield* promised(authMethod(() => authInvoke(provider, "beginFlow", [])))
480
+ : "";
481
+ if (!owned())
482
+ return yield* Effect.fail(rt.scopeEndedError());
483
+ if (provider)
484
+ state.attemptProvider = provider;
485
+ if (provider?.isOperatorDisconnectedEpoch(genAtStart)) {
486
+ return yield* Effect.fail(rt.operatorDisconnectedError());
487
+ }
488
+ // SDK v2 selects its validator by runtime export condition: AJV on
489
+ // Node and @cfworker/json-schema under workerd. The Workers-safe path
490
+ // no longer needs Connecta-specific wiring.
491
+ const negotiationDigest = yield* promised(authMethod(() => authCall(negotiationKey, [ctx, state.credentialDigest, genAtStart])));
492
+ let prior = opts.versionNegotiation === "legacy"
493
+ ? undefined
494
+ : negotiation?.digest === negotiationDigest
495
+ ? negotiation.prior
496
+ : yield* promised(authMethod(() => authCall(readNegotiation, [ctx, negotiationDigest])));
497
+ if (!owned())
498
+ return yield* Effect.fail(rt.scopeEndedError());
499
+ const catalogConfig = yield* promised(authMethod(() => authCall(catalogConfigFor, [ctx])));
500
+ const cacheOptions = yield* promised(authMethod(() => authCall(catalogClientOptions, [
501
+ ctx,
502
+ id,
503
+ catalogConfig,
504
+ JSON.stringify([genAtStart, state.credentialDigest]),
505
+ connectionAbort.signal,
506
+ cacheOwner,
507
+ catalogSharing,
508
+ provider ? catalogIdentityFor(ctx, provider) : undefined,
509
+ ])));
510
+ if (!owned())
511
+ return yield* Effect.fail(rt.scopeEndedError());
512
+ const makeClient = () => {
513
+ const { intake: _intake, completeCatalogRefresh: _refresh, withListing: _listing, currentContext: _context, cachedTools: _cached, ...clientOptions } = cacheOptions;
514
+ const client = new Client({ name: "connecta", version: CONNECTA_VERSION }, {
515
+ ...clientOptions,
516
+ listMaxPages: MAX_TOOL_PAGES,
517
+ versionNegotiation: {
518
+ mode: opts.versionNegotiation ?? "auto",
519
+ },
520
+ capabilities: {
521
+ ...downstreamInputCapabilities(ctx.requestScope ?? ctx),
522
+ ...(skillsEnabled ? { extensions: { [SKILLS_EXTENSION]: {} } } : {}),
523
+ },
524
+ // The host owns each sealed continuation; never auto-retry a write.
525
+ inputRequired: { autoFulfill: false },
526
+ });
527
+ rt.installCatalogIntake(client, ctx, cacheOptions);
528
+ return client;
529
+ };
530
+ let c = makeClient();
531
+ let t = rt.buildTransport(ctx, provider, credentialFramed, connectionAbort.signal, credentialValue, false, principal);
532
+ if (!owned()) {
533
+ held.transport = t;
534
+ return yield* Effect.fail(rt.scopeEndedError());
535
+ }
536
+ state.transport = t;
537
+ held.transport = t;
538
+ recordWireErrors(t, () => observeCatalogChange(ctx, id, connectionAbort.signal, cacheOwner));
539
+ yield* promised(authMethod(() => authFlow(function* () {
540
+ try {
541
+ (yield authInvoke(c, "connect", [
542
+ t,
543
+ { signal: handshakeAbort.signal, ...(prior ? { prior } : {}) },
544
+ ]));
545
+ }
546
+ catch (error) {
547
+ // A server that stopped answering the legacy handshake its
548
+ // verdict recorded refuses it with a 4xx (a modern-only server
549
+ // names its versions). Negotiate afresh rather than wait out
550
+ // the verdict; the handshake is not a write.
551
+ const refused = prior?.kind === "legacy" &&
552
+ error instanceof SdkHttpError &&
553
+ error.status >= 400 &&
554
+ error.status < 500 &&
555
+ !AUTH_OR_THROTTLE.includes(error.status);
556
+ if (!owned() ||
557
+ handshakeAbort.signal.aborted ||
558
+ (!refused &&
559
+ (prior ||
560
+ opts.versionNegotiation === "legacy" ||
561
+ !(error instanceof SdkHttpError) ||
562
+ error.code !== SdkErrorCode.EraNegotiationFailed ||
563
+ error.status < 500 ||
564
+ error.status >= 600)))
565
+ throw error;
566
+ c = makeClient();
567
+ t = rt.buildTransport(ctx, provider, credentialFramed, connectionAbort.signal, credentialValue, false, principal);
568
+ state.transport = held.transport = t;
569
+ recordWireErrors(t, () => observeCatalogChange(ctx, id, connectionAbort.signal, cacheOwner));
570
+ if (refused)
571
+ prior = undefined;
572
+ (yield authInvoke(c, "connect", [
573
+ t,
574
+ { signal: handshakeAbort.signal, ...(refused ? {} : { prior: { kind: "legacy" } }) },
575
+ ]));
576
+ }
577
+ }))).pipe(Effect.mapError((err) => rt.atMcpBoundary(ctx,
578
+ // First, while the runtime's text can still be read for it.
579
+ rt.withoutCredential(ctx, err, credentialValue, credentialFramed), "MCP handshake", t, [ctx.signal, connectionAbort.signal, handshakeAbort.signal])));
580
+ held.client = c;
581
+ // A probe deadline can end its scope while connect is still in flight.
582
+ // The transport is closed immediately by closeScope; if connect wins
583
+ // that race anyway, close the resulting client rather than resurrecting
584
+ // a session in the detached state object.
585
+ if (!owned())
586
+ return yield* Effect.fail(rt.scopeEndedError());
587
+ // A force re-auth that landed WHILE we were connecting wiped the
588
+ // credentials this client just bound to. Discard it rather than cache
589
+ // a stale-isolate connection.
590
+ if (provider) {
591
+ const generation = yield* promised(authMethod(() => authInvoke(provider, "liveEpoch", []))).pipe(Effect.mapError((error) => (owned() ? error : rt.scopeEndedError())));
592
+ // closeScope can land while the epoch read is pending, after
593
+ // connect succeeded but before this client is cached. Discard the
594
+ // client on that side of the await too.
595
+ if (!owned())
596
+ return yield* Effect.fail(rt.scopeEndedError());
597
+ if (generation !== genAtStart) {
598
+ return yield* Effect.fail(new UnauthorizedError("Connector was re-authorized during connect; reconnect required."));
599
+ }
600
+ }
601
+ state.client = c;
602
+ if (provider)
603
+ connectedProviders.set(c, provider);
604
+ state.connectedGeneration = genAtStart;
605
+ state.authRequired = false;
606
+ const discover = c.getDiscoverResult();
607
+ // A modern connection, adopted or just negotiated, rests on the stored verdict.
608
+ const era = discover ? rt.eraRefusals.get(t) : undefined;
609
+ if (era)
610
+ era.refused = () => {
611
+ era.refused = undefined;
612
+ rt.eraRefused.add(c);
613
+ return forgetNegotiation(ctx, negotiationDigest);
614
+ };
615
+ if (!prior)
616
+ yield* promised(authMethod(() => authCall(storeNegotiation, [
617
+ ctx,
618
+ negotiationDigest,
619
+ discover ? { kind: "modern", discover } : { kind: "legacy" },
620
+ ])));
621
+ if (!owned())
622
+ return yield* Effect.fail(rt.scopeEndedError());
623
+ return c;
624
+ }).pipe(Effect.catchDefect(Effect.fail), Effect.catch((err) => {
625
+ // Close the failed connection through its client, without waiting.
626
+ if (owned()) {
627
+ state.lease = null;
628
+ state.transport = null;
629
+ // Only a real 401/UnauthorizedError means auth is the problem —
630
+ // a network error on an oauth connector must surface as "error",
631
+ // not "auth_required".
632
+ if (requiresAuthorization(err))
633
+ state.authRequired = true;
634
+ const release = Scope.closeUnsafe(lease, Exit.void);
635
+ if (release)
636
+ detach(release);
637
+ }
638
+ else {
639
+ // Whoever abandoned this attempt closed the lease already, while
640
+ // the connect was in flight. A session it acquired after that
641
+ // close still owes its DELETE.
642
+ detach(rt.closeConnection(held.client, held.transport, ctx.logger));
643
+ }
644
+ if (err instanceof UnauthorizedError) {
645
+ return Effect.fail(carryFailureFacts(err, rt.authRequiredError(ctx)));
646
+ }
647
+ // Defense in depth for the one error class that can quote the
648
+ // credential: a runtime refusing the assembled header.
649
+ // `readCredential` already rejects a value that cannot ride one, so
650
+ // reaching this is a gap in that check rather than a routine
651
+ // outcome.
652
+ return Effect.fail(rt.withoutCredential(ctx, err, credentialValue, credentialFramed));
653
+ }));
654
+ });
655
+ };
656
+ return {
657
+ entryFor,
658
+ newProvider,
659
+ isClosed,
660
+ refreshCoordinatorFor,
661
+ closeHalf,
662
+ connectedProviders,
663
+ stateFor,
664
+ ensureConnected,
665
+ requestOptions,
666
+ cachedCatalog,
667
+ cacheOwner,
668
+ reset,
669
+ states,
670
+ };
671
+ }