@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.
- package/AGENTS.md +58 -30
- package/CHANGELOG.md +317 -1
- package/README.md +5 -6
- package/dist/advertised-schema.js +53 -0
- package/dist/auth/downstream-oauth.js +490 -106
- package/dist/branding.d.ts +31 -2
- package/dist/branding.js +116 -8
- package/dist/call-admission.js +139 -113
- package/dist/catalog-service.js +141 -105
- package/dist/catalog.js +258 -33
- package/dist/concurrency.js +24 -23
- package/dist/connector-scope.js +6 -37
- package/dist/connectors/api.d.ts +3 -3
- package/dist/connectors/api.js +10 -2
- package/dist/connectors/guarded-fetch.js +34 -4
- package/dist/connectors/remote-mcp.d.ts +6 -5
- package/dist/connectors/remote-mcp.js +325 -245
- package/dist/credential-contract.d.ts +9 -0
- package/dist/credential-rules.js +2 -2
- package/dist/credentials.d.ts +6 -1
- package/dist/credentials.js +45 -3
- package/dist/errors.d.ts +11 -3
- package/dist/errors.js +2 -1
- package/dist/execute.js +363 -257
- package/dist/executor-admission.js +67 -50
- package/dist/executors/quickjs-runtime.js +4 -2
- package/dist/executors/quickjs.js +251 -210
- package/dist/fix-prompt.js +37 -0
- package/dist/index.d.ts +11 -2
- package/dist/index.js +63 -21
- package/dist/invocation.js +324 -285
- package/dist/meta-tools.js +304 -114
- package/dist/oauth-callback-outcome.js +94 -0
- package/dist/oauth-sealing.js +29 -0
- package/dist/operator-ui/fix-prompts.js +87 -0
- package/dist/operator-ui/generated.js +2 -2
- package/dist/operator-ui/model.d.ts +46 -1
- package/dist/operator-ui/setup-commands.js +53 -0
- package/dist/operator-ui/view.js +109 -2
- package/dist/program-source.js +205 -0
- package/dist/providers/cloudflare.d.ts +14 -2
- package/dist/providers/cloudflare.js +107 -16
- package/dist/providers/linear.d.ts +26 -4
- package/dist/providers/linear.js +19 -4
- package/dist/providers/mixpanel.d.ts +16 -3
- package/dist/providers/mixpanel.js +13 -2
- package/dist/providers/notion.d.ts +8 -1
- package/dist/providers/notion.js +85 -12
- package/dist/providers/revenuecat.d.ts +30 -4
- package/dist/providers/revenuecat.js +70 -5
- package/dist/providers/stripe.d.ts +10 -3
- package/dist/providers/stripe.js +30 -4
- package/dist/providers/vercel.js +12 -2
- package/dist/registry.d.ts +13 -5
- package/dist/registry.js +317 -258
- package/dist/routes/activity.js +120 -137
- package/dist/routes/credentials.js +174 -163
- package/dist/routes/mcp.js +129 -70
- package/dist/routes/oauth-management.js +60 -71
- package/dist/routes/oauth.js +44 -16
- package/dist/routes/operator.js +82 -0
- package/dist/routes/shared.d.ts +2 -0
- package/dist/routes/ui-data.js +210 -0
- package/dist/routes/ui.js +64 -32
- package/dist/runtime/admission.js +43 -0
- package/dist/runtime/call-admission.js +67 -0
- package/dist/runtime/connector-scope.js +72 -0
- package/dist/runtime/run.js +174 -0
- package/dist/runtime/services.js +130 -0
- package/dist/runtime/storage.js +66 -0
- package/dist/server.js +111 -89
- package/dist/skills.js +1 -1
- package/dist/storage/file.js +39 -2
- package/dist/storage/memory.js +28 -16
- package/dist/timeout.js +17 -47
- package/dist/types.d.ts +64 -7
- package/dist/ui.d.ts +16 -2
- package/dist/ui.js +68 -176
- package/dist/url-safety.js +157 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/documentation/architecture.md +368 -182
- package/documentation/auth.md +277 -176
- package/documentation/code-mode.md +479 -329
- package/documentation/meta-tools.md +494 -417
- package/ethos.md +44 -47
- package/examples/worker/AGENTS.md +2 -1
- package/examples/worker/README.md +62 -14
- package/examples/worker/src/d1-storage.ts +116 -0
- package/examples/worker/src/index.ts +18 -21
- package/examples/worker/wrangler.jsonc +16 -4
- package/package.json +11 -4
- package/templates/node/.env.example +3 -3
- package/templates/node/AGENTS.md +5 -4
- package/templates/node/README.md +2 -1
- package/templates/node/package.json +1 -1
- package/templates/node/src/index.ts +23 -22
- package/dist/auth/downstream-oauth.d.ts +0 -200
- package/dist/catalog-fingerprint.d.ts +0 -19
- package/dist/catalog-limits.d.ts +0 -9
- package/dist/catalog-service.d.ts +0 -263
- package/dist/catalog.d.ts +0 -87
- package/dist/concurrency.d.ts +0 -3
- package/dist/connectors/guarded-fetch.d.ts +0 -92
- package/dist/credential-rules.d.ts +0 -85
- package/dist/execute.d.ts +0 -154
- package/dist/executor-result.d.ts +0 -10
- package/dist/executors/quickjs-child.d.ts +0 -1
- package/dist/executors/quickjs-protocol.d.ts +0 -67
- package/dist/favicon.d.ts +0 -1
- package/dist/identity.d.ts +0 -4
- package/dist/invocation.d.ts +0 -74
- package/dist/mcp-result.d.ts +0 -8
- package/dist/meta-tools.d.ts +0 -129
- package/dist/operator-ui/generated.d.ts +0 -2
- package/dist/operator-ui/view.d.ts +0 -151
- package/dist/result-shapes.d.ts +0 -13
- package/dist/retired-toolkits.d.ts +0 -2
- package/dist/routes/activity.d.ts +0 -2
- package/dist/routes/credentials.d.ts +0 -2
- package/dist/routes/mcp.d.ts +0 -10
- package/dist/routes/oauth-management.d.ts +0 -2
- package/dist/routes/oauth.d.ts +0 -2
- package/dist/routes/ui.d.ts +0 -2
- package/dist/server.d.ts +0 -9
- package/dist/skills.d.ts +0 -44
- package/dist/timeout.d.ts +0 -21
- package/dist/tool-safety.d.ts +0 -9
- package/documentation/call-admission.md +0 -158
- package/documentation/cloudflare.md +0 -471
- package/documentation/connector-guides.md +0 -176
- package/documentation/connectors.md +0 -431
- package/documentation/linear.md +0 -193
- package/documentation/mixpanel.md +0 -160
- package/documentation/notion.md +0 -308
- package/documentation/operations.md +0 -359
- package/documentation/operator-ui.md +0 -135
- package/documentation/optional-modules-upgrade.md +0 -243
- package/documentation/provider-conventions.md +0 -729
- package/documentation/request-admission.md +0 -204
- package/documentation/revenuecat.md +0 -305
- package/documentation/storage-and-credentials.md +0 -254
- package/documentation/stripe.md +0 -262
- package/documentation/upgrading.md +0 -768
- 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
|
-
|
|
108
|
-
//
|
|
109
|
-
//
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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)
|
|
457
|
-
"
|
|
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
|
|
483
|
-
"
|
|
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
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
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(
|
|
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
|
-
// `
|
|
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
|
|
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,
|
|
607
|
-
return
|
|
608
|
-
};
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
687
|
+
if (isClosed(state))
|
|
642
688
|
throw scopeEndedError();
|
|
643
689
|
if (oauthGeneration !== state.connectedGeneration) {
|
|
644
|
-
closeHalf(state
|
|
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
|
|
650
|
-
//
|
|
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
|
|
712
|
+
if (isClosed(state))
|
|
667
713
|
throw scopeEndedError();
|
|
668
|
-
|
|
669
|
-
void connecting?.catch(() => { });
|
|
670
|
-
closeHalf(state, ctx);
|
|
714
|
+
closeHalf(state);
|
|
671
715
|
}
|
|
672
716
|
}
|
|
673
|
-
if (state
|
|
717
|
+
if (isClosed(state))
|
|
674
718
|
throw scopeEndedError();
|
|
675
719
|
if (state.client)
|
|
676
|
-
return;
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
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
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
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 (
|
|
755
|
-
|
|
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
|
-
|
|
764
|
-
//
|
|
765
|
-
//
|
|
766
|
-
//
|
|
767
|
-
|
|
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
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
813
|
-
//
|
|
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
|
|
871
|
-
//
|
|
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
|
-
|
|
874
|
-
//
|
|
875
|
-
//
|
|
876
|
-
//
|
|
877
|
-
//
|
|
878
|
-
|
|
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
|
|
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
|
-
|
|
1026
|
-
//
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
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 =
|
|
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);
|