@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.
@@ -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
- if (opts.auth?.type === "headers") {
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" ? opts.auth.headers : undefined;
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
- throw err;
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
- const url = await getProvider(ctx, state).pendingAuthorizationUrl();
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: "Authorization required — open the URL to connect.",
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
- /** OAuth by default; static headers support a Linear personal API key. */
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;
@@ -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
- auth: options.auth ?? { type: "oauth" },
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
- /** OAuth by default; static headers support Mixpanel service accounts. */
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
- - For a new analysis, fetch \`Get-Query-Schema\` before \`Run-Query\`. Reduce query results inside \`execute_code\` before returning them.
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
- auth: options.auth ?? { type: "oauth" },
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;