@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.
Files changed (40) hide show
  1. package/CHANGELOG.md +173 -0
  2. package/README.md +8 -5
  3. package/dist/artifacts/connector.js +1 -1
  4. package/dist/auth/downstream-oauth.js +147 -28
  5. package/dist/auth/static-oauth.js +504 -0
  6. package/dist/connectors/api-connector.d.ts +168 -0
  7. package/dist/connectors/api-connector.js +127 -0
  8. package/dist/connectors/api.d.ts +8 -71
  9. package/dist/connectors/api.js +10 -99
  10. package/dist/connectors/guarded-fetch.js +37 -3
  11. package/dist/connectors/remote-mcp.js +7 -32
  12. package/dist/index.d.ts +1 -1
  13. package/dist/oauth-partition.js +17 -0
  14. package/dist/providers/basecamp.d.ts +65 -0
  15. package/dist/providers/basecamp.js +445 -0
  16. package/dist/providers/breeze.d.ts +34 -0
  17. package/dist/providers/breeze.js +1402 -0
  18. package/dist/providers/ccb.d.ts +103 -0
  19. package/dist/providers/ccb.js +1570 -0
  20. package/dist/providers/cloudflare.js +1 -1
  21. package/dist/providers/linear.js +1 -1
  22. package/dist/providers/mixpanel.js +1 -1
  23. package/dist/providers/notion.js +1 -1
  24. package/dist/providers/overflow.d.ts +60 -0
  25. package/dist/providers/overflow.js +1572 -0
  26. package/dist/providers/planning-center.d.ts +59 -0
  27. package/dist/providers/planning-center.js +2039 -0
  28. package/dist/providers/revenuecat.js +1 -1
  29. package/dist/providers/stripe.js +1 -1
  30. package/dist/providers/tithely.d.ts +49 -0
  31. package/dist/providers/tithely.js +1034 -0
  32. package/dist/providers/vercel.js +1 -1
  33. package/dist/routes/oauth.js +29 -3
  34. package/dist/version.d.ts +1 -1
  35. package/dist/version.js +1 -1
  36. package/documentation/architecture.md +5 -3
  37. package/documentation/auth.md +120 -6
  38. package/ethos.md +3 -3
  39. package/package.json +25 -1
  40. package/templates/node/package.json +1 -1
