@zackbart/connecta 0.28.0 → 0.28.1
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 +173 -0
- package/README.md +8 -5
- package/dist/artifacts/connector.js +1 -1
- package/dist/auth/downstream-oauth.js +147 -28
- package/dist/auth/static-oauth.js +504 -0
- package/dist/connectors/api-connector.d.ts +168 -0
- package/dist/connectors/api-connector.js +127 -0
- package/dist/connectors/api.d.ts +8 -71
- package/dist/connectors/api.js +10 -99
- package/dist/connectors/guarded-fetch.js +37 -3
- package/dist/connectors/remote-mcp.js +7 -32
- package/dist/index.d.ts +1 -1
- package/dist/oauth-partition.js +17 -0
- package/dist/providers/basecamp.d.ts +65 -0
- package/dist/providers/basecamp.js +445 -0
- package/dist/providers/breeze.d.ts +34 -0
- package/dist/providers/breeze.js +1402 -0
- package/dist/providers/ccb.d.ts +103 -0
- package/dist/providers/ccb.js +1570 -0
- package/dist/providers/cloudflare.js +1 -1
- package/dist/providers/linear.js +1 -1
- package/dist/providers/mixpanel.js +1 -1
- package/dist/providers/notion.js +1 -1
- package/dist/providers/overflow.d.ts +60 -0
- package/dist/providers/overflow.js +1572 -0
- package/dist/providers/planning-center.d.ts +59 -0
- package/dist/providers/planning-center.js +2039 -0
- package/dist/providers/revenuecat.js +1 -1
- package/dist/providers/stripe.js +1 -1
- package/dist/providers/tithely.d.ts +49 -0
- package/dist/providers/tithely.js +1034 -0
- package/dist/providers/vercel.js +1 -1
- package/dist/routes/oauth.js +29 -3
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/documentation/architecture.md +5 -3
- package/documentation/auth.md +120 -6
- package/ethos.md +3 -3
- package/package.json +25 -1
- package/templates/node/package.json +1 -1
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
import { auth, OAuthError, UnauthorizedError } from "@modelcontextprotocol/client";
|
|
2
|
+
import { redirectSafeFetch } from "../connectors/remote-mcp.js";
|
|
3
|
+
import { ConnectorCallError, msg } from "../errors.js";
|
|
4
|
+
import { oauthPartitionFor, retainOAuthPartition, retainingOAuthPartition, } from "../oauth-partition.js";
|
|
5
|
+
import { oauthSealerFor } from "../oauth-sealing.js";
|
|
6
|
+
import { assertOAuthScope, authorizingContext, KvOAuthProvider, LEGACY_GENERATION, refreshCoordinatorsByPartition, } from "./downstream-oauth.js";
|
|
7
|
+
import { trackOAuthStartReset } from "./oauth-start-reset.js";
|
|
8
|
+
/**
|
|
9
|
+
* Downstream OAuth for a hand-written `api()` connector: the authorization
|
|
10
|
+
* code grant against endpoints and a pre-registered client the deployment
|
|
11
|
+
* declares, rather than ones a server advertises.
|
|
12
|
+
*
|
|
13
|
+
* Everything that makes the `remoteMcp()` grant safe is reused, not copied.
|
|
14
|
+
* `StaticOAuthProvider` is a `KvOAuthProvider` — the same epochs, sealing,
|
|
15
|
+
* owner partitions, generation fence, reset lineage, and refresh-failure
|
|
16
|
+
* verdicts — whose discovery state and client are constants, and the SDK's
|
|
17
|
+
* own `auth()` drives it through the same `OAuthRefreshCoordinator`. What a
|
|
18
|
+
* static configuration subtracts is the network learning: no RFC 9728 or 8414
|
|
19
|
+
* discovery and no dynamic registration, so no URL here was taught to
|
|
20
|
+
* connecta by a downstream.
|
|
21
|
+
*/
|
|
22
|
+
/** Parameters the grant itself owns; a deployment may not restate them. */
|
|
23
|
+
const RESERVED_AUTHORIZATION_PARAMS = new Set([
|
|
24
|
+
"response_type",
|
|
25
|
+
"client_id",
|
|
26
|
+
"redirect_uri",
|
|
27
|
+
"state",
|
|
28
|
+
"scope",
|
|
29
|
+
"code_challenge",
|
|
30
|
+
"code_challenge_method",
|
|
31
|
+
"resource",
|
|
32
|
+
]);
|
|
33
|
+
/** Token-request headers the grant owns: client authentication and framing. */
|
|
34
|
+
const RESERVED_TOKEN_HEADERS = new Set([
|
|
35
|
+
"authorization",
|
|
36
|
+
"content-type",
|
|
37
|
+
"content-length",
|
|
38
|
+
"cookie",
|
|
39
|
+
"host",
|
|
40
|
+
]);
|
|
41
|
+
/** An RFC 9110 field-name token. */
|
|
42
|
+
const HEADER_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
|
43
|
+
const CLIENT_AUTHENTICATION = new Set([
|
|
44
|
+
"client_secret_basic",
|
|
45
|
+
"client_secret_post",
|
|
46
|
+
"none",
|
|
47
|
+
]);
|
|
48
|
+
function isLoopback(hostname) {
|
|
49
|
+
return (hostname === "localhost" ||
|
|
50
|
+
hostname === "127.0.0.1" ||
|
|
51
|
+
hostname === "[::1]");
|
|
52
|
+
}
|
|
53
|
+
/** An absolute HTTPS URL (or HTTP on loopback), without credentials or fragment. */
|
|
54
|
+
function configuredUrl(id, option, value) {
|
|
55
|
+
let url;
|
|
56
|
+
try {
|
|
57
|
+
url = new URL(String(value));
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// Reported below without echoing the value.
|
|
61
|
+
}
|
|
62
|
+
if (!url ||
|
|
63
|
+
!(url.protocol === "https:" || (url.protocol === "http:" && isLoopback(url.hostname))) ||
|
|
64
|
+
url.username ||
|
|
65
|
+
url.password ||
|
|
66
|
+
url.hash) {
|
|
67
|
+
throw new Error(`[connecta] connector "${id}" oauth.${option} must be an absolute https URL ` +
|
|
68
|
+
"(http only on loopback), without credentials or a fragment.");
|
|
69
|
+
}
|
|
70
|
+
return url;
|
|
71
|
+
}
|
|
72
|
+
/** Structural mistakes throw here, where they are written. */
|
|
73
|
+
function settingsFor(id, config) {
|
|
74
|
+
if (!config || typeof config !== "object") {
|
|
75
|
+
throw new Error(`[connecta] connector "${id}" oauth must be an object.`);
|
|
76
|
+
}
|
|
77
|
+
const authorizationEndpoint = configuredUrl(id, "authorizationEndpoint", config.authorizationEndpoint);
|
|
78
|
+
const tokenEndpoint = configuredUrl(id, "tokenEndpoint", config.tokenEndpoint);
|
|
79
|
+
if (tokenEndpoint.search) {
|
|
80
|
+
throw new Error(`[connecta] connector "${id}" oauth.tokenEndpoint must not carry a query.`);
|
|
81
|
+
}
|
|
82
|
+
if (typeof config.clientId !== "string" || config.clientId.trim() === "") {
|
|
83
|
+
throw new Error(`[connecta] connector "${id}" oauth.clientId must be a non-empty string.`);
|
|
84
|
+
}
|
|
85
|
+
if (config.clientSecret !== undefined &&
|
|
86
|
+
(typeof config.clientSecret !== "string" || config.clientSecret === "")) {
|
|
87
|
+
// Usually an unset environment variable read as "". Never echo it.
|
|
88
|
+
throw new Error(`[connecta] connector "${id}" oauth.clientSecret must be a non-empty string when set.`);
|
|
89
|
+
}
|
|
90
|
+
const clientAuthentication = config.tokenEndpointAuthMethod ??
|
|
91
|
+
(config.clientSecret !== undefined ? "client_secret_basic" : "none");
|
|
92
|
+
if (!CLIENT_AUTHENTICATION.has(clientAuthentication)) {
|
|
93
|
+
throw new Error(`[connecta] connector "${id}" oauth.tokenEndpointAuthMethod must be ` +
|
|
94
|
+
'"client_secret_basic", "client_secret_post", or "none".');
|
|
95
|
+
}
|
|
96
|
+
if (clientAuthentication === "none" && config.clientSecret !== undefined) {
|
|
97
|
+
throw new Error(`[connecta] connector "${id}" oauth declares a clientSecret with ` +
|
|
98
|
+
'tokenEndpointAuthMethod "none"; a public client sends no secret.');
|
|
99
|
+
}
|
|
100
|
+
if (clientAuthentication !== "none" && config.clientSecret === undefined) {
|
|
101
|
+
throw new Error(`[connecta] connector "${id}" oauth.tokenEndpointAuthMethod ` +
|
|
102
|
+
`"${clientAuthentication}" needs a clientSecret.`);
|
|
103
|
+
}
|
|
104
|
+
assertOAuthScope(id, config.scope);
|
|
105
|
+
const authorizationParams = [];
|
|
106
|
+
for (const [name, value] of Object.entries(config.authorizationParams ?? {})) {
|
|
107
|
+
if (RESERVED_AUTHORIZATION_PARAMS.has(name)) {
|
|
108
|
+
throw new Error(`[connecta] connector "${id}" oauth.authorizationParams may not set ` +
|
|
109
|
+
`"${name}"; the authorization request owns it.`);
|
|
110
|
+
}
|
|
111
|
+
if (typeof value !== "string") {
|
|
112
|
+
throw new Error(`[connecta] connector "${id}" oauth.authorizationParams.${name} must be a string.`);
|
|
113
|
+
}
|
|
114
|
+
authorizationParams.push([name, value]);
|
|
115
|
+
}
|
|
116
|
+
const tokenRequestHeaders = [];
|
|
117
|
+
for (const [name, value] of Object.entries(config.tokenRequestHeaders ?? {})) {
|
|
118
|
+
if (!HEADER_NAME.test(name)) {
|
|
119
|
+
throw new Error(`[connecta] connector "${id}" oauth.tokenRequestHeaders has an invalid header name.`);
|
|
120
|
+
}
|
|
121
|
+
if (RESERVED_TOKEN_HEADERS.has(name.toLowerCase())) {
|
|
122
|
+
throw new Error(`[connecta] connector "${id}" oauth.tokenRequestHeaders may not set ` +
|
|
123
|
+
`"${name}"; the token request owns it.`);
|
|
124
|
+
}
|
|
125
|
+
if (typeof value !== "string" || value.includes("\r") || value.includes("\n") || value.includes("\0")) {
|
|
126
|
+
throw new Error(`[connecta] connector "${id}" oauth.tokenRequestHeaders.${name} must be a single-line string.`);
|
|
127
|
+
}
|
|
128
|
+
tokenRequestHeaders.push([name, value]);
|
|
129
|
+
}
|
|
130
|
+
const origins = Array.isArray(config.apiOrigins) ? config.apiOrigins : [];
|
|
131
|
+
if (origins.length === 0) {
|
|
132
|
+
throw new Error(`[connecta] connector "${id}" oauth.apiOrigins must name at least one ` +
|
|
133
|
+
"origin; ctx.oauth.fetch sends the access token nowhere else.");
|
|
134
|
+
}
|
|
135
|
+
const apiOrigins = new Set();
|
|
136
|
+
for (const origin of origins) {
|
|
137
|
+
const url = configuredUrl(id, "apiOrigins", origin);
|
|
138
|
+
if (url.origin !== origin) {
|
|
139
|
+
throw new Error(`[connecta] connector "${id}" oauth.apiOrigins entries must be exact ` +
|
|
140
|
+
'origins, like "https://api.example.com", with no path or trailing slash.');
|
|
141
|
+
}
|
|
142
|
+
apiOrigins.add(url.origin);
|
|
143
|
+
}
|
|
144
|
+
return {
|
|
145
|
+
authorizationEndpoint,
|
|
146
|
+
tokenEndpoint,
|
|
147
|
+
clientId: config.clientId,
|
|
148
|
+
...(config.clientSecret !== undefined ? { clientSecret: config.clientSecret } : {}),
|
|
149
|
+
clientAuthentication,
|
|
150
|
+
...(config.scope !== undefined ? { scope: config.scope } : {}),
|
|
151
|
+
pkce: config.pkce ?? true,
|
|
152
|
+
authorizationParams,
|
|
153
|
+
tokenRequestHeaders,
|
|
154
|
+
apiOrigins,
|
|
155
|
+
serverUrl: new URL(origins[0]),
|
|
156
|
+
identity: tokenEndpoint.href,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* A `KvOAuthProvider` whose discovery and client are constants. Storage,
|
|
161
|
+
* sealing, epochs, refresh coordination, and the authorization hooks are the
|
|
162
|
+
* base class's, unchanged; only what discovery and registration would have
|
|
163
|
+
* written is answered from configuration and never written.
|
|
164
|
+
*/
|
|
165
|
+
class StaticOAuthProvider extends KvOAuthProvider {
|
|
166
|
+
settings;
|
|
167
|
+
/** Set only for the code exchange of a PKCE-less flow. */
|
|
168
|
+
exchangeCode;
|
|
169
|
+
constructor(connectorId, ctx, coordinator, settings) {
|
|
170
|
+
super(connectorId, ctx.storage, `${ctx.baseUrl}/oauth/callback/${connectorId}`, coordinator, ctx.allowAuthorization === true, oauthSealerFor(ctx), ctx.signal,
|
|
171
|
+
// No client binding: the client is configuration, so there is no
|
|
172
|
+
// registration for a restart to carry forward or discard.
|
|
173
|
+
undefined, (reset) => trackOAuthStartReset(ctx.requestScope ?? ctx, reset), undefined, settings.scope);
|
|
174
|
+
this.settings = settings;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* The pre-registered client, answered from configuration. Its secret is
|
|
178
|
+
* never written to storage, sealed or otherwise: it is deployment config,
|
|
179
|
+
* not grant state, and an operator disconnect cannot delete it.
|
|
180
|
+
*/
|
|
181
|
+
async clientInformation() {
|
|
182
|
+
const { settings } = this;
|
|
183
|
+
return {
|
|
184
|
+
client_id: settings.clientId,
|
|
185
|
+
...(settings.clientSecret !== undefined
|
|
186
|
+
? { client_secret: settings.clientSecret }
|
|
187
|
+
: {}),
|
|
188
|
+
redirect_uris: [this.redirectUrl],
|
|
189
|
+
token_endpoint_auth_method: settings.clientAuthentication,
|
|
190
|
+
issuer: settings.identity,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
async saveClientInformation() {
|
|
194
|
+
// Nothing to save: the SDK only saves a client it registered or stamped.
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Discovery as a constant. Supplying both halves keeps the SDK from
|
|
198
|
+
* fetching either: no protected-resource probe of the API origin, and no
|
|
199
|
+
* metadata request to an authorization server that may not publish any.
|
|
200
|
+
* The metadata names no `issuer`, so the SDK binds grants to the configured
|
|
201
|
+
* token endpoint and does not demand an RFC 9207 `iss` it cannot know.
|
|
202
|
+
*/
|
|
203
|
+
async discoveryState() {
|
|
204
|
+
const { settings } = this;
|
|
205
|
+
const metadata = {
|
|
206
|
+
authorization_endpoint: settings.authorizationEndpoint.href,
|
|
207
|
+
token_endpoint: settings.tokenEndpoint.href,
|
|
208
|
+
response_types_supported: ["code"],
|
|
209
|
+
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
210
|
+
code_challenge_methods_supported: ["S256"],
|
|
211
|
+
token_endpoint_auth_methods_supported: [settings.clientAuthentication],
|
|
212
|
+
};
|
|
213
|
+
return {
|
|
214
|
+
authorizationServerUrl: settings.identity,
|
|
215
|
+
authorizationServerMetadata: metadata,
|
|
216
|
+
resourceMetadata: { resource: settings.serverUrl.origin },
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
async saveDiscoveryState() {
|
|
220
|
+
// Configuration is the discovery; there is nothing to remember.
|
|
221
|
+
}
|
|
222
|
+
/** No RFC 8707 `resource` parameter: a plain REST API names no resource. */
|
|
223
|
+
async validateResourceURL() {
|
|
224
|
+
return undefined;
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* The code exchange without `code_verifier`, for a flow whose authorization
|
|
228
|
+
* request carried no challenge. Undefined everywhere else, which hands the
|
|
229
|
+
* SDK back its own request.
|
|
230
|
+
*/
|
|
231
|
+
prepareTokenRequest() {
|
|
232
|
+
if (this.settings.pkce || this.exchangeCode === undefined)
|
|
233
|
+
return undefined;
|
|
234
|
+
return new URLSearchParams({
|
|
235
|
+
grant_type: "authorization_code",
|
|
236
|
+
code: this.exchangeCode,
|
|
237
|
+
redirect_uri: this.redirectUrl,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
/** @internal Remember the code a PKCE-less exchange sends. */
|
|
241
|
+
exchanging(code) {
|
|
242
|
+
this.exchangeCode = code;
|
|
243
|
+
}
|
|
244
|
+
async redirectToAuthorization(authorizationUrl) {
|
|
245
|
+
for (const [name, value] of this.settings.authorizationParams) {
|
|
246
|
+
authorizationUrl.searchParams.set(name, value);
|
|
247
|
+
}
|
|
248
|
+
if (!this.settings.pkce) {
|
|
249
|
+
authorizationUrl.searchParams.delete("code_challenge");
|
|
250
|
+
authorizationUrl.searchParams.delete("code_challenge_method");
|
|
251
|
+
}
|
|
252
|
+
await super.redirectToAuthorization(authorizationUrl);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
const AUTH_REQUIRED_MESSAGE = "Authorization required — open the URL to connect.";
|
|
256
|
+
/** The grant behind one `api()` connector's `oauth`, as the hooks it exposes. */
|
|
257
|
+
export function staticOAuth(id, config) {
|
|
258
|
+
const settings = settingsFor(id, config);
|
|
259
|
+
const issuerContext = { issuer: settings.identity };
|
|
260
|
+
const coordinatorFor = refreshCoordinatorsByPartition();
|
|
261
|
+
// Keyed by request scope, so nothing here outlives the request that set it.
|
|
262
|
+
const callbackProviders = new WeakMap();
|
|
263
|
+
const rejectedScopes = new WeakSet();
|
|
264
|
+
const scopeOf = (ctx) => ctx.requestScope ?? ctx;
|
|
265
|
+
const providerFor = (ctx) => new StaticOAuthProvider(id, ctx, coordinatorFor(ctx), settings);
|
|
266
|
+
/**
|
|
267
|
+
* The global `fetch`, read per request, with the configured token-request
|
|
268
|
+
* headers laid over the SDK's own. With discovery and registration answered
|
|
269
|
+
* from configuration the token endpoint is the only URL the SDK fetches here;
|
|
270
|
+
* the check keeps the headers on it even if that ever changes.
|
|
271
|
+
*/
|
|
272
|
+
const tokenEndpointFetch = (input, init = {}) => {
|
|
273
|
+
const url = new URL(input);
|
|
274
|
+
if (settings.tokenRequestHeaders.length === 0 ||
|
|
275
|
+
`${url.origin}${url.pathname}` !== settings.identity) {
|
|
276
|
+
return fetch(input, init);
|
|
277
|
+
}
|
|
278
|
+
const headers = new Headers(init.headers);
|
|
279
|
+
for (const [name, value] of settings.tokenRequestHeaders)
|
|
280
|
+
headers.set(name, value);
|
|
281
|
+
return fetch(input, { ...init, headers });
|
|
282
|
+
};
|
|
283
|
+
/**
|
|
284
|
+
* The SDK's `auth()` over this provider. The token endpoint is fetched
|
|
285
|
+
* through the refresh coordinator — rotation, coalescing, and dead-versus-
|
|
286
|
+
* outage verdicts — above a fetch that refuses every redirect, as the
|
|
287
|
+
* `remoteMcp()` default does.
|
|
288
|
+
*/
|
|
289
|
+
const runAuth = (provider, ctx, exchange) => auth(provider, {
|
|
290
|
+
serverUrl: settings.serverUrl,
|
|
291
|
+
...exchange,
|
|
292
|
+
fetchFn: coordinatorFor(ctx).coordinatedFetch(provider, redirectSafeFetch(id, "none", tokenEndpointFetch), ctx.signal),
|
|
293
|
+
});
|
|
294
|
+
const authRequiredError = (cause) => new ConnectorCallError("auth_required", `Connector "${id}" requires authorization — call authorize_connector({ connector: "${id}" }) and open the returned URL.`, cause !== undefined ? { cause } : undefined);
|
|
295
|
+
const disconnectedMessage = `Connector "${id}" was disconnected by an operator — explicitly start authorization to reconnect it.`;
|
|
296
|
+
/**
|
|
297
|
+
* One request through the owner's grant. A 401 earns exactly one recovery:
|
|
298
|
+
* a token another request has already rotated in, or else one coordinated
|
|
299
|
+
* refresh. A second 401 is `auth_required`, latched for the request scope
|
|
300
|
+
* so a program's later calls do not each spend a refresh on it.
|
|
301
|
+
*/
|
|
302
|
+
const authorizedFetch = async (ctx, input, init = {}) => {
|
|
303
|
+
const url = new URL(input);
|
|
304
|
+
if (!settings.apiOrigins.has(url.origin)) {
|
|
305
|
+
throw new ConnectorCallError("connector_call_failed", `Connector "${id}" sends its OAuth access token only to its declared ` +
|
|
306
|
+
`apiOrigins, and ${url.origin} is not one of them.`, { retryable: false });
|
|
307
|
+
}
|
|
308
|
+
if (init.body instanceof ReadableStream) {
|
|
309
|
+
throw new TypeError(`Connector "${id}": ctx.oauth.fetch may replay a request once after a ` +
|
|
310
|
+
"refresh, so it cannot send a stream body. Buffer the body first.");
|
|
311
|
+
}
|
|
312
|
+
const headers = new Headers(init.headers);
|
|
313
|
+
if (headers.has("authorization")) {
|
|
314
|
+
throw new ConnectorCallError("connector_call_failed", `Connector "${id}": a request through ctx.oauth.fetch may not set ` +
|
|
315
|
+
"Authorization; the connector's grant owns it.", { retryable: false });
|
|
316
|
+
}
|
|
317
|
+
const signal = init.signal && ctx.signal
|
|
318
|
+
? AbortSignal.any([init.signal, ctx.signal])
|
|
319
|
+
: (init.signal ?? ctx.signal);
|
|
320
|
+
const send = (accessToken) => {
|
|
321
|
+
const sent = new Headers(headers);
|
|
322
|
+
sent.set("Authorization", `Bearer ${accessToken}`);
|
|
323
|
+
// The token rides to a declared origin and no further: a redirect is
|
|
324
|
+
// handed back to the handler unfollowed rather than re-sent anywhere.
|
|
325
|
+
return fetch(url, {
|
|
326
|
+
...init,
|
|
327
|
+
headers: sent,
|
|
328
|
+
redirect: "manual",
|
|
329
|
+
...(signal ? { signal } : {}),
|
|
330
|
+
});
|
|
331
|
+
};
|
|
332
|
+
if (rejectedScopes.has(scopeOf(ctx)))
|
|
333
|
+
throw authRequiredError();
|
|
334
|
+
const release = retainOAuthPartition(oauthPartitionFor(ctx));
|
|
335
|
+
try {
|
|
336
|
+
if (signal?.aborted)
|
|
337
|
+
throw signal.reason;
|
|
338
|
+
const provider = providerFor(ctx);
|
|
339
|
+
const generation = await provider.generation();
|
|
340
|
+
if (provider.isOperatorDisconnectedGeneration(generation)) {
|
|
341
|
+
throw new ConnectorCallError("auth_required", disconnectedMessage);
|
|
342
|
+
}
|
|
343
|
+
// A refresh this call triggers writes into the epoch it started under,
|
|
344
|
+
// so a reset landing meanwhile leaves its rotation unreadable.
|
|
345
|
+
provider.captureGeneration(generation);
|
|
346
|
+
const tokens = await provider.tokens(issuerContext);
|
|
347
|
+
if (!tokens)
|
|
348
|
+
throw authRequiredError();
|
|
349
|
+
const first = await send(tokens.access_token);
|
|
350
|
+
if (first.status !== 401)
|
|
351
|
+
return first;
|
|
352
|
+
void first.body?.cancel().catch(() => { });
|
|
353
|
+
let accessToken;
|
|
354
|
+
const current = await provider.tokens(issuerContext);
|
|
355
|
+
if (current && current.access_token !== tokens.access_token) {
|
|
356
|
+
accessToken = current.access_token;
|
|
357
|
+
}
|
|
358
|
+
else {
|
|
359
|
+
if ((await runAuth(provider, ctx)) !== "AUTHORIZED")
|
|
360
|
+
throw authRequiredError();
|
|
361
|
+
const refreshed = await provider.tokens(issuerContext);
|
|
362
|
+
if (!refreshed)
|
|
363
|
+
throw authRequiredError();
|
|
364
|
+
accessToken = refreshed.access_token;
|
|
365
|
+
}
|
|
366
|
+
const second = await send(accessToken);
|
|
367
|
+
if (second.status !== 401)
|
|
368
|
+
return second;
|
|
369
|
+
void second.body?.cancel().catch(() => { });
|
|
370
|
+
rejectedScopes.add(scopeOf(ctx));
|
|
371
|
+
throw authRequiredError();
|
|
372
|
+
}
|
|
373
|
+
catch (error) {
|
|
374
|
+
if (error instanceof UnauthorizedError) {
|
|
375
|
+
rejectedScopes.add(scopeOf(ctx));
|
|
376
|
+
throw authRequiredError(error);
|
|
377
|
+
}
|
|
378
|
+
// The coordinator's answer while another request is still committing
|
|
379
|
+
// a rotation it redeemed. The SDK rethrows it untouched; it is a
|
|
380
|
+
// moment, not a verdict on the grant.
|
|
381
|
+
if (error instanceof OAuthError && error.code === "temporarily_unavailable") {
|
|
382
|
+
throw new ConnectorCallError("unavailable", `Connector "${id}" could not refresh its OAuth grant while another ` +
|
|
383
|
+
"request was saving one. The grant is kept; retry.", { cause: error });
|
|
384
|
+
}
|
|
385
|
+
throw error;
|
|
386
|
+
}
|
|
387
|
+
finally {
|
|
388
|
+
release();
|
|
389
|
+
}
|
|
390
|
+
};
|
|
391
|
+
const status = async (ctx) => {
|
|
392
|
+
if (rejectedScopes.has(scopeOf(ctx))) {
|
|
393
|
+
return {
|
|
394
|
+
state: "auth_required",
|
|
395
|
+
message: "Authorization required — the downstream rejected this connector's OAuth grant.",
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
// A status read never starts authorization: this provider cannot, and
|
|
399
|
+
// nothing here calls the SDK. A stored grant is healthy until a call
|
|
400
|
+
// says otherwise — the downstream is asked nothing.
|
|
401
|
+
const provider = providerFor(ctx);
|
|
402
|
+
if (await provider.operatorDisconnected()) {
|
|
403
|
+
return { state: "auth_required", message: disconnectedMessage };
|
|
404
|
+
}
|
|
405
|
+
return (await provider.tokens())
|
|
406
|
+
? { state: "ok" }
|
|
407
|
+
: { state: "auth_required", message: AUTH_REQUIRED_MESSAGE };
|
|
408
|
+
};
|
|
409
|
+
const startAuth = async (original, opts) => {
|
|
410
|
+
const ctx = authorizingContext(original);
|
|
411
|
+
const provider = providerFor(ctx);
|
|
412
|
+
if (ctx.signal?.aborted)
|
|
413
|
+
throw ctx.signal.reason;
|
|
414
|
+
const disconnected = opts?.force ? false : await provider.operatorDisconnected();
|
|
415
|
+
if (ctx.signal?.aborted)
|
|
416
|
+
throw ctx.signal.reason;
|
|
417
|
+
if (opts?.force || disconnected) {
|
|
418
|
+
await provider.resetAuthorization();
|
|
419
|
+
rejectedScopes.delete(scopeOf(original));
|
|
420
|
+
}
|
|
421
|
+
else {
|
|
422
|
+
// Hand back a recent consent URL rather than overwrite the verifier
|
|
423
|
+
// the operator may be mid-consent on.
|
|
424
|
+
const pending = await provider.reusablePendingAuthorizationUrl();
|
|
425
|
+
if (pending) {
|
|
426
|
+
return {
|
|
427
|
+
state: "auth_required",
|
|
428
|
+
authorizationUrl: pending,
|
|
429
|
+
authorizationReused: true,
|
|
430
|
+
message: AUTH_REQUIRED_MESSAGE,
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
if (ctx.signal?.aborted)
|
|
435
|
+
throw ctx.signal.reason;
|
|
436
|
+
try {
|
|
437
|
+
let generation = await provider.generation();
|
|
438
|
+
// Born modern. The legacy generation exists so a `remoteMcp()` grant
|
|
439
|
+
// from before epochs survives an upgrade; this connector has no such
|
|
440
|
+
// past, and a flow started there writes an untimed pending URL that a
|
|
441
|
+
// second Connect could not hand back.
|
|
442
|
+
if (generation === LEGACY_GENERATION)
|
|
443
|
+
generation = await provider.bumpGeneration();
|
|
444
|
+
provider.captureGeneration(generation);
|
|
445
|
+
// An issuer-bound read: a grant from a since-repointed token endpoint
|
|
446
|
+
// is fenced here, before it could be reported healthy.
|
|
447
|
+
if (await provider.tokens(issuerContext)) {
|
|
448
|
+
return { state: "ok", message: "Already authorized — connection is healthy." };
|
|
449
|
+
}
|
|
450
|
+
if ((await runAuth(provider, ctx)) === "AUTHORIZED") {
|
|
451
|
+
return { state: "ok", message: "Already authorized — connection is healthy." };
|
|
452
|
+
}
|
|
453
|
+
const authorizationUrl = await provider.pendingAuthorizationUrl();
|
|
454
|
+
return {
|
|
455
|
+
state: "auth_required",
|
|
456
|
+
...(authorizationUrl !== undefined ? { authorizationUrl } : {}),
|
|
457
|
+
message: AUTH_REQUIRED_MESSAGE,
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
catch (error) {
|
|
461
|
+
if (error instanceof UnauthorizedError) {
|
|
462
|
+
return { state: "auth_required", message: AUTH_REQUIRED_MESSAGE };
|
|
463
|
+
}
|
|
464
|
+
return { state: "error", message: msg(error) };
|
|
465
|
+
}
|
|
466
|
+
};
|
|
467
|
+
const disconnectAuth = async (ctx) => {
|
|
468
|
+
rejectedScopes.delete(scopeOf(ctx));
|
|
469
|
+
await providerFor(ctx).resetAuthorization(true);
|
|
470
|
+
};
|
|
471
|
+
const verifyState = async (state, ctx) => {
|
|
472
|
+
// The provider that verified the state captured its flow's epoch, and the
|
|
473
|
+
// exchange must write under exactly that one.
|
|
474
|
+
const provider = providerFor(ctx);
|
|
475
|
+
callbackProviders.set(scopeOf(ctx), provider);
|
|
476
|
+
return provider.verifyState(state);
|
|
477
|
+
};
|
|
478
|
+
const finishAuth = async (code, ctx, callbackParams) => {
|
|
479
|
+
const provider = callbackProviders.get(scopeOf(ctx)) ?? providerFor(ctx);
|
|
480
|
+
callbackProviders.delete(scopeOf(ctx));
|
|
481
|
+
const authorizationCode = callbackParams?.get("code") ?? code;
|
|
482
|
+
const iss = callbackParams?.get("iss") ?? undefined;
|
|
483
|
+
provider.exchanging(authorizationCode);
|
|
484
|
+
const result = await runAuth(provider, ctx, {
|
|
485
|
+
authorizationCode,
|
|
486
|
+
...(iss !== undefined ? { iss } : {}),
|
|
487
|
+
});
|
|
488
|
+
if (result !== "AUTHORIZED") {
|
|
489
|
+
throw new UnauthorizedError("Failed to authorize");
|
|
490
|
+
}
|
|
491
|
+
await provider.clearPending();
|
|
492
|
+
rejectedScopes.delete(scopeOf(ctx));
|
|
493
|
+
};
|
|
494
|
+
return {
|
|
495
|
+
status: retainingOAuthPartition(status, 0),
|
|
496
|
+
startAuth: retainingOAuthPartition(startAuth, 0),
|
|
497
|
+
disconnectAuth: retainingOAuthPartition(disconnectAuth, 0),
|
|
498
|
+
verifyState: retainingOAuthPartition(verifyState, 1),
|
|
499
|
+
finishAuth: retainingOAuthPartition(finishAuth, 1),
|
|
500
|
+
access: (ctx) => ({
|
|
501
|
+
fetch: (input, init) => authorizedFetch(ctx, input, init),
|
|
502
|
+
}),
|
|
503
|
+
};
|
|
504
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import type { Connector, ConnectorCallAdmissionPolicy, ConnectorCredentialConfig, ConnectorCredentialValues, ConnectorContext, ConnectorStatus, ConnectorUsageGuide, CredentialTestResult, JsonSchema, ToolAnnotations } from "../types.js";
|
|
2
|
+
export declare function defined<T extends object>(value: T): {
|
|
3
|
+
[K in keyof T]?: Exclude<T[K], undefined>;
|
|
4
|
+
};
|
|
5
|
+
export interface ApiTool {
|
|
6
|
+
name: string;
|
|
7
|
+
/**
|
|
8
|
+
* Required, non-empty. Discovery has nothing else to go on: a nameless
|
|
9
|
+
* capability costs the agent a guess, and a guess costs a wrong call.
|
|
10
|
+
*/
|
|
11
|
+
description: string;
|
|
12
|
+
/**
|
|
13
|
+
* A plain JSON Schema object describing the tool input. Optional, but what
|
|
14
|
+
* you supply must be a schema the validator can compile — `api()` refuses to
|
|
15
|
+
* construct otherwise.
|
|
16
|
+
*/
|
|
17
|
+
inputSchema?: JsonSchema;
|
|
18
|
+
/** A plain JSON Schema object describing the tool's structured output. */
|
|
19
|
+
outputSchema?: JsonSchema;
|
|
20
|
+
/**
|
|
21
|
+
* Standard MCP-style behavior hints, with an explicit `readOnlyHint`
|
|
22
|
+
* required: `true` declares a read and admits the tool to call_tool and
|
|
23
|
+
* execute_code, `false` declares work that must cross
|
|
24
|
+
* `call_destructive_tool` where a host can ask a human. Connecta never
|
|
25
|
+
* infers the classification from a name, a description, a schema, or the
|
|
26
|
+
* other annotations.
|
|
27
|
+
*/
|
|
28
|
+
annotations: ToolAnnotations & {
|
|
29
|
+
readOnlyHint: boolean;
|
|
30
|
+
};
|
|
31
|
+
handler: (args: any, ctx: ApiHandlerContext) => Promise<unknown> | unknown;
|
|
32
|
+
}
|
|
33
|
+
/** How a pre-registered client authenticates at the token endpoint. */
|
|
34
|
+
export type ApiOAuthClientAuthentication = "client_secret_basic" | "client_secret_post" | "none";
|
|
35
|
+
/**
|
|
36
|
+
* Downstream OAuth 2.0 authorization code grant for a hand-written API whose
|
|
37
|
+
* provider issues clients by hand and publishes no discovery metadata. Every
|
|
38
|
+
* endpoint is declared here; nothing is learned from the network.
|
|
39
|
+
*/
|
|
40
|
+
export interface ApiOAuthConfig {
|
|
41
|
+
/** Where the operator's browser is sent for consent. Absolute https URL. */
|
|
42
|
+
authorizationEndpoint: string;
|
|
43
|
+
/** Where codes are exchanged and refresh tokens redeemed. Absolute https URL. */
|
|
44
|
+
tokenEndpoint: string;
|
|
45
|
+
/** The client id the provider issued for this deployment. */
|
|
46
|
+
clientId: string;
|
|
47
|
+
/**
|
|
48
|
+
* The client secret, from deployment configuration (an environment variable
|
|
49
|
+
* or Worker secret), never a literal in source. Omit for a public client.
|
|
50
|
+
* It is never written to storage.
|
|
51
|
+
*/
|
|
52
|
+
clientSecret?: string;
|
|
53
|
+
/**
|
|
54
|
+
* Token-endpoint client authentication. Defaults to `client_secret_basic`
|
|
55
|
+
* with a secret and `none` without one.
|
|
56
|
+
*/
|
|
57
|
+
tokenEndpointAuthMethod?: ApiOAuthClientAuthentication;
|
|
58
|
+
/** Space-separated scopes requested at consent. */
|
|
59
|
+
scope?: string;
|
|
60
|
+
/** PKCE with S256 (default true). Disable only for a server that rejects it. */
|
|
61
|
+
pkce?: boolean;
|
|
62
|
+
/**
|
|
63
|
+
* Extra authorization-request parameters, such as a provider's
|
|
64
|
+
* `access_type`. The grant's own parameters cannot be overridden.
|
|
65
|
+
*/
|
|
66
|
+
authorizationParams?: Record<string, string>;
|
|
67
|
+
/**
|
|
68
|
+
* Headers added to every token-endpoint request — the code exchange and
|
|
69
|
+
* each refresh — for a provider whose token endpoint demands its own
|
|
70
|
+
* framing, such as Church Community Builder's
|
|
71
|
+
* `Accept: application/vnd.ccbchurch.v2+json`. They replace the grant's
|
|
72
|
+
* defaults of the same name; `Authorization`, `Content-Type`,
|
|
73
|
+
* `Content-Length`, `Cookie`, and `Host` stay the grant's.
|
|
74
|
+
*/
|
|
75
|
+
tokenRequestHeaders?: Record<string, string>;
|
|
76
|
+
/**
|
|
77
|
+
* The exact origins `ctx.oauth.fetch` sends the access token to, such as
|
|
78
|
+
* `"https://api.ccbchurch.com"`. A request anywhere else is refused before
|
|
79
|
+
* it is sent, so an untrusted URL in a response cannot carry the token off.
|
|
80
|
+
*/
|
|
81
|
+
apiOrigins: readonly string[];
|
|
82
|
+
}
|
|
83
|
+
/** A handler's use of its connector's OAuth grant, never the grant itself. */
|
|
84
|
+
export interface ApiOAuthAccess {
|
|
85
|
+
/**
|
|
86
|
+
* `fetch` with the calling owner's access token as `Authorization: Bearer`.
|
|
87
|
+
* Only to `apiOrigins`; redirects are returned unfollowed. A 401 earns one
|
|
88
|
+
* coordinated refresh and one replay; a second 401, a refused refresh, or
|
|
89
|
+
* no grant at all throws `auth_required`, and an authorization-server
|
|
90
|
+
* outage throws a retryable `unavailable`.
|
|
91
|
+
*/
|
|
92
|
+
fetch(input: string | URL, init?: RequestInit): Promise<Response>;
|
|
93
|
+
}
|
|
94
|
+
/** The context an `api()` handler receives. */
|
|
95
|
+
export interface ApiHandlerContext extends ConnectorContext {
|
|
96
|
+
/** Present exactly when the connector declares `oauth`. */
|
|
97
|
+
oauth?: ApiOAuthAccess;
|
|
98
|
+
}
|
|
99
|
+
export interface ApiOptions {
|
|
100
|
+
/** Human-readable display name; the connector id remains the address prefix. */
|
|
101
|
+
title?: string;
|
|
102
|
+
description?: string;
|
|
103
|
+
/** Downstream auth ownership. Defaults to one shared deployment grant. */
|
|
104
|
+
authScope?: "shared" | "personal";
|
|
105
|
+
/**
|
|
106
|
+
* Max inline result size (bytes) for this connector's tools before
|
|
107
|
+
* call_tool truncates and stashes the full text for get_result
|
|
108
|
+
* paging. Overrides the deployment's `calls.maxResultBytes`; omit to inherit
|
|
109
|
+
* it. Must be a whole number of bytes >= 1; anything else warns at startup
|
|
110
|
+
* and is ignored.
|
|
111
|
+
*/
|
|
112
|
+
maxResultBytes?: number;
|
|
113
|
+
/** Optional per-runtime downstream call-admission policy. */
|
|
114
|
+
callAdmission?: ConnectorCallAdmissionPolicy;
|
|
115
|
+
/**
|
|
116
|
+
* Optional agent-facing usage guide served by `skills` as
|
|
117
|
+
* `connector:<id>`. A string is markdown; the structured form adds bounded
|
|
118
|
+
* discovery metadata. See `Connector.usageGuide`.
|
|
119
|
+
*/
|
|
120
|
+
usageGuide?: string | ConnectorUsageGuide;
|
|
121
|
+
/** Optional operator-managed credential exposed through ctx.credential and the connection in the operator UI. */
|
|
122
|
+
credential?: ConnectorCredentialConfig;
|
|
123
|
+
/**
|
|
124
|
+
* Optional downstream OAuth, exposed to handlers as `ctx.oauth` and managed
|
|
125
|
+
* like a `remoteMcp()` grant: Connect in the operator UI, or
|
|
126
|
+
* `authorize_connector`. Exclusive with `credential` — one connector, one
|
|
127
|
+
* way to authenticate, so `auth_required` has one recovery.
|
|
128
|
+
*/
|
|
129
|
+
oauth?: ApiOAuthConfig;
|
|
130
|
+
/** Optional validation behind the connection's Test action in the operator UI. */
|
|
131
|
+
testCredential?: (value: string, ctx: ConnectorContext) => Promise<CredentialTestResult>;
|
|
132
|
+
/** Optional validation for named multi-field credentials. */
|
|
133
|
+
testCredentials?: (values: ConnectorCredentialValues, ctx: ConnectorContext) => Promise<CredentialTestResult>;
|
|
134
|
+
/**
|
|
135
|
+
* Validate call arguments against each tool's `inputSchema` before invoking
|
|
136
|
+
* the handler (default true). Mismatches fail with a non-retryable
|
|
137
|
+
* `invalid_args` ConnectorCallError instead of reaching the handler. Set
|
|
138
|
+
* false to restore the pre-validation pass-through for deployments relying
|
|
139
|
+
* on loose coercion.
|
|
140
|
+
*/
|
|
141
|
+
validateArgs?: boolean;
|
|
142
|
+
tools: ApiTool[];
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* The OAuth half of an `api()` connector: the hooks the callback route,
|
|
146
|
+
* operator UI, and `authorize_connector` key on, and the handler accessor.
|
|
147
|
+
* Supplied by `api()` from `src/auth/static-oauth.ts`; defined here so this
|
|
148
|
+
* module never imports the grant machinery.
|
|
149
|
+
*/
|
|
150
|
+
export interface ApiOAuthHooks {
|
|
151
|
+
status(ctx: ConnectorContext): Promise<ConnectorStatus>;
|
|
152
|
+
startAuth(ctx: ConnectorContext, opts?: {
|
|
153
|
+
force?: boolean;
|
|
154
|
+
}): Promise<ConnectorStatus>;
|
|
155
|
+
disconnectAuth(ctx: ConnectorContext): Promise<void>;
|
|
156
|
+
verifyState(state: string | null, ctx: ConnectorContext): Promise<boolean>;
|
|
157
|
+
finishAuth(code: string, ctx: ConnectorContext, callbackParams?: URLSearchParams): Promise<void>;
|
|
158
|
+
/** The handler-facing accessor for one call's context. */
|
|
159
|
+
access(ctx: ConnectorContext): ApiOAuthAccess;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* A static connector; every tool passes {@link checkToolContract} first.
|
|
163
|
+
*
|
|
164
|
+
* The builder behind `api()`, without the downstream OAuth machinery: a
|
|
165
|
+
* module that only ever builds credential-free connectors (the artifacts
|
|
166
|
+
* connector) imports this and pays nothing for a grant it cannot declare.
|
|
167
|
+
*/
|
|
168
|
+
export declare function apiConnector(id: string, opts: ApiOptions, oauth?: ApiOAuthHooks): Connector;
|