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