@zackbart/connecta 0.18.0 → 0.18.2
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 +108 -0
- package/README.md +79 -118
- package/dist/connectors/remote-mcp.d.ts +49 -2
- package/dist/connectors/remote-mcp.js +302 -7
- package/dist/providers/linear.d.ts +7 -1
- package/dist/providers/linear.js +12 -2
- package/dist/providers/mixpanel.d.ts +5 -1
- package/dist/providers/mixpanel.js +17 -3
- package/dist/providers/revenuecat.d.ts +79 -0
- package/dist/providers/revenuecat.js +323 -0
- package/dist/providers/stripe.d.ts +9 -3
- package/dist/providers/stripe.js +29 -6
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/documentation/connectors.md +26 -0
- package/documentation/linear.md +25 -4
- package/documentation/meta-tools.md +5 -0
- package/documentation/mixpanel.md +19 -0
- package/documentation/operations.md +2 -0
- package/documentation/provider-audit.md +32 -2
- package/documentation/provider-conventions.md +61 -36
- package/documentation/revenuecat.md +301 -0
- package/documentation/storage-and-credentials.md +55 -0
- package/documentation/stripe.md +32 -2
- package/documentation/upgrading.md +31 -4
- package/examples/worker/src/index.ts +5 -0
- package/package.json +5 -1
- package/templates/node/package.json +1 -1
|
@@ -3,6 +3,26 @@ import { KvOAuthProvider } from "../auth/downstream-oauth.js";
|
|
|
3
3
|
import { MAX_CATALOG_TOOLS } from "../catalog-limits.js";
|
|
4
4
|
import { ConnectorCallError } from "../errors.js";
|
|
5
5
|
import { CONNECTA_VERSION } from "../version.js";
|
|
6
|
+
/**
|
|
7
|
+
* Apply a maintained provider's slot copy and header framing to credential
|
|
8
|
+
* auth the deployment left bare.
|
|
9
|
+
*
|
|
10
|
+
* A provider knows what its own key is called and how the endpoint expects it
|
|
11
|
+
* framed; a deployment that states either one keeps its answer. Every other
|
|
12
|
+
* auth shape passes through untouched, so a provider can hand this its whole
|
|
13
|
+
* `auth` option without branching first.
|
|
14
|
+
*/
|
|
15
|
+
export function withCredentialDefaults(auth, defaults) {
|
|
16
|
+
if (auth.type !== "credential")
|
|
17
|
+
return auth;
|
|
18
|
+
return {
|
|
19
|
+
...auth,
|
|
20
|
+
credential: auth.credential ?? defaults.credential,
|
|
21
|
+
...(auth.scheme === undefined && defaults.scheme !== undefined
|
|
22
|
+
? { scheme: defaults.scheme }
|
|
23
|
+
: {}),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
6
26
|
/**
|
|
7
27
|
* How long a downstream gets to answer the session-termination DELETE before
|
|
8
28
|
* teardown stops waiting. This is a network round-trip budget, deliberately
|
|
@@ -160,6 +180,65 @@ async function terminateSession(transport, logger, connectorId) {
|
|
|
160
180
|
});
|
|
161
181
|
});
|
|
162
182
|
}
|
|
183
|
+
const encoder = new TextEncoder();
|
|
184
|
+
/** Base64 of a UTF-8 string, Web-API only so the core still runs on workerd. */
|
|
185
|
+
function base64Utf8(value) {
|
|
186
|
+
let binary = "";
|
|
187
|
+
for (const byte of encoder.encode(value)) {
|
|
188
|
+
binary += String.fromCharCode(byte);
|
|
189
|
+
}
|
|
190
|
+
return btoa(binary);
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Hex SHA-256 of a credential, used only to notice that a cached client
|
|
194
|
+
* connected with a value the vault no longer holds. WebCrypto rather than a
|
|
195
|
+
* `node:` hash: the whole core has to keep running unchanged on Workers.
|
|
196
|
+
*/
|
|
197
|
+
async function digestOf(value) {
|
|
198
|
+
const bytes = new Uint8Array(await crypto.subtle.digest("SHA-256", encoder.encode(value)));
|
|
199
|
+
let hex = "";
|
|
200
|
+
for (const byte of bytes)
|
|
201
|
+
hex += byte.toString(16).padStart(2, "0");
|
|
202
|
+
return hex;
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Assemble the one header a credential-auth connector sends.
|
|
206
|
+
*
|
|
207
|
+
* A scheme ending in `Basic` names HTTP Basic credentials wherever a provider
|
|
208
|
+
* nests it, so the `user:secret` it frames is base64-encoded — that is what
|
|
209
|
+
* makes plain `Basic` and Mixpanel's `Bearer Basic` one rule instead of two.
|
|
210
|
+
*/
|
|
211
|
+
function credentialHeaderValue(scheme, value) {
|
|
212
|
+
if (scheme === null)
|
|
213
|
+
return value;
|
|
214
|
+
return /(?:^|\s)basic$/i.test(scheme)
|
|
215
|
+
? `${scheme} ${base64Utf8(value)}`
|
|
216
|
+
: `${scheme} ${value}`;
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* True when a stored credential carries a character a header cannot.
|
|
220
|
+
*
|
|
221
|
+
* The fetch specification refuses NUL, CR, and LF outright, and the runtime
|
|
222
|
+
* that refuses them says so in a `TypeError` that quotes the whole offending
|
|
223
|
+
* value — a message that would otherwise travel to the agent. The remaining C0
|
|
224
|
+
* controls and DEL are refused here too: no real API key contains one, and a
|
|
225
|
+
* paste that picked one up is a paste to redo rather than a request to send.
|
|
226
|
+
* Leading and trailing whitespace is already gone by the time this runs.
|
|
227
|
+
*
|
|
228
|
+
* A scan rather than a regular expression, because a character class over the
|
|
229
|
+
* control range is exactly what `no-control-regex` exists to flag, and the
|
|
230
|
+
* suppression would be less readable than the loop it suppressed.
|
|
231
|
+
*/
|
|
232
|
+
function carriesIllegalHeaderChar(value) {
|
|
233
|
+
for (let index = 0; index < value.length; index++) {
|
|
234
|
+
const code = value.charCodeAt(index);
|
|
235
|
+
if (code <= 0x1f || code === 0x7f)
|
|
236
|
+
return true;
|
|
237
|
+
}
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
/** RFC 9110 field-name token, checked once at construction. */
|
|
241
|
+
const HEADER_NAME_TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
|
163
242
|
function isLoopbackHost(hostname) {
|
|
164
243
|
return (hostname === "localhost" ||
|
|
165
244
|
hostname === "127.0.0.1" ||
|
|
@@ -273,6 +352,29 @@ export function remoteMcp(id, opts) {
|
|
|
273
352
|
const closedScopes = new WeakSet();
|
|
274
353
|
const isOauth = opts.auth?.type === "oauth";
|
|
275
354
|
const logger = opts.logger ?? console;
|
|
355
|
+
const credentialAuth = opts.auth?.type === "credential" ? opts.auth : undefined;
|
|
356
|
+
if (credentialAuth?.credential?.fields?.length) {
|
|
357
|
+
throw new Error(`[connecta] connector "${id}" credential auth declares named fields; ` +
|
|
358
|
+
"this shape sends one secret in one header and reads the reserved " +
|
|
359
|
+
"`value` field only.");
|
|
360
|
+
}
|
|
361
|
+
const credentialConfig = credentialAuth?.credential ?? {
|
|
362
|
+
label: "API key",
|
|
363
|
+
};
|
|
364
|
+
const credentialHeader = credentialAuth?.header?.trim() || "Authorization";
|
|
365
|
+
// A structural mistake in the deployment file, beside the `fields` refusal
|
|
366
|
+
// above: an unsendable header name is not worth discovering on the first
|
|
367
|
+
// request, where only a runtime error can report it.
|
|
368
|
+
if (credentialAuth && !HEADER_NAME_TOKEN.test(credentialHeader)) {
|
|
369
|
+
throw new Error(`[connecta] connector "${id}" credential auth declares header ` +
|
|
370
|
+
`"${credentialHeader}", which is not a valid HTTP field name.`);
|
|
371
|
+
}
|
|
372
|
+
// `undefined` means "not stated" and takes the bearer default; `null` and
|
|
373
|
+
// `""` both mean "send the stored value verbatim", which some providers
|
|
374
|
+
// require for a bare API key.
|
|
375
|
+
const credentialScheme = credentialAuth === undefined || credentialAuth.scheme === undefined
|
|
376
|
+
? "Bearer"
|
|
377
|
+
: (credentialAuth.scheme?.trim() ?? "") || null;
|
|
276
378
|
// Check the destination scheme once at construction: buildTransport (and the
|
|
277
379
|
// SDK's fetch) attach any static credentials to every request, so an http://
|
|
278
380
|
// endpoint sends bearer tokens / API keys in cleartext. Loopback is exempt
|
|
@@ -283,7 +385,10 @@ export function remoteMcp(id, opts) {
|
|
|
283
385
|
if (opts.requireHttps) {
|
|
284
386
|
throw new Error(`[connecta] connector "${id}" url ${opts.url} is not https:// (and not loopback) — refusing to connect (requireHttps).`);
|
|
285
387
|
}
|
|
286
|
-
|
|
388
|
+
// Both static shapes send a secret on every request; that an operator
|
|
389
|
+
// typed one into /credentials rather than a deployment file changes who
|
|
390
|
+
// owns it, not whether the wire carries it in the clear.
|
|
391
|
+
if (opts.auth?.type === "headers" || credentialAuth) {
|
|
287
392
|
logger.warn(`[connecta] connector "${id}" sends static credentials to ${opts.url} over a non-https:// connection — those tokens will be transmitted in cleartext.`);
|
|
288
393
|
}
|
|
289
394
|
}
|
|
@@ -295,6 +400,77 @@ export function remoteMcp(id, opts) {
|
|
|
295
400
|
}
|
|
296
401
|
}
|
|
297
402
|
const operatorDisconnectedError = () => new OperatorDisconnectedError();
|
|
403
|
+
/**
|
|
404
|
+
* The credential slot is declared but the vault has nothing in it (or has no
|
|
405
|
+
* key to read it with). Deliberately the same `auth_required` code an absent
|
|
406
|
+
* OAuth grant produces: the agent's next move is `authorize_connector`
|
|
407
|
+
* either way, and that tool reads `connector.credential` to hand the
|
|
408
|
+
* operator `/credentials` instead of a consent URL.
|
|
409
|
+
*/
|
|
410
|
+
class CredentialRequiredError extends ConnectorCallError {
|
|
411
|
+
constructor(message) {
|
|
412
|
+
super("auth_required", message);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Read this request's credential. Called before every connect and before
|
|
417
|
+
* trusting any cached client, so an operator's replacement is picked up
|
|
418
|
+
* without a redeploy. The value stays in the caller's local scope.
|
|
419
|
+
*/
|
|
420
|
+
const readCredential = async (ctx) => {
|
|
421
|
+
if (!ctx.credential) {
|
|
422
|
+
throw new CredentialRequiredError(`Connector "${id}" needs an operator-managed credential, but ` +
|
|
423
|
+
"credential storage is not configured. Set " +
|
|
424
|
+
"credentials.encryptionKey and redeploy.");
|
|
425
|
+
}
|
|
426
|
+
// A stored-shape mismatch already arrives as a typed auth_required from
|
|
427
|
+
// the registry's accessor; nothing to reclassify here.
|
|
428
|
+
const value = (await ctx.credential.get())?.trim();
|
|
429
|
+
if (!value) {
|
|
430
|
+
throw new CredentialRequiredError(`Connector "${id}" has no stored credential — call ` +
|
|
431
|
+
`authorize_connector({ connector: "${id}" }) and follow the ` +
|
|
432
|
+
"operator handoff it returns.");
|
|
433
|
+
}
|
|
434
|
+
// Refuse a value a header cannot carry BEFORE anything frames it. A
|
|
435
|
+
// wrapped newline in a pasted key is the ordinary way this happens, and
|
|
436
|
+
// the runtime that rejects the header quotes the whole value back in its
|
|
437
|
+
// TypeError — a message that travels to status, to the activity log, and
|
|
438
|
+
// to the agent. So the check lives here, and says only what is wrong.
|
|
439
|
+
if (carriesIllegalHeaderChar(value)) {
|
|
440
|
+
throw new CredentialRequiredError(`Connector "${id}"'s stored credential contains a character a header ` +
|
|
441
|
+
"cannot carry (a line break or other control character) — re-enter " +
|
|
442
|
+
"it on /credentials. The value is not shown or logged.");
|
|
443
|
+
}
|
|
444
|
+
return value;
|
|
445
|
+
};
|
|
446
|
+
/**
|
|
447
|
+
* Replace, never edit.
|
|
448
|
+
*
|
|
449
|
+
* Any error whose message quotes the credential — the raw value or the
|
|
450
|
+
* framed header it becomes — is discarded whole and replaced with a fixed
|
|
451
|
+
* sentence. Nothing is substringed, masked, or truncated out of the original:
|
|
452
|
+
* a redaction that keeps part of a secret is still a leak, and the original
|
|
453
|
+
* error is not worth one. This is defense in depth behind the validation
|
|
454
|
+
* above, which is what keeps an unsendable value from reaching a transport
|
|
455
|
+
* at all.
|
|
456
|
+
*/
|
|
457
|
+
const withoutCredential = (err, ...secrets) => {
|
|
458
|
+
const quoted = secrets.filter((secret) => typeof secret === "string" && secret !== "");
|
|
459
|
+
if (quoted.length === 0)
|
|
460
|
+
return err;
|
|
461
|
+
const seen = new Set();
|
|
462
|
+
let current = err;
|
|
463
|
+
while (current instanceof Error && !seen.has(current)) {
|
|
464
|
+
seen.add(current);
|
|
465
|
+
if (quoted.some((secret) => current instanceof Error && current.message.includes(secret))) {
|
|
466
|
+
return new CredentialRequiredError(`Connector "${id}" could not send its stored credential as a ` +
|
|
467
|
+
"header — re-enter it on /credentials. The value is not shown or " +
|
|
468
|
+
"logged.");
|
|
469
|
+
}
|
|
470
|
+
current = current.cause;
|
|
471
|
+
}
|
|
472
|
+
return err;
|
|
473
|
+
};
|
|
298
474
|
const scopeEndedError = () => new Error(`Connector "${id}" scope ended during connection.`);
|
|
299
475
|
const requestOptions = (ctx) => ctx.timeoutMs || ctx.signal
|
|
300
476
|
? {
|
|
@@ -334,6 +510,7 @@ export function remoteMcp(id, opts) {
|
|
|
334
510
|
authRequired: false,
|
|
335
511
|
provider: null,
|
|
336
512
|
connectedGeneration: null,
|
|
513
|
+
credentialDigest: null,
|
|
337
514
|
closed: false,
|
|
338
515
|
};
|
|
339
516
|
states.set(scope, state);
|
|
@@ -345,7 +522,13 @@ export function remoteMcp(id, opts) {
|
|
|
345
522
|
return state.provider;
|
|
346
523
|
};
|
|
347
524
|
const newProvider = (ctx) => new KvOAuthProvider(id, ctx.storage, `${ctx.baseUrl}/oauth/callback/${id}`);
|
|
348
|
-
const buildTransport = (ctx, provider
|
|
525
|
+
const buildTransport = (ctx, provider,
|
|
526
|
+
/**
|
|
527
|
+
* This attempt's assembled header value, for credential auth only — framed
|
|
528
|
+
* by the caller so the raw secret is not passed around twice. Never
|
|
529
|
+
* retained past the transport it configures.
|
|
530
|
+
*/
|
|
531
|
+
credentialFramed = null) => {
|
|
349
532
|
if (opts._transportFactory)
|
|
350
533
|
return opts._transportFactory(ctx);
|
|
351
534
|
const url = new URL(opts.url);
|
|
@@ -356,7 +539,11 @@ export function remoteMcp(id, opts) {
|
|
|
356
539
|
fetch: guardedFetch,
|
|
357
540
|
});
|
|
358
541
|
}
|
|
359
|
-
const headers = opts.auth?.type === "headers"
|
|
542
|
+
const headers = opts.auth?.type === "headers"
|
|
543
|
+
? opts.auth.headers
|
|
544
|
+
: credentialAuth && credentialFramed !== null
|
|
545
|
+
? { [credentialHeader]: credentialFramed }
|
|
546
|
+
: undefined;
|
|
360
547
|
return new StreamableHTTPClientTransport(url, {
|
|
361
548
|
...(headers ? { requestInit: { headers } } : {}),
|
|
362
549
|
fetch: guardedFetch,
|
|
@@ -370,6 +557,7 @@ export function remoteMcp(id, opts) {
|
|
|
370
557
|
state.authRequired = false;
|
|
371
558
|
state.provider = null;
|
|
372
559
|
state.connectedGeneration = null;
|
|
560
|
+
state.credentialDigest = null;
|
|
373
561
|
// `closed` is deliberately not cleared — see ConnectionState.
|
|
374
562
|
};
|
|
375
563
|
const ensureConnected = async (ctx, state) => {
|
|
@@ -415,6 +603,43 @@ export function remoteMcp(id, opts) {
|
|
|
415
603
|
reset(state);
|
|
416
604
|
}
|
|
417
605
|
}
|
|
606
|
+
// The static-credential counterpart of the epoch read above, and
|
|
607
|
+
// deliberately beside it: the vault is read before any cached client is
|
|
608
|
+
// trusted, so an operator's rotation on /credentials takes effect on the
|
|
609
|
+
// next call rather than the next deploy. Compared by digest — the
|
|
610
|
+
// plaintext lives in this function's scope and never reaches `state`.
|
|
611
|
+
let credentialValue = null;
|
|
612
|
+
let credentialFramed = null;
|
|
613
|
+
let credentialDigest = null;
|
|
614
|
+
if (credentialAuth) {
|
|
615
|
+
credentialValue = await readCredential(ctx);
|
|
616
|
+
credentialFramed = credentialHeaderValue(credentialScheme, credentialValue);
|
|
617
|
+
credentialDigest = await digestOf(credentialValue);
|
|
618
|
+
// Gated on a connect in flight as well as a cached client, exactly like
|
|
619
|
+
// the epoch read above: a rotation that lands while the first caller is
|
|
620
|
+
// still connecting must not let the second one ride the key the vault
|
|
621
|
+
// has already replaced.
|
|
622
|
+
if ((state.client || state.connecting) &&
|
|
623
|
+
state.credentialDigest !== null &&
|
|
624
|
+
state.credentialDigest !== credentialDigest) {
|
|
625
|
+
if (state.closed)
|
|
626
|
+
throw scopeEndedError();
|
|
627
|
+
const connecting = state.connecting;
|
|
628
|
+
const client = state.client;
|
|
629
|
+
const transport = state.transport;
|
|
630
|
+
reset(state);
|
|
631
|
+
void connecting?.catch(() => { });
|
|
632
|
+
try {
|
|
633
|
+
if (client)
|
|
634
|
+
await client.close();
|
|
635
|
+
else
|
|
636
|
+
await transport?.close();
|
|
637
|
+
}
|
|
638
|
+
catch {
|
|
639
|
+
// The rotated-away client is discarded either way.
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
}
|
|
418
643
|
if (state.closed)
|
|
419
644
|
throw scopeEndedError();
|
|
420
645
|
if (state.client)
|
|
@@ -458,7 +683,7 @@ export function remoteMcp(id, opts) {
|
|
|
458
683
|
// below as one structured, non-retryable connector failure.
|
|
459
684
|
inputRequired: { autoFulfill: false },
|
|
460
685
|
});
|
|
461
|
-
const t = buildTransport(ctx, provider);
|
|
686
|
+
const t = buildTransport(ctx, provider, credentialFramed);
|
|
462
687
|
if (!ownsAttempt())
|
|
463
688
|
await abandon(t);
|
|
464
689
|
state.transport = t;
|
|
@@ -506,7 +731,11 @@ export function remoteMcp(id, opts) {
|
|
|
506
731
|
if (err instanceof UnauthorizedError) {
|
|
507
732
|
throw authRequiredError(err);
|
|
508
733
|
}
|
|
509
|
-
|
|
734
|
+
// Defense in depth for the one error class that can quote the
|
|
735
|
+
// credential: a runtime refusing the assembled header. `readCredential`
|
|
736
|
+
// already rejects a value that cannot ride one, so reaching this is a
|
|
737
|
+
// gap in that check rather than a routine outcome.
|
|
738
|
+
throw withoutCredential(err, credentialValue, credentialFramed);
|
|
510
739
|
}
|
|
511
740
|
finally {
|
|
512
741
|
// Force reset may have abandoned this attempt and installed a new one
|
|
@@ -517,6 +746,10 @@ export function remoteMcp(id, opts) {
|
|
|
517
746
|
}
|
|
518
747
|
})();
|
|
519
748
|
state.connecting = attempt;
|
|
749
|
+
// Published with the attempt, not with its result: a rotation that lands
|
|
750
|
+
// while this connect is in flight has to be visible to the next caller,
|
|
751
|
+
// which would otherwise wait on a client bound to the older key.
|
|
752
|
+
state.credentialDigest = credentialDigest;
|
|
520
753
|
}
|
|
521
754
|
return state.connecting;
|
|
522
755
|
};
|
|
@@ -562,6 +795,54 @@ export function remoteMcp(id, opts) {
|
|
|
562
795
|
? { callAdmission: opts.callAdmission }
|
|
563
796
|
: {}),
|
|
564
797
|
...(opts.usageGuide !== undefined ? { usageGuide: opts.usageGuide } : {}),
|
|
798
|
+
// Declaring the slot is what makes the rest of the operator surface work:
|
|
799
|
+
// /credentials renders it, the shape check compares against it, and
|
|
800
|
+
// authorize_connector reads it to return the operator handoff rather than
|
|
801
|
+
// an OAuth URL this connector has none of.
|
|
802
|
+
...(credentialAuth
|
|
803
|
+
? {
|
|
804
|
+
credential: credentialConfig,
|
|
805
|
+
/**
|
|
806
|
+
* The honest test for a proxy is the catalog: connect with the
|
|
807
|
+
* stored value and count what the downstream serves. Nothing else
|
|
808
|
+
* here is connecta's to verify — the credential's scope, project,
|
|
809
|
+
* and mode are the provider's answer, not ours.
|
|
810
|
+
*/
|
|
811
|
+
testCredential: async (value, ctx) => {
|
|
812
|
+
try {
|
|
813
|
+
// The connect below reads the vault itself — the header is
|
|
814
|
+
// assembled deep inside `ensureConnected`, and handing a
|
|
815
|
+
// candidate down that path would mean threading a second secret
|
|
816
|
+
// through the whole connection state. `/ui/credentials/<id>/test`
|
|
817
|
+
// reads the stored value and passes it here, so today the two are
|
|
818
|
+
// the same string. Check rather than assume: a route that later
|
|
819
|
+
// tested an unsaved candidate would otherwise silently report on
|
|
820
|
+
// the old value, which is the one answer worse than refusing.
|
|
821
|
+
const stored = (await ctx.credential?.get())?.trim();
|
|
822
|
+
if (stored !== value.trim()) {
|
|
823
|
+
return {
|
|
824
|
+
ok: false,
|
|
825
|
+
message: "This connector tests the credential that is currently " +
|
|
826
|
+
"saved. Save the value first, then test it.",
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
const tools = await connector.listTools(ctx);
|
|
830
|
+
return {
|
|
831
|
+
ok: true,
|
|
832
|
+
message: `Connected — the downstream served ${tools.length} tool${tools.length === 1 ? "" : "s"}.`,
|
|
833
|
+
};
|
|
834
|
+
}
|
|
835
|
+
catch (err) {
|
|
836
|
+
return { ok: false, message: msg(err) };
|
|
837
|
+
}
|
|
838
|
+
finally {
|
|
839
|
+
// A test owns the scope it just opened; leaving the session for
|
|
840
|
+
// the downstream to age out is not this button's to spend.
|
|
841
|
+
await connector.closeScope?.(ctx);
|
|
842
|
+
}
|
|
843
|
+
},
|
|
844
|
+
}
|
|
845
|
+
: {}),
|
|
565
846
|
// `tools/list` is cursor-paginated: the server chooses the page size and
|
|
566
847
|
// signals "there is more" with a `nextCursor`, which the SDK's
|
|
567
848
|
// Client.listTools() returns without following. Collect the whole chain
|
|
@@ -744,6 +1025,7 @@ export function remoteMcp(id, opts) {
|
|
|
744
1025
|
state.connecting = null;
|
|
745
1026
|
state.authRequired = false;
|
|
746
1027
|
state.connectedGeneration = null;
|
|
1028
|
+
state.credentialDigest = null;
|
|
747
1029
|
// Ask the downstream to drop its session first — closing only aborts our
|
|
748
1030
|
// side, and the DELETE that frees the server's rides on the very
|
|
749
1031
|
// AbortSignal the close is about to trip.
|
|
@@ -766,12 +1048,25 @@ export function remoteMcp(id, opts) {
|
|
|
766
1048
|
return { state: "ok" };
|
|
767
1049
|
}
|
|
768
1050
|
catch (err) {
|
|
1051
|
+
// An empty slot, or one with no vault behind it, is reported the way a
|
|
1052
|
+
// missing grant is: present, unauthenticated, and repairable — never a
|
|
1053
|
+
// boot failure and never a silently absent connector.
|
|
1054
|
+
if (err instanceof CredentialRequiredError) {
|
|
1055
|
+
return { state: "auth_required", message: err.message };
|
|
1056
|
+
}
|
|
769
1057
|
if (state.authRequired) {
|
|
770
|
-
|
|
1058
|
+
// Only an OAuth connector has a pending consent URL to offer. A
|
|
1059
|
+
// credential connector's downstream 401 is repaired on /credentials,
|
|
1060
|
+
// so do not reach into OAuth storage to look for one.
|
|
1061
|
+
const url = isOauth
|
|
1062
|
+
? await getProvider(ctx, state).pendingAuthorizationUrl()
|
|
1063
|
+
: undefined;
|
|
771
1064
|
return {
|
|
772
1065
|
state: "auth_required",
|
|
773
1066
|
...(url !== undefined ? { authorizationUrl: url } : {}),
|
|
774
|
-
message:
|
|
1067
|
+
message: credentialAuth
|
|
1068
|
+
? "Authorization required — the downstream rejected this connector's stored credential."
|
|
1069
|
+
: "Authorization required — open the URL to connect.",
|
|
775
1070
|
};
|
|
776
1071
|
}
|
|
777
1072
|
if (err instanceof OperatorDisconnectedError) {
|
|
@@ -32,7 +32,13 @@ export interface LinearOptions {
|
|
|
32
32
|
* ([#342](https://github.com/zackbart/connecta/issues/342)).
|
|
33
33
|
*/
|
|
34
34
|
access: LinearAccess;
|
|
35
|
-
/**
|
|
35
|
+
/**
|
|
36
|
+
* OAuth by default. A Linear personal API key works either as a literal
|
|
37
|
+
* header or as an operator-managed credential (`{ type: "credential" }`).
|
|
38
|
+
* Linear's MCP documentation asks for `Authorization: Bearer <yourtoken>`
|
|
39
|
+
* for both API keys and OAuth tokens (https://linear.app/docs/mcp), which is
|
|
40
|
+
* the framing default, so the credential shape needs no `scheme` of its own.
|
|
41
|
+
*/
|
|
36
42
|
auth?: RemoteMcpAuth;
|
|
37
43
|
/** Workspace-specific conventions appended to the maintained provider guide. */
|
|
38
44
|
instructions?: string;
|
package/dist/providers/linear.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { remoteMcp, } from "../connectors/remote-mcp.js";
|
|
1
|
+
import { remoteMcp, withCredentialDefaults, } from "../connectors/remote-mcp.js";
|
|
2
2
|
import { vettedCatalog, withVettedCatalog } from "../catalog-drift.js";
|
|
3
3
|
/**
|
|
4
4
|
* Linear publishes two hosted endpoints. `read-only` is not a client-side
|
|
@@ -189,7 +189,17 @@ export function linear(id, options) {
|
|
|
189
189
|
description: access === "read-only"
|
|
190
190
|
? `Linear issue tracking and project planning (read-only) — ${purpose}`
|
|
191
191
|
: `Linear issue tracking and project planning — ${purpose}`,
|
|
192
|
-
|
|
192
|
+
// Linear's MCP endpoint takes an API key the same way it takes an OAuth
|
|
193
|
+
// token — `Authorization: Bearer <yourtoken>` — so only the slot copy is
|
|
194
|
+
// provider-specific and the bearer framing default stands. The bare-header
|
|
195
|
+
// convention belongs to Linear's GraphQL API, not to this endpoint.
|
|
196
|
+
auth: withCredentialDefaults(options.auth ?? { type: "oauth" }, {
|
|
197
|
+
credential: {
|
|
198
|
+
label: "Personal API key",
|
|
199
|
+
description: "A Linear personal API key. It carries the issuing user's full workspace access and is stored encrypted; the read-only endpoint still limits what it can reach.",
|
|
200
|
+
placeholder: "lin_api_…",
|
|
201
|
+
},
|
|
202
|
+
}),
|
|
193
203
|
requireHttps: true,
|
|
194
204
|
usageGuide: {
|
|
195
205
|
content: usageGuide(purpose, access, options.instructions),
|
|
@@ -18,7 +18,11 @@ export interface MixpanelOptions {
|
|
|
18
18
|
* rather than a convenient one.
|
|
19
19
|
*/
|
|
20
20
|
region?: MixpanelRegion;
|
|
21
|
-
/**
|
|
21
|
+
/**
|
|
22
|
+
* OAuth by default; static headers support Mixpanel service accounts, and
|
|
23
|
+
* `{ type: "credential" }` takes the same service account as an
|
|
24
|
+
* operator-managed `username:secret` that Connecta frames for the endpoint.
|
|
25
|
+
*/
|
|
22
26
|
auth?: RemoteMcpAuth;
|
|
23
27
|
/** Account-specific conventions appended to the maintained provider guide. */
|
|
24
28
|
instructions?: string;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { remoteMcp, } from "../connectors/remote-mcp.js";
|
|
1
|
+
import { remoteMcp, withCredentialDefaults, } from "../connectors/remote-mcp.js";
|
|
2
2
|
import { vettedCatalog, withVettedCatalog } from "../catalog-drift.js";
|
|
3
3
|
export const MIXPANEL_MCP_ENDPOINTS = {
|
|
4
4
|
us: "https://mcp.mixpanel.com/mcp",
|
|
@@ -184,7 +184,10 @@ Account purpose: ${purpose}
|
|
|
184
184
|
- \`Get-Business-Context\` requires either \`project_id\` or \`organization_id\`. Its schema marks both optional, but the hosted tool rejects a call with neither.
|
|
185
185
|
- \`Get-Property-Values\` requires \`properties\` or the deprecated \`property\` alias. Event property values also require \`event\`; prefer \`properties\` and never send both property forms with conflicting values.
|
|
186
186
|
- \`List-Properties\` accepts \`names\` or \`query\`, never both. Use exact \`names\` for known properties and \`query\` for substring discovery.
|
|
187
|
-
-
|
|
187
|
+
- One analysis is one \`execute_code\` program: fetch \`Get-Query-Schema\` once, run every \`Run-Query\` of the analysis in that program, and return the reduced table. The schema is tens of kilobytes and the same for every report type, so re-fetching it per query buys nothing. Never return raw \`Run-Query\` output.
|
|
188
|
+
- Insights, funnels, and retention answer aggregate questions. A per-user ordered event timeline, or a sequence question such as "event A with no later event B", is not answerable with \`Run-Query\` in a reasonable number of calls, and this hosted catalog has no per-\`distinct_id\` event timeline — \`Get-User-Replays-Data\` covers one user's replays with their events only where session replay is enabled and present. If the deployment exposes a Mixpanel export or activity-feed connector, use that; if it does not, tell the user the question is out of reach here rather than approximating it with hourly buckets and hundreds of empty rows.
|
|
189
|
+
- \`false\` on a boolean property may be an absent property: Mixpanel renders a missing value as \`false\` in boolean breakdowns, and server-imported events often lack client-side properties entirely. Confirm the property is present with \`List-Properties\` or \`Get-Property-Values\` before treating \`false\` as a signal, and say when a conclusion rests on that ambiguity.
|
|
190
|
+
- Breakdown responses nest \`$overall\` and per-segment series objects. Flatten to one row per complete breakdown combination inside \`execute_code\` before returning, and drop \`$overall\` unless the question asks for the total.
|
|
188
191
|
- Use \`Get-Report\` when the request names an existing saved report. Use \`Run-Query\` for a new question.
|
|
189
192
|
- This account's tool list is not a fixed set. Mixpanel gates parts of its MCP catalog by plan and beta enrollment — experiments, feature flags, session replay, and issue triage are the usual absentees — so search this connector for what it actually exposes rather than assuming a documented tool is here.
|
|
190
193
|
- Mixpanel meters MCP traffic per user per hour, shared with everything else that credential does. Reuse discovery results within a run and avoid speculative fan-out.
|
|
@@ -211,7 +214,18 @@ export function mixpanel(id, options) {
|
|
|
211
214
|
// an agent must not get wrong between two Mixpanel connections.
|
|
212
215
|
title: options.title ?? `Mixpanel (${region})`,
|
|
213
216
|
description: `Mixpanel product analytics (${REGION_COPY[region]} residency) — ${purpose}`,
|
|
214
|
-
|
|
217
|
+
// Mixpanel's beta service-account scheme is deliberately not ordinary HTTP
|
|
218
|
+
// Basic: the endpoint wants `Bearer Basic <base64(user:secret)>`. The
|
|
219
|
+
// operator therefore pastes the pair, not an encoded blob, and Connecta
|
|
220
|
+
// does the framing — the same shaping the maintainer drift check applies.
|
|
221
|
+
auth: withCredentialDefaults(options.auth ?? { type: "oauth" }, {
|
|
222
|
+
credential: {
|
|
223
|
+
label: "Service account",
|
|
224
|
+
description: "A Mixpanel service account as `username:secret`. Connecta encodes and frames it the way the hosted endpoint requires; it is stored encrypted and never displayed.",
|
|
225
|
+
placeholder: "username:secret",
|
|
226
|
+
},
|
|
227
|
+
scheme: "Bearer Basic",
|
|
228
|
+
}),
|
|
215
229
|
requireHttps: true,
|
|
216
230
|
usageGuide: {
|
|
217
231
|
content: usageGuide(purpose, region, options.instructions),
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { type RemoteMcpAuth } from "../connectors/remote-mcp.js";
|
|
2
|
+
import type { Connector, ConnectorCallAdmissionPolicy } from "../types.js";
|
|
3
|
+
/** RevenueCat publishes one hosted MCP endpoint, streamable HTTP. */
|
|
4
|
+
export declare const REVENUECAT_MCP_ENDPOINT = "https://mcp.revenuecat.ai/mcp";
|
|
5
|
+
export interface RevenueCatOptions {
|
|
6
|
+
/**
|
|
7
|
+
* Human-readable display name; defaults to "RevenueCat" for OAuth and
|
|
8
|
+
* "RevenueCat (single project)" for a static API v2 secret key. The scope
|
|
9
|
+
* shape rides the title because it is the one routing fact connecta can
|
|
10
|
+
* know at construction: an `sk_` key reaches exactly one project, an OAuth
|
|
11
|
+
* session reaches every project the account can. *Which* project a key
|
|
12
|
+
* reaches is not knowable here (P10 — no credential test), so the guide's
|
|
13
|
+
* first line carries the operator's stated purpose instead.
|
|
14
|
+
*/
|
|
15
|
+
title?: string;
|
|
16
|
+
/**
|
|
17
|
+
* Which project this connector is for and what decisions it answers. With
|
|
18
|
+
* headers auth this is the only place the project a key reaches is named,
|
|
19
|
+
* so it goes in the guide's first line and its summary.
|
|
20
|
+
*/
|
|
21
|
+
purpose: string;
|
|
22
|
+
/**
|
|
23
|
+
* OAuth by default; static headers support a RevenueCat API v2 secret key
|
|
24
|
+
* as `Authorization: Bearer sk_…`. `{ type: "credential" }` is the same
|
|
25
|
+
* single-project scope with the key pasted at `/credentials` instead — which
|
|
26
|
+
* is what two projects on two keys wants, since each is its own connector.
|
|
27
|
+
*/
|
|
28
|
+
auth?: RemoteMcpAuth;
|
|
29
|
+
/** Project-specific conventions appended to the maintained provider guide. */
|
|
30
|
+
instructions?: string;
|
|
31
|
+
/** Connector-specific inline result limit; omit to inherit the deployment. */
|
|
32
|
+
maxResultBytes?: number;
|
|
33
|
+
/**
|
|
34
|
+
* Optional per-runtime call-admission policy. Deliberately not defaulted,
|
|
35
|
+
* even though RevenueCat does publish numbers.
|
|
36
|
+
*
|
|
37
|
+
* API v2 meters per *domain*, and the domains disagree by a factor of
|
|
38
|
+
* nineteen: Customer Information 480/min, Virtual Currencies 480/min,
|
|
39
|
+
* Subscription Transactions Refunds 480/min, Audiences 60/min, Project
|
|
40
|
+
* Configuration 60/min, Charts & Metrics 25/min
|
|
41
|
+
* (https://www.revenuecat.com/docs/api-v2#tag/Rate-Limit, read 2026-08-18).
|
|
42
|
+
* A `ConnectorCallAdmissionPolicy` carries exactly one rule, so a
|
|
43
|
+
* connector-wide budget has to pick one of those six numbers for all ninety-
|
|
44
|
+
* five tools. Transcribing 25 would throttle a customer read loop to a
|
|
45
|
+
* nineteenth of its documented allowance; transcribing 480 would leave a
|
|
46
|
+
* chart sweep unprotected. Neither is the provider's limit, and both would
|
|
47
|
+
* look like RevenueCat being flaky.
|
|
48
|
+
*
|
|
49
|
+
* The metering scope says the same thing again: the limit applies per API
|
|
50
|
+
* key for app-level keys and per *developer* for developer-level keys, so
|
|
51
|
+
* an OAuth session shares one budget with everything else that developer
|
|
52
|
+
* does — which a per-runtime counter cannot approximate in either
|
|
53
|
+
* direction. So the number stays with the operator who knows the account,
|
|
54
|
+
* exactly as P12 prescribes; `documentation/revenuecat.md` shows how to
|
|
55
|
+
* supply one.
|
|
56
|
+
*/
|
|
57
|
+
callAdmission?: ConnectorCallAdmissionPolicy;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The manifest this release reviewed: both lists in one place, which is what
|
|
61
|
+
* makes the classification the connector applies and the drift check that runs
|
|
62
|
+
* beside it the same fact (P13). Ninety-four of the ninety-five tools
|
|
63
|
+
* RevenueCat's reference lists on 2026-08-18 are classified; the ninety-fifth,
|
|
64
|
+
* `render-paywall-screenshot`, has no access column to classify from and fails
|
|
65
|
+
* closed.
|
|
66
|
+
*
|
|
67
|
+
* No schema digests. No release has read RevenueCat's live schemas and written
|
|
68
|
+
* them down — that needs a live project and a maintainer's own `sk_` key — and
|
|
69
|
+
* an invented digest would report a change that never happened.
|
|
70
|
+
* `npm run drift:check -- --record` reads them from a live catalog and prints
|
|
71
|
+
* the block to paste in
|
|
72
|
+
* ([#351](https://github.com/zackbart/connecta/issues/351)).
|
|
73
|
+
*
|
|
74
|
+
* Exported because the maintainer-run check compares against this manifest and
|
|
75
|
+
* *names* what moved, which the runtime check deliberately cannot.
|
|
76
|
+
*/
|
|
77
|
+
export declare const REVENUECAT_VETTED_CATALOG: import("../catalog-drift.js").VettedCatalog;
|
|
78
|
+
/** A maintained RevenueCat hosted-MCP connection. */
|
|
79
|
+
export declare function revenuecat(id: string, options: RevenueCatOptions): Connector;
|