@zackbart/connecta 0.38.0 → 0.40.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/CHANGELOG.md +91 -0
- package/README.md +3 -2
- package/bin/connecta.mjs +4 -8
- package/bin/version.mjs +1 -1
- package/dist/access-tokens.js +6 -4
- package/dist/agent-routes.js +231 -0
- package/dist/auth/downstream-oauth/consent.js +519 -0
- package/dist/auth/downstream-oauth/grant-store.js +439 -0
- package/dist/auth/downstream-oauth/refresh-coordinator.js +562 -0
- package/dist/auth/downstream-oauth/refresh-records.js +447 -0
- package/dist/auth/downstream-oauth/sdk-hooks.js +242 -0
- package/dist/auth/downstream-oauth.js +66 -2382
- package/dist/auth/native.js +37 -0
- package/dist/auth/oauth-grant.js +13 -0
- package/dist/auth/static-oauth.js +153 -131
- package/dist/auth-elicitation.js +7 -1
- package/dist/authorization-handoff.js +11 -5
- package/dist/catalog-cache.js +219 -59
- package/dist/catalog-policy.js +18 -0
- package/dist/catalog-service.js +284 -47
- package/dist/catalog.js +18 -2
- package/dist/config.d.ts +16 -11
- package/dist/config.js +9 -6
- package/dist/connector-caller.d.ts +1 -1
- package/dist/connector-health.js +59 -0
- package/dist/connector-status.js +117 -0
- package/dist/connectors/api-connector.d.ts +7 -2
- package/dist/connectors/api-connector.js +7 -4
- package/dist/connectors/negotiation-cache.js +22 -4
- package/dist/connectors/remote-mcp/call.js +164 -0
- package/dist/connectors/remote-mcp/catalog.js +278 -0
- package/dist/connectors/remote-mcp/config.js +263 -0
- package/dist/connectors/remote-mcp/connection.js +682 -0
- package/dist/connectors/remote-mcp/errors.js +488 -0
- package/dist/connectors/remote-mcp/oauth.js +473 -0
- package/dist/connectors/remote-mcp/resources.js +473 -0
- package/dist/connectors/remote-mcp/runtime.js +1 -0
- package/dist/connectors/remote-mcp/transport.js +555 -0
- package/dist/connectors/remote-mcp.js +62 -2863
- package/dist/credential-contract.d.ts +23 -0
- package/dist/credential-reads.js +17 -0
- package/dist/credential-verification.js +53 -0
- package/dist/credentials.d.ts +7 -0
- package/dist/credentials.js +60 -25
- package/dist/describe-config.d.ts +12 -2
- package/dist/describe-config.js +2 -0
- package/dist/discovery-health.js +199 -0
- package/dist/downstream-input.js +2 -0
- package/dist/errors.d.ts +55 -17
- package/dist/errors.js +95 -41
- package/dist/execute.js +152 -114
- package/dist/executor-result.js +3 -1
- package/dist/guest-functions.js +83 -0
- package/dist/guest-runtime.js +60 -19
- package/dist/guest-types.d.ts +19 -5
- package/dist/http/argument-problems.js +35 -0
- package/dist/http/errors.js +5 -4
- package/dist/http/fields.js +192 -13
- package/dist/http/hatch-response.js +73 -0
- package/dist/http/hatch.js +88 -23
- package/dist/http/oauth.js +3 -2
- package/dist/http/provider.js +32 -4
- package/dist/http/safety.js +8 -0
- package/dist/http/tools.js +44 -23
- package/dist/index.d.ts +1 -1
- package/dist/index.js +9 -2
- package/dist/invocation.js +26 -8
- package/dist/membership.d.ts +13 -5
- package/dist/membership.js +34 -2
- package/dist/meta-tools.js +54 -31
- package/dist/native-recovery.js +4 -16
- package/dist/native-tools.js +5 -2
- package/dist/oauth-sealing.js +10 -0
- package/dist/operation-vocabulary.js +135 -13
- package/dist/operator-record.d.ts +2 -2
- package/dist/operator-record.js +1 -0
- package/dist/operator-ui/contract.d.ts +10 -0
- package/dist/operator-ui/generated.js +2 -2
- package/dist/operator-ui/model.d.ts +22 -1
- package/dist/provider-call-facts.js +6 -2
- package/dist/providers/_shared/rest/operation-index.js +48 -50
- package/dist/providers/_shared/rest/value-safety.js +80 -0
- package/dist/providers/breeze/api.js +7 -1
- package/dist/providers/breeze/skill.generated.js +1 -1
- package/dist/providers/breeze/tools.js +4 -3
- package/dist/providers/ccb/api.js +2 -1
- package/dist/providers/ccb/index.js +5 -0
- package/dist/providers/ccb/skill.generated.js +1 -1
- package/dist/providers/cloudflare/api.js +8 -1
- package/dist/providers/cloudflare/skill.generated.js +1 -1
- package/dist/providers/docs/lib/schemas.js +24 -22
- package/dist/providers/docs/safety.js +2 -0
- package/dist/providers/docs/skill.generated.js +1 -1
- package/dist/providers/docs/tools.js +2 -4
- package/dist/providers/drive/api.js +1 -0
- package/dist/providers/drive/lib/contracts.js +8 -5
- package/dist/providers/drive/skill.generated.js +1 -1
- package/dist/providers/drive/tools.js +11 -16
- package/dist/providers/forms/api.js +1 -0
- package/dist/providers/forms/skill.generated.js +1 -1
- package/dist/providers/forms/tools.js +10 -7
- package/dist/providers/github/api.js +1 -0
- package/dist/providers/github/lib/discovery.js +1 -1
- package/dist/providers/github/skill.generated.js +1 -1
- package/dist/providers/github/tools.js +61 -17
- package/dist/providers/gmail/lib/schemas.js +60 -50
- package/dist/providers/gmail/skill.generated.js +1 -1
- package/dist/providers/gmail/tools.js +7 -5
- package/dist/providers/granola/index.js +2 -0
- package/dist/providers/granola/skill.generated.js +1 -1
- package/dist/providers/overflow/api.js +7 -1
- package/dist/providers/overflow/skill.generated.js +1 -1
- package/dist/providers/planning-center/api.js +3 -1
- package/dist/providers/planning-center/lib/constants.d.ts +1 -1
- package/dist/providers/planning-center/skill.generated.js +1 -1
- package/dist/providers/quickbooks/safety.js +80 -39
- package/dist/providers/quickbooks/skill.generated.js +1 -1
- package/dist/providers/sheets/api.js +1 -0
- package/dist/providers/sheets/skill.generated.js +1 -1
- package/dist/providers/sheets/tools.js +13 -14
- package/dist/providers/slides/skill.generated.js +1 -1
- package/dist/providers/slides/tools.js +15 -13
- package/dist/providers/stripe/skill.generated.js +1 -1
- package/dist/providers/tithely/api.js +3 -1
- package/dist/providers/tithely/skill.generated.js +1 -1
- package/dist/providers/vercel/api.js +1 -1
- package/dist/providers/vercel/skill.generated.js +1 -1
- package/dist/providers/vercel/tools.js +4 -3
- package/dist/registry.d.ts +10 -4
- package/dist/registry.js +41 -13
- package/dist/routes/credentials.js +129 -60
- package/dist/routes/dispatch.js +5 -1
- package/dist/routes/mcp.js +30 -14
- package/dist/routes/operator.js +2 -2
- package/dist/routes/shared.d.ts +6 -1
- package/dist/routes/shared.js +7 -1
- package/dist/routes/ui-config.js +2 -2
- package/dist/routes/ui-data.js +126 -20
- package/dist/routes/ui.js +16 -0
- package/dist/runtime/call.js +7 -2
- package/dist/runtime/native-storage.js +12 -0
- package/dist/runtime/request-reads.js +207 -0
- package/dist/runtime/request-scope.js +16 -0
- package/dist/runtime/storage.js +12 -0
- package/dist/skills.js +37 -20
- package/dist/storage/keys.js +53 -3
- package/dist/storage/memory.js +8 -2
- package/dist/storage/sql-native.js +53 -1
- package/dist/storage/sql.js +12 -2
- package/dist/storage-digest.js +32 -0
- package/dist/timeout.js +8 -0
- package/dist/types.d.ts +33 -0
- package/dist/usage-guide.js +16 -35
- package/dist/validate.d.ts +3 -0
- package/dist/validate.js +220 -158
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/write-outcome.js +0 -3
- package/documentation/README.md +13 -13
- package/documentation/architecture.md +385 -20
- package/documentation/auth.md +233 -64
- package/documentation/code-mode.md +122 -39
- package/documentation/deploying.md +6 -3
- package/documentation/integrating.md +16 -2
- package/documentation/meta-tools.md +240 -49
- package/documentation/migration-0.33.md +5 -1
- package/documentation/migration-0.34.md +3 -1
- package/documentation/operator-tests.md +1 -1
- package/documentation/operator-ui.md +105 -12
- package/documentation/pool-membership-upgrade.md +44 -0
- package/documentation/providers.md +105 -0
- package/examples/worker/AGENTS.md +10 -10
- package/examples/worker/README.md +4 -3
- package/examples/worker/src/connecta.config.ts +1 -0
- package/guides/connecting-agents.md +2 -1
- package/guides/how-it-works.md +4 -2
- package/package.json +1 -1
- package/templates/node/AGENTS.md +13 -12
- package/templates/node/package.json +1 -1
- package/templates/node/src/connecta.config.ts +1 -0
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
// The downstream OAuth surface: bounded SDK flows, early renewal before a
|
|
2
|
+
// call, unchallenged consent starts, and the connector's auth operations.
|
|
3
|
+
import { authView } from "../../auth/native-call.js";
|
|
4
|
+
import { authFlow, authCall, authInvoke, authMethod } from "../../auth/native.js";
|
|
5
|
+
import { viewSignal } from "../../runtime/call.js";
|
|
6
|
+
import { OAuthError, RegistrationRejectedError, extractWWWAuthenticateParams, UnauthorizedError, } from "@modelcontextprotocol/client";
|
|
7
|
+
import { Effect } from "effect";
|
|
8
|
+
import { authorizingContext, EarlyRenewals } from "../../auth/downstream-oauth.js";
|
|
9
|
+
import { byteReadResponse } from "../../byte-read-response.js";
|
|
10
|
+
import { requestSentSecrets, sentSecretsFor } from "../../sent-secrets.js";
|
|
11
|
+
import { ConnectorCallError, resendAdvice, WithheldTextError } from "../../errors.js";
|
|
12
|
+
import { attachFailureFacts, carryFailureFacts, OAUTH_ERROR_CODES } from "../../operator-record.js";
|
|
13
|
+
import { detach } from "../../runtime/run.js";
|
|
14
|
+
import { retryAfterMs } from "../../http-policy.js";
|
|
15
|
+
import { sendCredentialPost } from "../../guarded-transport.js";
|
|
16
|
+
import { UnboundOAuthFlowsError, downstreamCallError, errorKind, hasSdkPayload, ownAbortReason, requiresAuthorization, startMessage, withheldAs, } from "./errors.js";
|
|
17
|
+
import { RemoteMcpDestinationError, learnedUrlSafeFetch } from "./transport.js";
|
|
18
|
+
/**
|
|
19
|
+
* Record each OAuth request on `trail` before sending it. Pinned against
|
|
20
|
+
* `@modelcontextprotocol/client` 2.3.1: every request the SDK sends anywhere
|
|
21
|
+
* but the MCP endpoint is a discovery GET, a token request (a form carrying
|
|
22
|
+
* `grant_type`), or the JSON POST of dynamic client registration.
|
|
23
|
+
*/
|
|
24
|
+
function tracedOAuthFetch(trail, endpoint, baseFetch) {
|
|
25
|
+
return authMethod((input, init) => authFlow(function* () {
|
|
26
|
+
const url = new URL(input);
|
|
27
|
+
if (url.href === endpoint.href) {
|
|
28
|
+
trail.last = undefined;
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
const body = init?.body;
|
|
32
|
+
const step = (init?.method ?? "GET").toUpperCase() === "GET"
|
|
33
|
+
? "discovery"
|
|
34
|
+
: body instanceof URLSearchParams ||
|
|
35
|
+
(typeof body === "string" && new URLSearchParams(body).has("grant_type"))
|
|
36
|
+
? "token request"
|
|
37
|
+
: "client registration";
|
|
38
|
+
trail.last = { step, host: url.origin };
|
|
39
|
+
if (step === "client registration")
|
|
40
|
+
trail.registration = url.origin;
|
|
41
|
+
}
|
|
42
|
+
const leg = trail.last;
|
|
43
|
+
const response = (yield authCall(baseFetch, [input, init]));
|
|
44
|
+
if (leg) {
|
|
45
|
+
leg.httpStatus = response.status;
|
|
46
|
+
const wait = retryAfterMs(response.headers);
|
|
47
|
+
if (wait !== undefined)
|
|
48
|
+
leg.retryAfterMs = wait;
|
|
49
|
+
}
|
|
50
|
+
return byteReadResponse(response);
|
|
51
|
+
}));
|
|
52
|
+
}
|
|
53
|
+
function registrationErrorCode(body) {
|
|
54
|
+
try {
|
|
55
|
+
const code = JSON.parse(body)?.error;
|
|
56
|
+
return typeof code === "string" && OAUTH_ERROR_CODES.has(code) ? code : undefined;
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/** One connector's OAuth flows, renewals and authorization operations. */
|
|
63
|
+
export function remoteMcpOAuth(rt) {
|
|
64
|
+
const { destination, staticClient, id, opts } = rt;
|
|
65
|
+
// A connection opened earlier in the request may still hold a replaced
|
|
66
|
+
// token, and one that lives under a minute is due again at once: the
|
|
67
|
+
// invocation's check renews it, and `callTool` sends it while it is unexpired.
|
|
68
|
+
const earlyRenewed = new EarlyRenewals();
|
|
69
|
+
/**
|
|
70
|
+
* Tell a failure of one OAuth flow in connecta's words.
|
|
71
|
+
*
|
|
72
|
+
* The SDK builds these errors from what a downstream or its authorization
|
|
73
|
+
* server sent: a refused registration quotes the response body whole, an
|
|
74
|
+
* issuer mismatch quotes the issuers, a discovery failure the URL or the
|
|
75
|
+
* metadata it could not accept. Any of them can echo what the request
|
|
76
|
+
* carried, or text the server planted for an agent to read. What survives
|
|
77
|
+
* is the step and host of the flow's last request, the classification, and
|
|
78
|
+
* for a registration the status and a registered OAuth error code.
|
|
79
|
+
* Anything not already in connecta's words is withheld, including a store's
|
|
80
|
+
* error while the flow saves what it discovered, which can quote it.
|
|
81
|
+
*/
|
|
82
|
+
const withoutAuthorizationServerText = authMethod((ctx, err, trail, signals) => authFlow(function* () {
|
|
83
|
+
const safeOrigin = authMethod((host) => authFlow(function* () {
|
|
84
|
+
if (!host || sentSecretsFor(ctx).contains(host, true))
|
|
85
|
+
return undefined;
|
|
86
|
+
if (host === destination.origin || (staticClient && host === new URL(staticClient.issuer).origin))
|
|
87
|
+
return host;
|
|
88
|
+
try {
|
|
89
|
+
return host ===
|
|
90
|
+
(yield authInvoke(rt.newProvider(ctx), "validatedIssuerOrigin", []))
|
|
91
|
+
? host
|
|
92
|
+
: undefined;
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
}));
|
|
98
|
+
if (ownAbortReason(err, signals) && !hasSdkPayload(err))
|
|
99
|
+
return err;
|
|
100
|
+
if (err instanceof RegistrationRejectedError) {
|
|
101
|
+
const code = registrationErrorCode(err.body);
|
|
102
|
+
const origin = (yield authCall(safeOrigin, [trail.registration]));
|
|
103
|
+
const facts = {
|
|
104
|
+
step: "OAuth client registration",
|
|
105
|
+
...(origin ? { origin } : {}),
|
|
106
|
+
httpStatus: err.status,
|
|
107
|
+
...(code ? { oauthError: code } : {}),
|
|
108
|
+
};
|
|
109
|
+
return attachFailureFacts(carryFailureFacts(err, resendAdvice(withheldAs(`Connector "${id}" could not register an OAuth client with ` +
|
|
110
|
+
`${origin ?? "its authorization server"}: the ` +
|
|
111
|
+
`registration endpoint answered HTTP ${err.status}` +
|
|
112
|
+
`${code ? ` with OAuth error ${code}` : ""}. Its response is ` +
|
|
113
|
+
"withheld because it can quote the request or anything the server " +
|
|
114
|
+
"chose to add. Check the server's client registration policy.", downstreamCallError(err, undefined, trail.last?.retryAfterMs)), ", then retry authorization")), facts);
|
|
115
|
+
}
|
|
116
|
+
const leg = trail.last;
|
|
117
|
+
const origin = (yield authCall(safeOrigin, [leg?.host]));
|
|
118
|
+
const facts = leg
|
|
119
|
+
? {
|
|
120
|
+
step: `OAuth ${leg.step}`,
|
|
121
|
+
...(origin ? { origin } : {}),
|
|
122
|
+
...(leg.httpStatus ? { httpStatus: leg.httpStatus } : {}),
|
|
123
|
+
}
|
|
124
|
+
: { step: "OAuth flow" };
|
|
125
|
+
if (err instanceof UnauthorizedError || err instanceof ConnectorCallError || err instanceof WithheldTextError) {
|
|
126
|
+
return attachFailureFacts(downstreamCallError(err), facts);
|
|
127
|
+
}
|
|
128
|
+
const classified = downstreamCallError(err, leg?.httpStatus, leg?.retryAfterMs, leg?.step);
|
|
129
|
+
if (err instanceof OAuthError ||
|
|
130
|
+
(classified instanceof ConnectorCallError && classified.code === "provider_permission_denied")) {
|
|
131
|
+
return attachFailureFacts(carryFailureFacts(err, classified), facts);
|
|
132
|
+
}
|
|
133
|
+
return attachFailureFacts(carryFailureFacts(err, resendAdvice(withheldAs(`Connector "${id}" OAuth ${leg ? `${leg.step}${origin ? ` with ${origin}` : ""}` : "flow"} ` +
|
|
134
|
+
`failed${errorKind(err)}. The error is withheld because its text can ` +
|
|
135
|
+
"quote what the server sent. Check the server's OAuth metadata.", classified), ", then retry authorization")), facts);
|
|
136
|
+
}));
|
|
137
|
+
/**
|
|
138
|
+
* Run every OAuth flow the SDK starts on `transport` inside a boundary of
|
|
139
|
+
* its own: a trail only that flow's requests write to, and
|
|
140
|
+
* `withoutAuthorizationServerText` on whatever the flow throws. Calls on
|
|
141
|
+
* one client can each start a flow at once, so no flow's failure is ever
|
|
142
|
+
* told by another flow's last request.
|
|
143
|
+
*
|
|
144
|
+
* Pinned against `@modelcontextprotocol/client` 2.3.1, whose transport
|
|
145
|
+
* offers no public seam for this. A flow starts in exactly two places, each
|
|
146
|
+
* handing `auth()` the fetch to use: the adapted provider's
|
|
147
|
+
* `onUnauthorized` (a 401, at connect or on a live client), given the fetch
|
|
148
|
+
* in its context, and `_stepUpAuthorize` (a 403 `insufficient_scope`),
|
|
149
|
+
* which reads `this._fetchWithInit`. The callback's code exchange runs
|
|
150
|
+
* outside both, and the callback route repeats nothing it throws. If an
|
|
151
|
+
* upgrade moves either seam, this refuses to build the transport rather
|
|
152
|
+
* than run flows unbounded.
|
|
153
|
+
*/
|
|
154
|
+
const boundOAuthFlows = (ctx, transport, endpoint,
|
|
155
|
+
/** The request's and connection's signals: only their reasons pass as written. */
|
|
156
|
+
signals, renewalSignal) => {
|
|
157
|
+
const internals = transport;
|
|
158
|
+
const adapted = internals._authProvider;
|
|
159
|
+
const onUnauthorized = adapted?.onUnauthorized;
|
|
160
|
+
const stepUp = internals._stepUpAuthorize;
|
|
161
|
+
if (!adapted || typeof onUnauthorized !== "function" || typeof stepUp !== "function") {
|
|
162
|
+
// Its own class, so that a status record, which carries no message, says so.
|
|
163
|
+
throw new UnboundOAuthFlowsError(`[connecta] connector "${id}": the MCP client's transport no longer ` +
|
|
164
|
+
"has the OAuth flow seams this release is pinned against, so its " +
|
|
165
|
+
"flows cannot be bounded. Pin @modelcontextprotocol/client to the " +
|
|
166
|
+
"version this release ships with.");
|
|
167
|
+
}
|
|
168
|
+
const flow = authMethod((start, signal) => authFlow(function* () {
|
|
169
|
+
const trail = {};
|
|
170
|
+
try {
|
|
171
|
+
return (yield authCall(start, [
|
|
172
|
+
(fetchFn) => tracedOAuthFetch(trail, endpoint, authMethod((input, init) => authFlow(function* () {
|
|
173
|
+
rt.trackSentRequest(ctx, input, init);
|
|
174
|
+
return (yield authCall(fetchFn, [
|
|
175
|
+
input,
|
|
176
|
+
signal
|
|
177
|
+
? {
|
|
178
|
+
...init,
|
|
179
|
+
signal: init?.signal ? AbortSignal.any([signal, init.signal]) : signal,
|
|
180
|
+
}
|
|
181
|
+
: init,
|
|
182
|
+
]));
|
|
183
|
+
}))),
|
|
184
|
+
]));
|
|
185
|
+
}
|
|
186
|
+
catch (err) {
|
|
187
|
+
throw (yield authCall(withoutAuthorizationServerText, [ctx, err, trail, [...signals, signal]]));
|
|
188
|
+
}
|
|
189
|
+
}));
|
|
190
|
+
adapted.onUnauthorized = authMethod((ctx) => authCall(flow, [
|
|
191
|
+
authMethod((trace) => authFlow(function* () {
|
|
192
|
+
// This seam receives every 401, during status, starts, and live
|
|
193
|
+
// calls. Use the SDK's parser so the check covers exactly the
|
|
194
|
+
// challenged URL it would honor. The provider separately drops
|
|
195
|
+
// foreign cached resource metadata on every SDK discovery read,
|
|
196
|
+
// including callbacks and flows without a challenge.
|
|
197
|
+
const { resourceMetadataUrl } = extractWWWAuthenticateParams(ctx.response);
|
|
198
|
+
if (resourceMetadataUrl && resourceMetadataUrl.origin !== destination.origin) {
|
|
199
|
+
throw new RemoteMcpDestinationError(id, "resource metadata outside the configured resource origin is not allowed");
|
|
200
|
+
}
|
|
201
|
+
yield authInvoke(Reflect, "apply", [
|
|
202
|
+
onUnauthorized,
|
|
203
|
+
adapted,
|
|
204
|
+
[{ ...ctx, fetchFn: trace(ctx.fetchFn ?? fetch) }],
|
|
205
|
+
]);
|
|
206
|
+
})),
|
|
207
|
+
renewalSignal(ctx.response),
|
|
208
|
+
]));
|
|
209
|
+
internals._stepUpAuthorize = authMethod((challenge, retries) => authCall(flow, [
|
|
210
|
+
authMethod((trace) => authFlow(function* () {
|
|
211
|
+
const traced = trace(internals._fetchWithInit ?? fetch);
|
|
212
|
+
const self = new Proxy(transport, {
|
|
213
|
+
get: (target, key) => key === "_fetchWithInit" ? traced : Reflect.get(target, key, target),
|
|
214
|
+
set: (target, key, value) => Reflect.set(target, key, value, target),
|
|
215
|
+
});
|
|
216
|
+
return (yield authInvoke(Reflect, "apply", [stepUp, self, [challenge, retries]]));
|
|
217
|
+
})),
|
|
218
|
+
]));
|
|
219
|
+
};
|
|
220
|
+
const disconnectAuthorization = authMethod((ctx, state, operatorDisconnected = false, preserveClient = false) => authFlow(function* () {
|
|
221
|
+
const provider = rt.newProvider(ctx, state);
|
|
222
|
+
const reset = operatorDisconnected
|
|
223
|
+
? authInvoke(provider, "disconnectAuthorization", [
|
|
224
|
+
learnedUrlSafeFetch(id, new URL(opts.url), authMethod((input, init) => authFlow(function* () {
|
|
225
|
+
rt.trackSentRequest(ctx, input, init);
|
|
226
|
+
return (yield authCall(sendCredentialPost, [input, init ?? {}]));
|
|
227
|
+
}))),
|
|
228
|
+
])
|
|
229
|
+
: authInvoke(provider, "resetAuthorization", [false, preserveClient]);
|
|
230
|
+
try {
|
|
231
|
+
yield reset;
|
|
232
|
+
}
|
|
233
|
+
finally {
|
|
234
|
+
rt.closeHalf(state);
|
|
235
|
+
}
|
|
236
|
+
}));
|
|
237
|
+
/**
|
|
238
|
+
* Whether the owner's grant holds tokens, read the way the invocation gate
|
|
239
|
+
* reads it. A downstream may answer its handshake and listing without
|
|
240
|
+
* credentials, so a healthy connection proves nothing about the grant.
|
|
241
|
+
* Read after connecting: connect has bound the epoch, migrated an older
|
|
242
|
+
* layout, and refused a disconnected one within the scope's lifetime.
|
|
243
|
+
*/
|
|
244
|
+
const granted = authMethod((provider) => authFlow(function* () {
|
|
245
|
+
return Boolean(yield authInvoke(provider, "tokens", []));
|
|
246
|
+
}));
|
|
247
|
+
/**
|
|
248
|
+
* Renew `due`, an access token within a minute of its stored expiry,
|
|
249
|
+
* through `transport`'s bound 401 flow and the shared refresh coordinator.
|
|
250
|
+
* Nothing is sent to the MCP endpoint. Only calls come here: status,
|
|
251
|
+
* listing and discovery never renew (INV-10).
|
|
252
|
+
*/
|
|
253
|
+
const renewEarly = authMethod((ctx, transport, provider, due) => authFlow(function* () {
|
|
254
|
+
const renew = rt.earlyRenewals.get(transport);
|
|
255
|
+
if (!renew)
|
|
256
|
+
return;
|
|
257
|
+
try {
|
|
258
|
+
(yield authCall(renew, [due, ctx.signal]));
|
|
259
|
+
const fresh = provider.adoptedRefresh();
|
|
260
|
+
if (fresh !== undefined)
|
|
261
|
+
earlyRenewed.record(ctx.requestScope ?? ctx, due, fresh, provider.observedExpiry(fresh));
|
|
262
|
+
}
|
|
263
|
+
catch (err) {
|
|
264
|
+
if (ownAbortReason(err, [ctx.signal]) && !hasSdkPayload(err))
|
|
265
|
+
throw err;
|
|
266
|
+
throw err instanceof UnauthorizedError
|
|
267
|
+
? carryFailureFacts(err, rt.authRequiredError(ctx))
|
|
268
|
+
: downstreamCallError(err, undefined, undefined, undefined, requestSentSecrets(ctx));
|
|
269
|
+
}
|
|
270
|
+
}));
|
|
271
|
+
/**
|
|
272
|
+
* The invocation's renewal, before it enters `callTool`: a failure there is
|
|
273
|
+
* the call's authorization verdict with nothing sent, so a write never
|
|
274
|
+
* draws the 401 it could not be repeated after (INV-9). The transport is
|
|
275
|
+
* built for its flow and never started, so it holds nothing to close.
|
|
276
|
+
*/
|
|
277
|
+
const renewBeforeCall = authMethod((ctx, due) => authFlow(function* () {
|
|
278
|
+
if (opts._transportFactory)
|
|
279
|
+
return;
|
|
280
|
+
const provider = rt.newProvider(ctx);
|
|
281
|
+
(yield authInvoke(provider, "beginFlow", []));
|
|
282
|
+
(yield authCall(renewEarly, [ctx, rt.buildTransport(ctx, provider), provider, due]));
|
|
283
|
+
}));
|
|
284
|
+
/**
|
|
285
|
+
* A connection reused later in the request renews again once its token
|
|
286
|
+
* comes due, before `callTool` sends it. The grant's last read decides
|
|
287
|
+
* (`KvOAuthProvider.renewalDue`, memory only).
|
|
288
|
+
*/
|
|
289
|
+
const renewBeforeDispatch = authMethod((ctx, state, client) => authFlow(function* () {
|
|
290
|
+
const provider = rt.connectedProviders.get(client);
|
|
291
|
+
const due = provider?.renewalDue();
|
|
292
|
+
if (due === undefined || !provider || !client.transport)
|
|
293
|
+
return;
|
|
294
|
+
if (earlyRenewed.settled(ctx.requestScope ?? ctx, due))
|
|
295
|
+
return;
|
|
296
|
+
try {
|
|
297
|
+
(yield authCall(renewEarly, [ctx, client.transport, provider, due]));
|
|
298
|
+
}
|
|
299
|
+
catch (err) {
|
|
300
|
+
if (requiresAuthorization(err) && state.client === client)
|
|
301
|
+
state.authRequired = true;
|
|
302
|
+
throw err;
|
|
303
|
+
}
|
|
304
|
+
}));
|
|
305
|
+
/**
|
|
306
|
+
* Start consent for a downstream that answered without challenging. The
|
|
307
|
+
* transport's bound 401 seam runs the SDK flow exactly as a challenge
|
|
308
|
+
* without `resource_metadata` would: RFC 9728 discovery at the resource's
|
|
309
|
+
* well-known URLs, the same issuer and resource checks, consent state, and
|
|
310
|
+
* CIMD/DCR/static client order. Nothing is sent to the MCP endpoint.
|
|
311
|
+
*/
|
|
312
|
+
const startUnchallenged = authMethod((ctx, connectedProvider) => authFlow(function* () {
|
|
313
|
+
const required = {
|
|
314
|
+
state: "auth_required",
|
|
315
|
+
message: "Authorization required \u2014 open the URL to connect.",
|
|
316
|
+
};
|
|
317
|
+
// A test transport carries no OAuth flow to start.
|
|
318
|
+
if (opts._transportFactory)
|
|
319
|
+
return required;
|
|
320
|
+
// A cached connection may belong to a passive status or call. Give this
|
|
321
|
+
// explicit start its own authorization-capable provider without widening
|
|
322
|
+
// the transport's authority or adopting a Restart's replacement epoch.
|
|
323
|
+
const epoch = (yield authInvoke(connectedProvider, "flowEpoch", []));
|
|
324
|
+
const provider = rt.newProvider(ctx);
|
|
325
|
+
(yield authInvoke(provider, "beginFlow", [epoch]));
|
|
326
|
+
// Never started, this transport holds no session or stream to close.
|
|
327
|
+
const transport = rt.buildTransport(ctx, provider);
|
|
328
|
+
const internals = transport;
|
|
329
|
+
try {
|
|
330
|
+
(yield authInvoke(internals._authProvider, "onUnauthorized", [
|
|
331
|
+
{
|
|
332
|
+
response: new Response(null, { status: 401 }),
|
|
333
|
+
serverUrl: new URL(opts.url),
|
|
334
|
+
fetchFn: internals._fetchWithInit,
|
|
335
|
+
},
|
|
336
|
+
]));
|
|
337
|
+
// Authorized without consent: nothing is pending to hand back.
|
|
338
|
+
return { state: "ok", message: "Already authorized \u2014 connection is healthy." };
|
|
339
|
+
}
|
|
340
|
+
catch (err) {
|
|
341
|
+
if (!(err instanceof UnauthorizedError))
|
|
342
|
+
throw err;
|
|
343
|
+
}
|
|
344
|
+
const authorizationUrl = (yield authInvoke(provider, "consentUrl", [viewSignal(ctx, true)]));
|
|
345
|
+
return { ...required, ...(authorizationUrl !== undefined ? { authorizationUrl } : {}) };
|
|
346
|
+
}));
|
|
347
|
+
const oauthSurface = (connector) => {
|
|
348
|
+
connector.finishAuth = authMethod((code, ctx, callbackParams) => authView(ctx, authMethod((signal) => authFlow(function* () {
|
|
349
|
+
const state = rt.stateFor(ctx);
|
|
350
|
+
const provider = rt.newProvider(ctx, state);
|
|
351
|
+
const consent = callbackParams?.get("state") ?? null;
|
|
352
|
+
if (!consent || !(yield authInvoke(provider, "verifyCallbackState", [consent]))) {
|
|
353
|
+
throw new ConnectorCallError("connector_call_failed", `Connector "${id}" authorization callback matches no pending consent; nothing was exchanged.`);
|
|
354
|
+
}
|
|
355
|
+
provider.validateCallbackIssuer(callbackParams?.get("iss") ?? null);
|
|
356
|
+
(yield authInvoke(provider, "bindFlow", []));
|
|
357
|
+
signal.throwIfAborted();
|
|
358
|
+
const t = rt.buildTransport(ctx, provider, null, signal, null, true);
|
|
359
|
+
const trail = {};
|
|
360
|
+
const internals = t;
|
|
361
|
+
const traced = tracedOAuthFetch(trail, new URL(opts.url), internals._fetchWithInit ?? fetch);
|
|
362
|
+
const exchange = new Proxy(t, {
|
|
363
|
+
get: (target, key) => key === "_fetchWithInit" ? traced : Reflect.get(target, key, target),
|
|
364
|
+
set: (target, key, value) => Reflect.set(target, key, value, target),
|
|
365
|
+
});
|
|
366
|
+
try {
|
|
367
|
+
(yield authInvoke(exchange, "finishAuth", [callbackParams]));
|
|
368
|
+
rt.closeHalf(state);
|
|
369
|
+
}
|
|
370
|
+
catch (err) {
|
|
371
|
+
throw (yield authCall(withoutAuthorizationServerText, [
|
|
372
|
+
ctx,
|
|
373
|
+
err,
|
|
374
|
+
trail,
|
|
375
|
+
[signal, ctx.signal],
|
|
376
|
+
]));
|
|
377
|
+
}
|
|
378
|
+
finally {
|
|
379
|
+
detach(rt.closeConnection(null, t, ctx.logger));
|
|
380
|
+
}
|
|
381
|
+
})), true));
|
|
382
|
+
connector.verifyState = authMethod((oauthState, ctx) => authFlow(function* () {
|
|
383
|
+
const state = rt.stateFor(ctx);
|
|
384
|
+
return (yield authInvoke(rt.newProvider(ctx, state), "verifyState", [oauthState]));
|
|
385
|
+
}));
|
|
386
|
+
connector.verifyCallbackIssuer = authMethod((issuer, ctx) => Effect.try({
|
|
387
|
+
try: () => {
|
|
388
|
+
rt.newProvider(ctx, rt.stateFor(ctx)).validateCallbackIssuer(issuer);
|
|
389
|
+
return true;
|
|
390
|
+
},
|
|
391
|
+
catch: (error) => error,
|
|
392
|
+
}));
|
|
393
|
+
connector.consumeAuthError = authMethod((ctx) => authFlow(function* () {
|
|
394
|
+
(yield authInvoke(rt.newProvider(ctx, rt.stateFor(ctx)), "consumeAuthError", []));
|
|
395
|
+
}));
|
|
396
|
+
connector.disconnectAuth = authMethod((ctx) => authFlow(function* () {
|
|
397
|
+
(yield authCall(disconnectAuthorization, [ctx, rt.stateFor(ctx), true]));
|
|
398
|
+
}));
|
|
399
|
+
connector.startAuth = authMethod((ctx, startOpts) => authFlow(function* () {
|
|
400
|
+
ctx = authorizingContext(ctx);
|
|
401
|
+
const state = rt.stateFor(ctx);
|
|
402
|
+
// A passive tokenless handshake was refused locally. An explicit
|
|
403
|
+
// start gets its own challenge-capable connection attempt.
|
|
404
|
+
if (state.authRequired && !state.client)
|
|
405
|
+
rt.closeHalf(state);
|
|
406
|
+
state.provider = null;
|
|
407
|
+
const p = rt.newProvider(ctx, state);
|
|
408
|
+
if (ctx.signal?.aborted)
|
|
409
|
+
throw ctx.signal.reason;
|
|
410
|
+
const disconnected = startOpts?.force
|
|
411
|
+
? false
|
|
412
|
+
: (yield authInvoke(p, "operatorDisconnected", []));
|
|
413
|
+
if (ctx.signal?.aborted)
|
|
414
|
+
throw ctx.signal.reason;
|
|
415
|
+
if (startOpts?.force || disconnected) {
|
|
416
|
+
(yield authCall(disconnectAuthorization, [ctx, state, false, startOpts?.force === true]));
|
|
417
|
+
}
|
|
418
|
+
else {
|
|
419
|
+
const pending = (yield authInvoke(p, "reusablePendingAuthorizationUrl", []));
|
|
420
|
+
if (pending) {
|
|
421
|
+
return {
|
|
422
|
+
state: "auth_required",
|
|
423
|
+
authorizationUrl: pending,
|
|
424
|
+
authorizationReused: true,
|
|
425
|
+
message: "Authorization required \u2014 open the URL to connect.",
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
if (ctx.signal?.aborted)
|
|
430
|
+
throw ctx.signal.reason;
|
|
431
|
+
try {
|
|
432
|
+
// A passive call's refusal stays latched for status and calls. A
|
|
433
|
+
// successful connection with no tokens still permits explicit
|
|
434
|
+
// consent, bound to that connection rather than the live grant.
|
|
435
|
+
if (state.authRequired && state.client) {
|
|
436
|
+
const provider = rt.connectedProviders.get(state.client);
|
|
437
|
+
if (!(yield authCall(granted, [provider])))
|
|
438
|
+
return (yield authCall(startUnchallenged, [ctx, provider]));
|
|
439
|
+
}
|
|
440
|
+
// The handshake runs first, so a 401 challenge's
|
|
441
|
+
// `resource_metadata` starts consent as it always has.
|
|
442
|
+
const client = (yield authCall(rt.ensureConnected, [ctx, state]));
|
|
443
|
+
const provider = rt.connectedProviders.get(client);
|
|
444
|
+
if (!(yield authCall(granted, [provider])))
|
|
445
|
+
return (yield authCall(startUnchallenged, [ctx, provider]));
|
|
446
|
+
return {
|
|
447
|
+
state: "ok",
|
|
448
|
+
message: "Already authorized \u2014 connection is healthy.",
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
catch (err) {
|
|
452
|
+
if (state.authRequired && requiresAuthorization(err)) {
|
|
453
|
+
let authorizationUrl;
|
|
454
|
+
try {
|
|
455
|
+
authorizationUrl = (yield state.attemptProvider
|
|
456
|
+
? authInvoke(state.attemptProvider, "consentUrl", [viewSignal(ctx, true)])
|
|
457
|
+
: Effect.succeed(undefined));
|
|
458
|
+
}
|
|
459
|
+
catch (readErr) {
|
|
460
|
+
return { state: "error", message: startMessage(readErr) };
|
|
461
|
+
}
|
|
462
|
+
return {
|
|
463
|
+
state: "auth_required",
|
|
464
|
+
...(authorizationUrl !== undefined ? { authorizationUrl } : {}),
|
|
465
|
+
message: "Authorization required \u2014 open the URL to connect.",
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
return { state: "error", message: startMessage(err) };
|
|
469
|
+
}
|
|
470
|
+
}));
|
|
471
|
+
};
|
|
472
|
+
return { boundOAuthFlows, renewBeforeDispatch, granted, oauthSurface, renewBeforeCall };
|
|
473
|
+
}
|