@zackbart/connecta 0.27.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.
Files changed (73) hide show
  1. package/AGENTS.md +6 -6
  2. package/CHANGELOG.md +229 -0
  3. package/README.md +23 -24
  4. package/bin/connecta.mjs +1 -5
  5. package/dist/activity.d.ts +5 -4
  6. package/dist/activity.js +1 -10
  7. package/dist/artifacts/connector.js +1 -1
  8. package/dist/artifacts/json.js +10 -0
  9. package/dist/artifacts/operations.js +1 -1
  10. package/dist/artifacts/validate.js +1 -2
  11. package/dist/auth/downstream-oauth.js +147 -28
  12. package/dist/auth/static-oauth.js +504 -0
  13. package/dist/catalog-service.js +1 -1
  14. package/dist/connectors/api-connector.d.ts +168 -0
  15. package/dist/connectors/api-connector.js +127 -0
  16. package/dist/connectors/api.d.ts +8 -71
  17. package/dist/connectors/api.js +10 -99
  18. package/dist/connectors/guarded-fetch.js +37 -3
  19. package/dist/connectors/remote-mcp.js +7 -32
  20. package/dist/execute.js +92 -368
  21. package/dist/executors/quickjs.js +1 -1
  22. package/dist/exempt-writes.js +162 -0
  23. package/dist/index.d.ts +13 -32
  24. package/dist/index.js +14 -47
  25. package/dist/invocation.js +6 -19
  26. package/dist/meta-tools.js +1 -2
  27. package/dist/oauth-partition.js +17 -0
  28. package/dist/operator-ui/generated.js +1 -1
  29. package/dist/operator-ui/model.d.ts +3 -3
  30. package/dist/operator-ui/view.js +6 -4
  31. package/dist/providers/basecamp.d.ts +65 -0
  32. package/dist/providers/basecamp.js +445 -0
  33. package/dist/providers/breeze.d.ts +34 -0
  34. package/dist/providers/breeze.js +1402 -0
  35. package/dist/providers/ccb.d.ts +103 -0
  36. package/dist/providers/ccb.js +1570 -0
  37. package/dist/providers/cloudflare.js +1 -1
  38. package/dist/providers/linear.js +1 -1
  39. package/dist/providers/mixpanel.js +1 -1
  40. package/dist/providers/notion.js +1 -1
  41. package/dist/providers/overflow.d.ts +60 -0
  42. package/dist/providers/overflow.js +1572 -0
  43. package/dist/providers/planning-center.d.ts +59 -0
  44. package/dist/providers/planning-center.js +2039 -0
  45. package/dist/providers/revenuecat.js +1 -1
  46. package/dist/providers/stripe.js +1 -1
  47. package/dist/providers/tithely.d.ts +49 -0
  48. package/dist/providers/tithely.js +1034 -0
  49. package/dist/providers/vercel.js +1 -1
  50. package/dist/routes/mcp.js +5 -21
  51. package/dist/routes/oauth.js +29 -3
  52. package/dist/routes/shared.d.ts +1 -6
  53. package/dist/server.js +0 -4
  54. package/dist/skills.js +8 -23
  55. package/dist/tool-safety.d.ts +1 -1
  56. package/dist/tool-safety.js +1 -1
  57. package/dist/types.d.ts +2 -2
  58. package/dist/version.d.ts +1 -1
  59. package/dist/version.js +1 -1
  60. package/documentation/architecture.md +15 -17
  61. package/documentation/auth.md +120 -6
  62. package/documentation/code-mode.md +91 -194
  63. package/documentation/meta-tools.md +38 -41
  64. package/ethos.md +8 -9
  65. package/examples/worker/README.md +10 -14
  66. package/examples/worker/src/d1-activity-row.ts +4 -1
  67. package/examples/worker/src/index.ts +1 -9
  68. package/package.json +26 -2
  69. package/templates/node/AGENTS.md +4 -3
  70. package/templates/node/README.md +3 -4
  71. package/templates/node/package.json +1 -1
  72. package/dist/resumable.js +0 -1323
  73. package/dist/run-journal.js +0 -347
@@ -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
+ }
@@ -538,7 +538,7 @@ export class CatalogService {
538
538
  ...(match.tool.annotations
539
539
  ? { annotations: match.tool.annotations }
540
540
  : {}),
541
- // Config lets a program call this write without pausing (#566).
541
+ // Config lets a program call this write without asking (#566).
542
542
  // A marker, not a class: the tool stays approval-required.
543
543
  ...(isApprovalExempt(this.approval, match.connector, match.tool.name, match.tool)
544
544
  ? { approval: "exempt" }
@@ -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;