@@ -0,0 +1,127 @@
1
+ import { ConnectorCallError, networkErrorCode, unavailableCallError, } from "../errors.js";
2
+ import { compileValidator, validateToolInput } from "../validate.js";
3
+ export function defined(value) {
4
+ return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined));
5
+ }
6
+ /**
7
+ * Three things a hand-written surface is refused for at construction rather
8
+ * than in production (#340): no description (discovery has nothing to route on
9
+ * and a guess costs a call), no explicit `readOnlyHint` (connecta never infers
10
+ * the safety class, so an unclassified tool is a deployment bug), and an
11
+ * `inputSchema` the validator cannot compile (declaring one is optional;
12
+ * declaring an unenforceable one is not). None of this reaches a proxied
13
+ * catalog — the contract binds the surfaces we write, not the ones we relay.
14
+ */
15
+ function checkToolContract(id, tool) {
16
+ const address = `${id}.${tool.name}`;
17
+ if (typeof tool.description !== "string" || tool.description.trim() === "") {
18
+ throw new Error(`api() tool "${address}" needs a non-empty description — it is what an ` +
19
+ "agent reads to choose the tool (convention: imperative one-liner, " +
20
+ 'e.g. "Send an email via Resend").');
21
+ }
22
+ if (typeof tool.annotations?.readOnlyHint !== "boolean") {
23
+ throw new Error(`api() tool "${address}" needs an explicit annotations.readOnlyHint: ` +
24
+ "true for a read, false for work that must cross " +
25
+ "call_destructive_tool. Connecta never infers the classification " +
26
+ "from a tool name, description, schema, or other annotations.");
27
+ }
28
+ if (tool.inputSchema)
29
+ compileValidator(tool.inputSchema, { address });
30
+ }
31
+ /**
32
+ * A static connector; every tool passes {@link checkToolContract} first.
33
+ *
34
+ * The builder behind `api()`, without the downstream OAuth machinery: a
35
+ * module that only ever builds credential-free connectors (the artifacts
36
+ * connector) imports this and pays nothing for a grant it cannot declare.
37
+ */
38
+ export function apiConnector(id, opts, oauth) {
39
+ if (opts.oauth !== undefined && oauth === undefined) {
40
+ throw new Error(`api() connector "${id}" declares oauth but was built without its grant; construct it with api().`);
41
+ }
42
+ const names = new Set();
43
+ for (const tool of opts.tools) {
44
+ if (names.has(tool.name)) {
45
+ throw new Error(`api() tool "${id}.${tool.name}" is declared more than once; discovery and dispatch must use one definition.`);
46
+ }
47
+ names.add(tool.name);
48
+ checkToolContract(id, tool);
49
+ }
50
+ const defs = opts.tools.map((t) => ({
51
+ name: t.name,
52
+ description: t.description,
53
+ ...defined({
54
+ inputSchema: t.inputSchema,
55
+ outputSchema: t.outputSchema,
56
+ }),
57
+ annotations: t.annotations,
58
+ }));
59
+ const byName = new Map(opts.tools.map((t) => [t.name, t]));
60
+ const validateArgs = opts.validateArgs ?? true;
61
+ return {
62
+ id,
63
+ ...defined({ title: opts.title }),
64
+ kind: "api",
65
+ ...defined({
66
+ description: opts.description,
67
+ authScope: opts.authScope,
68
+ maxResultBytes: opts.maxResultBytes,
69
+ callAdmission: opts.callAdmission,
70
+ usageGuide: opts.usageGuide,
71
+ credential: opts.credential,
72
+ testCredential: opts.testCredential,
73
+ testCredentials: opts.testCredentials,
74
+ }),
75
+ ...(oauth
76
+ ? {
77
+ status: oauth.status,
78
+ startAuth: oauth.startAuth,
79
+ disconnectAuth: oauth.disconnectAuth,
80
+ verifyState: oauth.verifyState,
81
+ finishAuth: oauth.finishAuth,
82
+ }
83
+ : {}),
84
+ staticTools: defs,
85
+ async listTools() {
86
+ return defs;
87
+ },
88
+ async callTool(name, args, ctx) {
89
+ const tool = byName.get(name);
90
+ if (!tool) {
91
+ throw new Error(`Unknown tool "${name}" on connector "${id}"`);
92
+ }
93
+ const input = args ?? {};
94
+ if (validateArgs && tool.inputSchema) {
95
+ const invalid = validateToolInput(tool.inputSchema, input, {
96
+ address: `${id}.${name}`,
97
+ logger: ctx.logger,
98
+ // Always: the schema compiled at construction, so anything that
99
+ // fails here is a schema that cannot be enforced, and a surface we
100
+ // wrote ourselves does not get to admit unvalidated input quietly.
101
+ failClosed: true,
102
+ });
103
+ if (invalid)
104
+ throw invalid;
105
+ }
106
+ // `await` (not a bare promise return) so a handler that throws before
107
+ // its first await never sits handler-less for the thenable-adoption
108
+ // microtask — workerd and vitest both report that gap as an unhandled
109
+ // rejection even though the caller catches the failure.
110
+ // The accessor closes over the registry's own context, not this copy,
111
+ // so the grant it reaches is the one that context's owner holds.
112
+ const handlerCtx = oauth
113
+ ? { ...ctx, oauth: oauth.access(ctx) }
114
+ : ctx;
115
+ try {
116
+ return await tool.handler(input, handlerCtx);
117
+ }
118
+ catch (error) {
119
+ // A handler owns its destinations. ctx.baseUrl is Connecta's inbound
120
+ // URL, so it must never masquerade as the failed downstream host.
121
+ if (error instanceof ConnectorCallError || !networkErrorCode(error))
122
+ throw error;
123
+ throw unavailableCallError(error);
124
+ }
125
+ },
126
+ };
127
+ }
@@ -1,72 +1,9 @@
1
- import type { Connector, ConnectorCallAdmissionPolicy, ConnectorCredentialConfig, ConnectorCredentialValues, ConnectorContext, 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: ConnectorContext) => Promise<unknown> | unknown;
32
- }
33
- export interface ApiOptions {
34
- /** Human-readable display name; the connector id remains the address prefix. */
35
- title?: string;
36
- description?: string;
37
- /** Downstream auth ownership. Defaults to one shared deployment grant. */
38
- authScope?: "shared" | "personal";
39
- /**
40
- * Max inline result size (bytes) for this connector's tools before
41
- * call_tool truncates and stashes the full text for get_result
42
- * paging. Overrides the deployment's `calls.maxResultBytes`; omit to inherit
43
- * it. Must be a whole number of bytes >= 1; anything else warns at startup
44
- * and is ignored.
45
- */
46
- maxResultBytes?: number;
47
- /** Optional per-runtime downstream call-admission policy. */
48
- callAdmission?: ConnectorCallAdmissionPolicy;
49
- /**
50
- * Optional agent-facing usage guide served by `skills` as
51
- * `connector:<id>`. A string is markdown; the structured form adds bounded
52
- * discovery metadata. See `Connector.usageGuide`.
53
- */
54
- usageGuide?: string | ConnectorUsageGuide;
55
- /** Optional operator-managed credential exposed through ctx.credential and the connection in the operator UI. */
56
- credential?: ConnectorCredentialConfig;
57
- /** Optional validation behind the connection's Test action in the operator UI. */
58
- testCredential?: (value: string, ctx: ConnectorContext) => Promise<CredentialTestResult>;
59
- /** Optional validation for named multi-field credentials. */
60
- testCredentials?: (values: ConnectorCredentialValues, ctx: ConnectorContext) => Promise<CredentialTestResult>;
61
- /**
62
- * Validate call arguments against each tool's `inputSchema` before invoking
63
- * the handler (default true). Mismatches fail with a non-retryable
64
- * `invalid_args` ConnectorCallError instead of reaching the handler. Set
65
- * false to restore the pre-validation pass-through for deployments relying
66
- * on loose coercion.
67
- */
68
- validateArgs?: boolean;
69
- tools: ApiTool[];
70
- }
71
- /** A static connector; every tool passes {@link checkToolContract} first. */
1
+ import type { Connector } from "../types.js";
2
+ import type { ApiOptions } from "./api-connector.js";
3
+ export type { ApiHandlerContext, ApiOAuthAccess, ApiOAuthClientAuthentication, ApiOAuthConfig, ApiOptions, ApiTool, } from "./api-connector.js";
4
+ /**
5
+ * A hand-written connector: static tools, each with a handler. With `oauth`,
6
+ * its handlers reach the API through the calling owner's downstream grant
7
+ * (`ctx.oauth`), managed exactly like a `remoteMcp()` grant.
8
+ */
72
9
  export declare function api(id: string, opts: ApiOptions): Connector;
