@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,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
|
+
}
|
package/dist/connectors/api.d.ts
CHANGED
|
@@ -1,72 +1,9 @@
|
|
|
1
|
-
import type { Connector
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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;
|
package/dist/connectors/api.js
CHANGED
|
@@ -1,104 +1,15 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
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
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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 {
|
|
11
|
-
import {
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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 =
|
|
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";
|
package/dist/oauth-partition.js
CHANGED
|
@@ -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;
|