@@ -1,104 +1,15 @@
1
- import { ConnectorCallError, networkErrorCode, unavailableCallError, } from "../errors.js";
2
- import { compileValidator, validateToolInput } from "../validate.js";
3
- export function defined(value) {
4
- return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined));
5
- }
1
+ import { staticOAuth } from "../auth/static-oauth.js";
2
+ import { apiConnector } from "./api-connector.js";
6
3
  /**
7
- * Three things a hand-written surface is refused for at construction rather
8
- * than in production (#340): no description (discovery has nothing to route on
9
- * and a guess costs a call), no explicit `readOnlyHint` (connecta never infers
10
- * the safety class, so an unclassified tool is a deployment bug), and an
11
- * `inputSchema` the validator cannot compile (declaring one is optional;
12
- * declaring an unenforceable one is not). None of this reaches a proxied
13
- * catalog — the contract binds the surfaces we write, not the ones we relay.
4
+ * A hand-written connector: static tools, each with a handler. With `oauth`,
5
+ * its handlers reach the API through the calling owner's downstream grant
6
+ * (`ctx.oauth`), managed exactly like a `remoteMcp()` grant.
14
7
  */
15
- function checkToolContract(id, tool) {
16
- const address = `${id}.${tool.name}`;
17
- if (typeof tool.description !== "string" || tool.description.trim() === "") {
18
- throw new Error(`api() tool "${address}" needs a non-empty description — it is what an ` +
19
- "agent reads to choose the tool (convention: imperative one-liner, " +
20
- 'e.g. "Send an email via Resend").');
21
- }
22
- if (typeof tool.annotations?.readOnlyHint !== "boolean") {
23
- throw new Error(`api() tool "${address}" needs an explicit annotations.readOnlyHint: ` +
24
- "true for a read, false for work that must cross " +
25
- "call_destructive_tool. Connecta never infers the classification " +
26
- "from a tool name, description, schema, or other annotations.");
27
- }
28
- if (tool.inputSchema)
29
- compileValidator(tool.inputSchema, { address });
30
- }
31
- /** A static connector; every tool passes {@link checkToolContract} first. */
32
8
  export function api(id, opts) {
33
- const names = new Set();
34
- for (const tool of opts.tools) {
35
- if (names.has(tool.name)) {
36
- throw new Error(`api() tool "${id}.${tool.name}" is declared more than once; discovery and dispatch must use one definition.`);
37
- }
38
- names.add(tool.name);
39
- checkToolContract(id, tool);
9
+ if (opts.oauth !== undefined && opts.credential !== undefined) {
10
+ throw new Error(`api() connector "${id}" declares both oauth and credential. Declare ` +
11
+ "one: auth_required must name a single recovery. A provider offering " +
12
+ "both lets the deployment choose which one to pass.");
40
13
  }
41
- const defs = opts.tools.map((t) => ({
42
- name: t.name,
43
- description: t.description,
44
- ...defined({
45
- inputSchema: t.inputSchema,
46
- outputSchema: t.outputSchema,
47
- }),
48
- annotations: t.annotations,
49
- }));
50
- const byName = new Map(opts.tools.map((t) => [t.name, t]));
51
- const validateArgs = opts.validateArgs ?? true;
52
- return {
53
- id,
54
- ...defined({ title: opts.title }),
55
- kind: "api",
56
- ...defined({
57
- description: opts.description,
58
- authScope: opts.authScope,
59
- maxResultBytes: opts.maxResultBytes,
60
- callAdmission: opts.callAdmission,
61
- usageGuide: opts.usageGuide,
62
- credential: opts.credential,
63
- testCredential: opts.testCredential,
64
- testCredentials: opts.testCredentials,
65
- }),
66
- staticTools: defs,
67
- async listTools() {
68
- return defs;
69
- },
70
- async callTool(name, args, ctx) {
71
- const tool = byName.get(name);
72
- if (!tool) {
73
- throw new Error(`Unknown tool "${name}" on connector "${id}"`);
74
- }
75
- const input = args ?? {};
76
- if (validateArgs && tool.inputSchema) {
77
- const invalid = validateToolInput(tool.inputSchema, input, {
78
- address: `${id}.${name}`,
79
- logger: ctx.logger,
80
- // Always: the schema compiled at construction, so anything that
81
- // fails here is a schema that cannot be enforced, and a surface we
82
- // wrote ourselves does not get to admit unvalidated input quietly.
83
- failClosed: true,
84
- });
85
- if (invalid)
86
- throw invalid;
87
- }
88
- // `await` (not a bare promise return) so a handler that throws before
89
- // its first await never sits handler-less for the thenable-adoption
90
- // microtask — workerd and vitest both report that gap as an unhandled
91
- // rejection even though the caller catches the failure.
92
- try {
93
- return await tool.handler(input, ctx);
94
- }
95
- catch (error) {
96
- // A handler owns its destinations. ctx.baseUrl is Connecta's inbound
97
- // URL, so it must never masquerade as the failed downstream host.
98
- if (error instanceof ConnectorCallError || !networkErrorCode(error))
99
- throw error;
100
- throw unavailableCallError(error);
101
- }
102
- },
103
- };
14
+ return apiConnector(id, opts, opts.oauth !== undefined ? staticOAuth(id, opts.oauth) : undefined);
104
15
  }
@@ -29,6 +29,33 @@ export function retryAfterMs(headers) {
29
29
  const date = Date.parse(raw);
30
30
  return Number.isFinite(date) ? Math.max(0, date - Date.now()) : undefined;
31
31
  }
32
+ /**
33
+ * The transport half of a guarded connector that authenticates through its
34
+ * own `api()` OAuth grant: no header of the helper's making, and every request
35
+ * sent through `ctx.oauth.fetch`, which adds the calling owner's bearer token,
36
+ * refuses any origin outside the grant's `apiOrigins`, and spends one
37
+ * coordinated refresh and one replay on a 401 before answering
38
+ * `auth_required`. Spread it into the options:
39
+ * `guardedFetch({ provider, baseUrl, maxResponseBytes, ...oauthBearer(provider) })`.
40
+ *
41
+ * Confinement still happens here first, so a request reaches the grant only
42
+ * once it is provably beneath `baseUrl`; the grant's origin check is the second
43
+ * fence, not the only one.
44
+ */
45
+ export function oauthBearer(provider) {
46
+ return {
47
+ authenticate: () => ({}),
48
+ fetch: (url, init, ctx) => {
49
+ const grant = ctx.oauth;
50
+ if (!grant) {
51
+ // A wiring bug, not an outage: the transport was built for a grant the
52
+ // connector does not have, so no request can be authenticated.
53
+ throw new ConnectorCallError("connector_call_failed", `The ${provider} transport authenticates through the connector's OAuth grant, and this call's context carries none.`, { retryable: false });
54
+ }
55
+ return grant.fetch(url, init);
56
+ },
57
+ };
58
+ }
32
59
  /** Statuses that instruct a client to re-send somewhere else. Never followed. */
33
60
  const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
34
61
  /** Statuses the HTTP spec defines as carrying no body at all. */
@@ -91,6 +118,12 @@ function confinedUrl(provider, base, basePath, request) {
91
118
  for (const [key, value] of Object.entries(request.query ?? {})) {
92
119
  if (value === undefined)
93
120
  continue;
121
+ if (Array.isArray(value)) {
122
+ url.searchParams.delete(key);
123
+ for (const item of value)
124
+ url.searchParams.append(key, String(item));
125
+ continue;
126
+ }
94
127
  url.searchParams.set(key, String(value));
95
128
  }
96
129
  return url;
@@ -224,7 +257,8 @@ export function guardedFetch(options) {
224
257
  // "" for a root base, so joining never doubles the separator.
225
258
  const basePath = base.pathname.replace(/\/+$/, "");
226
259
  const constantHeaders = { ...options.headers };
227
- return async function send(request, ctx, map) {
260
+ const send = options.fetch ?? ((url, init) => fetch(url, init));
261
+ return async function guarded(request, ctx, map) {
228
262
  if (request.body !== undefined && request.rawBody !== undefined) {
229
263
  throw new Error(`A ${provider} request cannot carry both a JSON body and a raw body.`);
230
264
  }
@@ -249,7 +283,7 @@ export function guardedFetch(options) {
249
283
  }
250
284
  let response;
251
285
  try {
252
- response = await fetch(url.toString(), {
286
+ response = await send(url.toString(), {
253
287
  method: request.method,
254
288
  headers,
255
289
  ...(request.body !== undefined
@@ -262,7 +296,7 @@ export function guardedFetch(options) {
262
296
  // never one. Refused below rather than followed.
263
297
  redirect: "manual",
264
298
  ...(ctx.signal ? { signal: ctx.signal } : {}),
265
- });
299
+ }, ctx);
266
300
  }
267
301
  catch (cause) {
268
302
  if (cause instanceof ConnectorCallError)
@@ -1,14 +1,14 @@
1
1
  import { Client, ProtocolError, SdkHttpError, isInputRequiredResult, specTypeSchemas, StreamableHTTPClientTransport, UnauthorizedError, } from "@modelcontextprotocol/client";
2
2
  import { Deferred, Duration, Effect, Exit, Scope } from "effect";
3
- import { KvOAuthProvider, OAuthRefreshCoordinator, } from "../auth/downstream-oauth.js";
3
+ import { assertOAuthScope, authorizingContext, KvOAuthProvider, refreshCoordinatorsByPartition, } from "../auth/downstream-oauth.js";
4
4
  import { trackOAuthStartReset } from "../auth/oauth-start-reset.js";
5
5
  import { MAX_CATALOG_TOOLS } from "../catalog-limits.js";
6
6
  import { connectorScopeCleanupClaimed } from "../connector-scope.js";
7
7
  import { boundedEchoText, ConnectorCallError, msg, unavailableCallError, } from "../errors.js";
8
8
  import { CONNECTA_VERSION } from "../version.js";
9
9
  import { learnedUrlRefusal } from "../url-safety.js";
10
- import { inheritOAuthSealer, oauthSealerFor } from "../oauth-sealing.js";
11
- import { attachOAuthPartition, oauthPartitionFor, retainOAuthPartition } from "../oauth-partition.js";
10
+ import { oauthSealerFor } from "../oauth-sealing.js";
11
+ import { retainingOAuthPartition } from "../oauth-partition.js";
12
12
  import { detach, runEdge } from "../runtime/run.js";
13
13
  /**
14
14
  * Apply a maintained provider's slot copy and header framing to credential
@@ -400,9 +400,7 @@ function learnedUrlSafeFetch(connectorId, configured, baseFetch) {
400
400
  export function remoteMcp(id, opts) {
401
401
  const clientMetadataUrl = opts.auth?.type === "oauth" ? opts.auth.clientMetadataUrl : undefined;
402
402
  const oauthScope = opts.auth?.type === "oauth" ? opts.auth.scope : undefined;
403
- if (oauthScope !== undefined && !/^[\x21\x23-\x5B\x5D-\x7E]+(?: [\x21\x23-\x5B\x5D-\x7E]+)*$/.test(oauthScope)) {
404
- throw new Error(`[connecta] connector "${id}" OAuth scope must contain space-separated scope tokens.`);
405
- }
403
+ assertOAuthScope(id, oauthScope);
406
404
  if (clientMetadataUrl !== undefined) {
407
405
  let valid = false;
408
406
  try {
@@ -428,18 +426,7 @@ export function remoteMcp(id, opts) {
428
426
  const isOauth = opts.auth?.type === "oauth";
429
427
  // Long-lived enough for distinct request scopes in this connector runtime to
430
428
  // join one token redemption. It owns no client, transport, or request state.
431
- const refreshCoordinators = new WeakMap();
432
- const refreshCoordinatorFor = (ctx) => {
433
- // Production contexts carry a stable registry partition. Hand-written
434
- // contexts can coordinate by their shared storage object instead.
435
- const partition = oauthPartitionFor(ctx) ?? ctx.storage;
436
- let coordinator = refreshCoordinators.get(partition);
437
- if (!coordinator) {
438
- coordinator = new OAuthRefreshCoordinator(() => retainOAuthPartition(partition));
439
- refreshCoordinators.set(partition, coordinator);
440
- }
441
- return coordinator;
442
- };
429
+ const refreshCoordinatorFor = refreshCoordinatorsByPartition();
443
430
  const logger = opts.logger ?? console;
444
431
  const credentialAuth = opts.auth?.type === "credential" ? opts.auth : undefined;
445
432
  if (credentialAuth?.credential?.fields?.length) {
@@ -1240,11 +1227,7 @@ export function remoteMcp(id, opts) {
1240
1227
  await disconnectAuthorization(ctx, stateFor(ctx), true);
1241
1228
  };
1242
1229
  connector.startAuth = async (ctx, startOpts) => {
1243
- ctx = attachOAuthPartition(inheritOAuthSealer(ctx, {
1244
- ...ctx,
1245
- requestScope: ctx.requestScope ?? ctx,
1246
- allowAuthorization: true,
1247
- }), oauthPartitionFor(ctx));
1230
+ ctx = authorizingContext(ctx);
1248
1231
  const state = stateFor(ctx);
1249
1232
  state.provider = null;
1250
1233
  const p = newProvider(ctx, state);
@@ -1297,15 +1280,7 @@ export function remoteMcp(id, opts) {
1297
1280
  if (isOauth) {
1298
1281
  // Pin before the first asynchronous storage/discovery read, not only once
1299
1282
  // a refresh flight exists. These operations do not take call admission.
1300
- const retain = (operation, contextIndex) => async (...args) => {
1301
- const release = retainOAuthPartition(oauthPartitionFor(args[contextIndex]));
1302
- try {
1303
- return await operation(...args);
1304
- }
1305
- finally {
1306
- release();
1307
- }
1308
- };
1283
+ const retain = retainingOAuthPartition;
1309
1284
  connector.listTools = retain(connector.listTools, 0);
1310
1285
  connector.callTool = retain(connector.callTool, 2);
1311
1286
  connector.status = retain(connector.status, 0);
package/dist/index.d.ts CHANGED
@@ -271,6 +271,6 @@ export { memoryStorage } from "./storage/memory.js";
271
271
  export { CONNECTA_VERSION } from "./version.js";
272
272
  export type { Registry } from "./registry.js";
273
273
  export type { RemoteMcpOptions, RemoteMcpAuth, RemoteMcpRedirectPolicy, } from "./connectors/remote-mcp.js";
274
- export type { ApiOptions, ApiTool } from "./connectors/api.js";
274
+ export type { ApiHandlerContext, ApiOAuthAccess, ApiOAuthClientAuthentication, ApiOAuthConfig, ApiOptions, ApiTool, } from "./connectors/api.js";
275
275
  export type { CatalogDriftCounts, CatalogDriftReport, ConnectaBranding, ConnectaTheme, Connector, ConnectorCallAdmissionInput, ConnectorCallAdmissionPolicy, ConnectorCallAdmissionRule, ConnectorRollingWindowBudget, ConnectorCredentialAccess, ConnectorCredentialConfig, ConnectorCredentialFieldConfig, ConnectorCredentialValues, ConnectorContext, ConnectorUsageGuide, ConnectorStatus, CredentialTestResult, AdmittingExecutor, AdmissionSnapshot, ExecuteResult, Executor, ExecutorLease, ExecutorProvider, InboundAuth, InboundAuthRuntimeContext, UiAuthConfig, AuthResult, AuthenticatedIdentity, IdentityReference, JsonSchema, KVStorage, Logger, ToolDef, ToolAnnotations, } from "./types.js";
276
276
  export type { ActivityActor, ActivityCallSource, ActivityOutcome, ActivityPage, ActivityReadActor, ActivityReadEvent, ActivityReader, ActivityReadGate, ActivityReadPage, ActivitySink, ActivityStore, AgentFriction, CatalogDriftActivityEvent, ToolCallActivityEvent, } from "./activity.js";
@@ -19,6 +19,23 @@ export function retainOAuthPartition(partition) {
19
19
  activeWork.set(partition, remaining);
20
20
  };
21
21
  }
22
+ /**
23
+ * Wrap one connector operation so its owner partition stays pinned from the
24
+ * first asynchronous storage read to the end, not only once a refresh flight
25
+ * exists: an idle personal registry can otherwise be evicted mid-flow. Status
26
+ * and auth operations take no call admission, so nothing else holds it.
27
+ */
28
+ export function retainingOAuthPartition(operation, contextIndex) {
29
+ return async (...args) => {
30
+ const release = retainOAuthPartition(oauthPartitionFor(args[contextIndex]));
31
+ try {
32
+ return await operation(...args);
33
+ }
34
+ finally {
35
+ release();
36
+ }
37
+ };
38
+ }
22
39
  export function oauthPartitionIdle(partition) {
23
40
  return !activeWork.has(partition);
24
41
  }
@@ -0,0 +1,65 @@
1
+ import type { Connector, ConnectorCallAdmissionPolicy } from "../types.js";
2
+ /**
3
+ * Basecamp's one hosted MCP endpoint, streamable HTTP.
4
+ *
5
+ * Live but undocumented: as of 2026-10-05 37signals has announced no MCP
6
+ * server, and basecamp.com/agents (where the endpoint's own 401 body points)
7
+ * describes the CLI and SDKs without naming it. What the server does publish is
8
+ * its OAuth discovery — protected-resource metadata naming
9
+ * `https://app.basecamp.com` as the authorization server — and that is what
10
+ * this provider is written against, beside a captured live `tools/list`.
11
+ */
12
+ export declare const BASECAMP_MCP_ENDPOINT = "https://mcp.basecamp.com/mcp";
13
+ export interface BasecampOptions {
14
+ /** Human-readable display name; defaults to "Basecamp". */
15
+ title?: string;
16
+ /**
17
+ * Downstream auth ownership. Defaults to one shared deployment grant. With
18
+ * `"personal"` each principal authorizes separately and may pick a
19
+ * different Basecamp account at consent, which the guide says.
20
+ */
21
+ authScope?: "shared" | "personal";
22
+ /** Which Basecamp account this is and what decisions it answers. */
23
+ purpose: string;
24
+ /**
25
+ * Required. The HTTPS URL of a Client ID Metadata Document this deployment
26
+ * controls: a public JSON document whose `client_id` is its own URL, whose
27
+ * `redirect_uris` include `<publicUrl>/oauth/callback/<connector id>`, with
28
+ * `token_endpoint_auth_method: "none"` and the `authorization_code` and
29
+ * `refresh_token` grants. Basecamp advertises dynamic client registration
30
+ * but restricts it for HTTPS redirect URIs, which every deployed connecta
31
+ * has; its authorization server accepts a metadata-document client id
32
+ * instead (`client_id_metadata_document_supported: true`). Connecta does not
33
+ * serve one of its own yet, so the deployment hosts it.
34
+ */
35
+ clientMetadataUrl: string;
36
+ /** Account-specific conventions appended to the maintained provider guide. */
37
+ instructions?: string;
38
+ /** Connector-specific inline result limit; omit to inherit the deployment. */
39
+ maxResultBytes?: number;
40
+ /**
41
+ * Optional per-runtime policy. There is no default: Basecamp's API documents
42
+ * several dynamically adjusted limits (GET versus POST, per second, hour,
43
+ * and day) and quotes only "currently 50 requests per 10 second period per
44
+ * IP address" for scale — metered at the IP that calls the API, which is
45
+ * Basecamp's own MCP server rather than this runtime. A per-runtime counter
46
+ * approximates none of that, so the operator picks (P12).
47
+ */
48
+ callAdmission?: ConnectorCallAdmissionPolicy;
49
+ }
50
+ /**
51
+ * One release-reviewed manifest, used both to classify a live tool and as the
52
+ * baseline the runtime drift check compares against, so the annotation a
53
+ * caller gets and the verdict a check reads can never disagree (P13). Names
54
+ * and verdicts only — no schemas are vendored; the live `tools/list` response
55
+ * stays authoritative.
56
+ *
57
+ * Basecamp publishes no tool inventory, so this list has no public document
58
+ * to be checked against before a release. Catalog drift is visible at runtime
59
+ * only — as unclassified-tool and unserved-name counts on connector status and
60
+ * `/health` — and the credential-free provider check reads the OAuth
61
+ * discovery metadata the server does publish instead.
62
+ */
63
+ export declare const BASECAMP_VETTED_CATALOG: import("../catalog-drift.js").VettedCatalog;
64
+ /** A maintained Basecamp hosted-MCP connection. */
65
+ export declare function basecamp(id: string, options: BasecampOptions): Connector;