@zackbart/connecta 0.18.1 → 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 CHANGED
@@ -2,6 +2,59 @@
2
2
 
3
3
  All notable changes to this package are documented here.
4
4
 
5
+ ## 0.18.2 — 2026-08-18
6
+
7
+ This patch closes the gap the RevenueCat rollout exposed on the same day 0.18.1
8
+ shipped: a hosted-MCP connection could authenticate with OAuth from the
9
+ Connections page or with a header literal baked into the deployment file, but
10
+ never with a key an operator pastes on `/credentials` the way every `api()`
11
+ connector already can. Now it can. Nothing breaks, no constructor changes, and a
12
+ deployment that keeps its static keys in runtime secrets can ignore this release.
13
+ Two things are worth reading before adopting the new shape: the operator vault
14
+ (`credentials.encryptionKey`) must be configured, and a key pasted with a
15
+ wrapped newline is refused before it is framed rather than echoed back by the
16
+ runtime that rejects it.
17
+
18
+ ### Added
19
+
20
+ - **`auth: { type: "credential" }` for `remoteMcp()` and every maintained
21
+ hosted connection.** The connector declares an operator credential slot on
22
+ `/credentials` (label and guidance from the provider, overridable), reads the
23
+ stored value on every request before trusting a cached client, and frames it
24
+ as `Authorization: Bearer <value>` by default — `header` and `scheme` are
25
+ configurable, `scheme: null` sends the value bare, and a scheme ending in
26
+ `Basic` base64-encodes it, which is how Mixpanel's documented
27
+ `Bearer Basic <base64(user:secret)>` is spelled. A missing or empty value is
28
+ `auth_required`, and `authorize_connector` returns the operator handoff to
29
+ `/credentials`; a deployment without a vault warns at construction and answers
30
+ `recovery: "unavailable"` at use. Rotation on `/credentials` takes effect on
31
+ the next call: a SHA-256 digest of the connected value is compared per
32
+ request (including against an in-flight connect) and a differing digest
33
+ closes the old client. The `/credentials` Test action connects with the
34
+ candidate and counts the catalog, and only when an operator presses it — the
35
+ #179 refusal of proactive probing stands, now written into P10. Provider
36
+ defaults: Stripe (requires `mode`, refuses `connectedAccount`), Linear
37
+ (Bearer, per Linear's MCP docs), Mixpanel (`Bearer Basic`, service account),
38
+ RevenueCat (single-project branch, "API v2 secret key") (#439).
39
+
40
+ ### Changed
41
+
42
+ - **A stored credential never reaches an agent or operator surface, even
43
+ malformed.** The value is refused before framing if it carries a control
44
+ character (the wrapped-newline paste), with a message that names the problem
45
+ and not the value; behind that, any error whose message or `cause` chain
46
+ quotes the raw or framed value is replaced whole rather than redacted. Both
47
+ layers are tested independently (#439).
48
+
49
+ - **Provider conventions P9 and P10 say what is now true.** P9 names both
50
+ headless shapes and requires the framing to match the provider's published
51
+ MCP contract; P10 is retitled to allow an operator-requested credential test
52
+ while forbidding any unasked probe (#439).
53
+
54
+ - **Linear's headers example uses `Bearer`.** Linear's MCP server documents
55
+ `Authorization: Bearer <token>` for API keys; the bare form is its GraphQL
56
+ convention. Existing `headers` connectors are untouched (#439).
57
+
5
58
  ## 0.18.1 — 2026-08-18
6
59
 
7
60
  This patch is the response to one long investigation run against a live
@@ -1,12 +1,58 @@
1
1
  import type { FetchLike, Transport } from "@modelcontextprotocol/client";
2
2
  import { ConnectorCallError } from "../errors.js";
3
- import type { Connector, ConnectorCallAdmissionPolicy, ConnectorContext, ConnectorUsageGuide, Logger } from "../types.js";
3
+ import type { Connector, ConnectorCallAdmissionPolicy, ConnectorContext, ConnectorCredentialConfig, ConnectorUsageGuide, Logger } from "../types.js";
4
+ /**
5
+ * A static downstream credential the operator supplies at `/credentials`
6
+ * rather than the deployment baking into its source.
7
+ *
8
+ * The connector, its endpoint, and the credential *slot* stay declared in
9
+ * code; only the secret arrives through the operator route, exactly as for
10
+ * `api()`. One reserved `value` field, deliberately: a header is assembled
11
+ * from a name, a framing scheme, and one secret, and anything that needs two
12
+ * secrets composed into one header is a provider integration, not a proxy
13
+ * config ([#439](https://github.com/zackbart/connecta/issues/439)).
14
+ */
15
+ interface RemoteMcpCredentialAuth {
16
+ type: "credential";
17
+ /**
18
+ * Operator-facing slot description rendered on `/credentials`. Defaults to
19
+ * `{ label: "API key" }`; a maintained provider passes the name the provider
20
+ * itself uses. Named `fields` are refused — this shape reads the reserved
21
+ * `value` field only.
22
+ */
23
+ credential?: ConnectorCredentialConfig;
24
+ /** Header the credential rides. Defaults to `Authorization`. */
25
+ header?: string;
26
+ /**
27
+ * Framing token placed before the value. Defaults to `"Bearer"`. `null` (or
28
+ * an empty string) sends the stored value verbatim, for an endpoint that
29
+ * reads a bare key. A scheme whose last token is `Basic` declares
30
+ * HTTP Basic credentials: the stored `user:secret` is base64-encoded first,
31
+ * so `"Basic"` produces `Basic <base64>` and Mixpanel's documented
32
+ * `"Bearer Basic"` produces `Bearer Basic <base64>`.
33
+ */
34
+ scheme?: string | null;
35
+ }
4
36
  export type RemoteMcpAuth = {
5
37
  type: "headers";
6
38
  headers: Record<string, string>;
7
- } | {
39
+ } | RemoteMcpCredentialAuth | {
8
40
  type: "oauth";
9
41
  };
42
+ /**
43
+ * Apply a maintained provider's slot copy and header framing to credential
44
+ * auth the deployment left bare.
45
+ *
46
+ * A provider knows what its own key is called and how the endpoint expects it
47
+ * framed; a deployment that states either one keeps its answer. Every other
48
+ * auth shape passes through untouched, so a provider can hand this its whole
49
+ * `auth` option without branching first.
50
+ */
51
+ export declare function withCredentialDefaults(auth: RemoteMcpAuth, defaults: {
52
+ credential: ConnectorCredentialConfig;
53
+ /** Provider framing; omit to leave the `Bearer` default in place. */
54
+ scheme?: string | null;
55
+ }): RemoteMcpAuth;
10
56
  export type RemoteMcpRedirectPolicy = "none" | "same-origin";
11
57
  export interface RemoteMcpOptions {
12
58
  url: string;
@@ -90,3 +136,4 @@ export declare function redirectSafeFetch(connectorId: string, policy?: RemoteMc
90
136
  * server or hide other connectors).
91
137
  */
92
138
  export declare function remoteMcp(id: string, opts: RemoteMcpOptions): Connector;
139
+ export {};
@@ -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",
@@ -214,7 +214,18 @@ export function mixpanel(id, options) {
214
214
  // an agent must not get wrong between two Mixpanel connections.
215
215
  title: options.title ?? `Mixpanel (${region})`,
216
216
  description: `Mixpanel product analytics (${REGION_COPY[region]} residency) — ${purpose}`,
217
- 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
+ }),
218
229
  requireHttps: true,
219
230
  usageGuide: {
220
231
  content: usageGuide(purpose, region, options.instructions),
@@ -21,7 +21,9 @@ export interface RevenueCatOptions {
21
21
  purpose: string;
22
22
  /**
23
23
  * OAuth by default; static headers support a RevenueCat API v2 secret key
24
- * as `Authorization: Bearer sk_…`.
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.
25
27
  */
26
28
  auth?: RemoteMcpAuth;
27
29
  /** Project-specific conventions appended to the maintained provider guide. */
@@ -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
  /** RevenueCat publishes one hosted MCP endpoint, streamable HTTP. */
4
4
  export const REVENUECAT_MCP_ENDPOINT = "https://mcp.revenuecat.ai/mcp";
@@ -274,8 +274,17 @@ export function revenuecat(id, options) {
274
274
  if (!purpose) {
275
275
  throw new Error("revenuecat() requires a non-empty project purpose.");
276
276
  }
277
- const auth = options.auth ?? { type: "oauth" };
278
- const scoped = auth.type === "headers";
277
+ const auth = withCredentialDefaults(options.auth ?? { type: "oauth" }, {
278
+ credential: {
279
+ label: "API v2 secret key",
280
+ description: "A RevenueCat API v2 secret key. It reaches exactly one project, which is why two projects are two connectors; it is stored encrypted and never displayed.",
281
+ placeholder: "sk_…",
282
+ },
283
+ });
284
+ // Both static shapes reach one project. Where the key came from — the
285
+ // deployment file or the operator page — changes nothing an agent must know
286
+ // about scope, so the title, description, and guide follow the scope alone.
287
+ const scoped = auth.type !== "oauth";
279
288
  const connector = remoteMcp(id, {
280
289
  url: REVENUECAT_MCP_ENDPOINT,
281
290
  // The scope shape rides the title because browse-time discovery renders
@@ -22,10 +22,16 @@ export interface StripeOAuthOptions extends StripeCommonOptions {
22
22
  mode?: never;
23
23
  connectedAccount?: never;
24
24
  }
25
- /** Static credentials have one fixed mode, including Stripe Connect calls. */
25
+ /**
26
+ * Static credentials have one fixed mode, including Stripe Connect calls.
27
+ *
28
+ * Both static shapes belong here: a key the deployment supplies as a literal
29
+ * header, and one the operator pastes at `/credentials`. Neither can discover
30
+ * its own mode — a restricted key answers for exactly one — so both declare it.
31
+ */
26
32
  export interface StripeHeaderOptions extends StripeCommonOptions {
27
- auth: Extract<RemoteMcpAuth, {
28
- type: "headers";
33
+ auth: Exclude<RemoteMcpAuth, {
34
+ type: "oauth";
29
35
  }>;
30
36
  mode: StripeMode;
31
37
  /** Act as one Connect account by sending Stripe's `Stripe-Account` header. */
@@ -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
  /** Stripe publishes one hosted MCP endpoint for every account and mode. */
4
4
  export const STRIPE_MCP_ENDPOINT = "https://mcp.stripe.com/";
@@ -109,17 +109,31 @@ function assertModeMatchesKey(id, mode, auth) {
109
109
  }
110
110
  }
111
111
  function resolveAuth(id, options) {
112
- const auth = options.auth ?? { type: "oauth" };
112
+ const auth = withCredentialDefaults(options.auth ?? { type: "oauth" }, {
113
+ credential: {
114
+ label: "Secret or restricted API key",
115
+ description: "A Stripe secret or restricted API key for this connector's declared mode. Stripe sends it as a bearer token; it is stored encrypted and never displayed.",
116
+ placeholder: "sk_… or rk_…",
117
+ },
118
+ });
113
119
  const connectedAccount = options.connectedAccount?.trim();
114
120
  if (connectedAccount === undefined || connectedAccount === "")
115
121
  return auth;
116
122
  if (!connectedAccount.startsWith("acct_")) {
117
123
  throw new Error(`stripe("${id}") connectedAccount must be a Stripe account id ("acct_...").`);
118
124
  }
119
- if (auth.type !== "headers") {
125
+ if (auth.type === "oauth") {
120
126
  throw new Error(`stripe("${id}") cannot reach a connected account over OAuth; Stripe ` +
121
127
  `requires a restricted API key for Stripe-Account calls.`);
122
128
  }
129
+ if (auth.type === "credential") {
130
+ // `Stripe-Account` is a second header beside the credential's own, and the
131
+ // credential shape assembles exactly one. A Connect connector therefore
132
+ // still takes its restricted key as a literal header.
133
+ throw new Error(`stripe("${id}") cannot reach a connected account with an ` +
134
+ `operator-managed credential; Stripe-Account is a second static ` +
135
+ `header, so declare auth: { type: "headers" } for this connector.`);
136
+ }
123
137
  return {
124
138
  type: "headers",
125
139
  headers: { ...auth.headers, "Stripe-Account": connectedAccount },
@@ -199,9 +213,14 @@ export function stripe(id, options) {
199
213
  if (auth.type === "oauth" && mode !== undefined) {
200
214
  throw new Error(`stripe("${id}") cannot declare a connector-wide mode for OAuth; Stripe returns mode with each account.`);
201
215
  }
202
- if (auth.type === "headers" && mode !== "production" && mode !== "sandbox") {
203
- throw new Error(`stripe("${id}") with headers auth requires mode "production" or "sandbox".`);
216
+ if (auth.type !== "oauth" && mode !== "production" && mode !== "sandbox") {
217
+ throw new Error(`stripe("${id}") with headers or credential auth requires mode ` +
218
+ `"production" or "sandbox".`);
204
219
  }
220
+ // Only a literal header can be inspected. An operator-managed credential is
221
+ // not readable at construction — there is nothing in the deployment file to
222
+ // read — so the declared mode stands alone, and a key pointed at the other
223
+ // one is Stripe's own refusal to report.
205
224
  if (auth.type === "headers") {
206
225
  assertModeMatchesKey(id, mode, auth);
207
226
  }
package/dist/version.d.ts CHANGED
@@ -4,4 +4,4 @@
4
4
  * a bump that forgets this file fails the build rather than shipping a stale
5
5
  * version to `/health` and to downstream MCP handshakes.
6
6
  */
7
- export declare const CONNECTA_VERSION = "0.18.1";
7
+ export declare const CONNECTA_VERSION = "0.18.2";
package/dist/version.js CHANGED
@@ -4,4 +4,4 @@
4
4
  * a bump that forgets this file fails the build rather than shipping a stale
5
5
  * version to `/health` and to downstream MCP handshakes.
6
6
  */
7
- export const CONNECTA_VERSION = "0.18.1";
7
+ export const CONNECTA_VERSION = "0.18.2";
@@ -294,3 +294,28 @@ server issuer discovered and validated by the SDK; see
294
294
  [storage and credentials](./storage-and-credentials.md#downstream-oauth).
295
295
  The callback route validates `state` before passing the complete callback query
296
296
  to the SDK so RFC 9207 `iss` validation is not lost.
297
+
298
+ A remote MCP connector that authenticates with a static key has two ways to
299
+ receive one. `{ type: "headers", headers }` bakes the literal value into the
300
+ deployment file, which suits a secret the runtime already holds.
301
+ `{ type: "credential" }` declares the slot instead and lets an operator paste
302
+ the key at `/credentials`, where it is encrypted at rest and rotatable without
303
+ a redeploy:
304
+
305
+ ```ts
306
+ remoteMcp("revenuecat_bepresent", {
307
+ url: "https://mcp.revenuecat.ai/mcp",
308
+ auth: {
309
+ type: "credential",
310
+ credential: { label: "API v2 secret key" },
311
+ },
312
+ });
313
+ ```
314
+
315
+ Header name and framing are configurable — `header` defaults to
316
+ `Authorization`, `scheme` to `Bearer`, `scheme: null` sends the value bare, and
317
+ a `Basic` framing base64-encodes a `user:secret` pair. Every maintained hosted
318
+ connection takes the shape through its existing `auth` option and fills in its
319
+ own label and framing. The full behavior, including rotation and the empty-slot
320
+ state, is in
321
+ [storage and credentials](./storage-and-credentials.md#a-remote-mcp-connectors-static-credential).
@@ -65,9 +65,12 @@ connection; it now answers 404.
65
65
  ## Authentication
66
66
 
67
67
  OAuth 2.1 with dynamic client registration is the default and keeps each
68
- connector instance's flow and tokens in its connector-scoped storage. Linear
69
- also accepts a bearer token or a personal API key passed directly in the
70
- `Authorization` header, which suits a headless deployment:
68
+ connector instance's flow and tokens in its connector-scoped storage. Linear's
69
+ MCP server also "supports passing OAuth token and API keys directly in the
70
+ `Authorization: Bearer <yourtoken>` header instead of using the interactive
71
+ authentication flow" ([Linear MCP docs](https://linear.app/docs/mcp)), which
72
+ suits a headless deployment. Note the framing: the bare-`Authorization`
73
+ convention is Linear's *GraphQL* API, and this endpoint is not that.
71
74
 
72
75
  ```ts
73
76
  linear("automation_tracker", {
@@ -75,7 +78,7 @@ linear("automation_tracker", {
75
78
  access: "read-only",
76
79
  auth: {
77
80
  type: "headers",
78
- headers: { Authorization: env.LINEAR_API_KEY },
81
+ headers: { Authorization: `Bearer ${env.LINEAR_API_KEY}` },
79
82
  },
80
83
  });
81
84
  ```
@@ -85,6 +88,24 @@ configuration. A personal API key carries the acting user's full workspace
85
88
  permissions, so pair it with `access: "read-only"` unless the deployment
86
89
  genuinely writes.
87
90
 
91
+ The same key can arrive from `/credentials` instead, which is what a deployment
92
+ with no secret store — or an operator who rotates keys without a redeploy —
93
+ wants:
94
+
95
+ ```ts
96
+ linear("automation_tracker", {
97
+ purpose: "Headless release reporting",
98
+ access: "read-only",
99
+ auth: { type: "credential" },
100
+ });
101
+ ```
102
+
103
+ The slot renders as "Personal API key" and Connecta sends the stored value as
104
+ `Authorization: Bearer <key>`, the framing Linear's MCP page documents; pass
105
+ `credential` or `scheme` to override either. Until the operator saves a value
106
+ the connector is present and reports `auth_required`. See
107
+ [storage and credentials](./storage-and-credentials.md#a-remote-mcp-connectors-static-credential).
108
+
88
109
  ## Safety classification
89
110
 
90
111
  The wrapper classifies Linear's documented `list_*`, `get_*`, and
@@ -310,6 +310,11 @@ handoff:
310
310
  declared credential label and field names/guidance; or
311
311
  - `unavailable`: an honest deployment/configuration message.
312
312
 
313
+ The class follows what the connector declares, not how it was authored: a
314
+ `remoteMcp()` connection using `auth: { type: "credential" }` declares a slot
315
+ and no OAuth flow, so it lands in `operator_config` beside every `api()`
316
+ credential.
317
+
313
318
  The tool accepts no secret. `force` applies only to OAuth and may discard its
314
319
  stored grant before restarting consent. Static credential values are written
315
320
  only through the same-origin, Clerk-operator credential route. After OAuth
@@ -50,6 +50,25 @@ password, not ordinary configuration. Mixpanel currently labels service-account
50
50
  MCP authentication beta. Prefer OAuth unless the deployment is intentionally
51
51
  headless.
52
52
 
53
+ The same service account can arrive from `/credentials` instead, and there the
54
+ operator pastes the readable pair rather than an encoded blob:
55
+
56
+ ```ts
57
+ mixpanel("automation_analytics", {
58
+ purpose: "Headless release-health reporting",
59
+ auth: { type: "credential" },
60
+ });
61
+ ```
62
+
63
+ The slot renders as "Service account" and takes `username:secret`. Connecta
64
+ base64-encodes it and sends Mixpanel's documented `Bearer Basic` framing, so the
65
+ operator never has to encode anything by hand. **The two paths take different
66
+ strings:** the `headers` example above wants the already-encoded blob
67
+ (`echo -n "username:secret" | base64`), and this one wants the plaintext pair.
68
+ Migrating from one to the other means decoding, not copying. Until a value is
69
+ saved the connector is present and reports `auth_required`. See
70
+ [storage and credentials](./storage-and-credentials.md#a-remote-mcp-connectors-static-credential).
71
+
53
72
  ## Conditional input contracts
54
73
 
55
74
  Mixpanel's hosted descriptions enforce three cross-field conditions that its
@@ -247,6 +247,7 @@ in.
247
247
  | `provider-conventions.test.ts` | the conventions a test can hold: hand-written providers refusing schemas they cannot enforce (H5), their compact discovery schemas staying complete (H7), Cloudflare stating its second pagination convention in the schema (H10), and Notion saying it has no escape hatch (H14) |
248
248
  | `registry.test.ts` | construction and id validation, startup warnings, address resolution, catalog TTL/persistence/completeness, agent-only stale-while-revalidate with cross-request single-flight shared with blocking reads in both start orders, owned teardown, invalidation/fingerprint guards, blocking diagnostics, and broken-connector isolation |
249
249
  | `remote-mcp.test.ts` | `remoteMcp()` against an in-process server through the `_transportFactory` seam: passthrough, downstream `isError`, Workers-safe output-schema validation, request-scoped client reuse and at-most-once scope close; plus the real transport's manual redirect policy, destination guard, credential containment, and downstream session termination |
250
+ | `remote-mcp-credential.test.ts` | `remoteMcp()` drawing a static key from `/credentials`: the declared slot and its refusal of named fields and bad header names, header framing (bearer, bare, and the two `Basic` forms) observed on the wire, an empty slot failing as `auth_required` rather than reaching the downstream, a value carrying a control character refused before framing and absent from every surface — `call_tool`, `status`, the Test result, `lastError`, and the thrown error — rotation replacing the cached client and a connect already in flight while a wiped value fails the next call, the Test action's catalog probe and scope close, the cleartext-destination warning, and the vault and `authorize_connector` handoff end to end |
250
251
  | `remote-mcp-pagination.test.ts` | the `tools/list` cursor chain in both directions — exact cursor handoff, first-wins dedup, a failed later page rejecting rather than returning its prefix, the runaway backstops, the tool-metadata re-prime across pages, and paginated catalogs reaching the discovery path |
251
252
  | `request-admission.test.ts` | `/mcp` bounded before auth, the stable 503 and `Retry-After`, health and operator responsiveness under saturation, payload-free counters, queued cancellation, shutdown rejection while active work drains, and the separate fallback code pool |
252
253
  | `revenuecat-provider.test.ts` / `revenuecat-registry.test.ts` | the RevenueCat proxy's per-project key scoping and account-wide OAuth guides, its purpose-bearing summary, the argued borderline verdicts in its digest-free manifest, and the deliberately unclassified `render-paywall-screenshot`; then two project-scoped keys as two connectors in a real deployment |
@@ -412,33 +412,49 @@ retries.
412
412
 
413
413
  OAuth per connector instance, stored in connector-scoped storage, is the
414
414
  default. The provider's own headless credential — a personal API key, a
415
- restricted key, a service account — is supported through explicit `headers`
416
- auth, documented as a secret rather than configuration, and paired with the
417
- narrowest mode the deployment can use. `requireHttps` is set. Recovery from an
418
- expired authorization is the ordinary `auth_required` → `authorize_connector`
419
- route.
415
+ restricted key, a service account — is supported two ways: explicit `headers`
416
+ auth, documented as a secret rather than configuration, and `{ type:
417
+ "credential" }`, which declares an operator slot and takes the same secret from
418
+ `/credentials` instead. Either way it is paired with the narrowest mode the
419
+ deployment can use, and the framing matches the provider's *published* contract
420
+ for the MCP endpoint — not a convention borrowed from that provider's other
421
+ APIs, and not this repository's earlier example, which is the same claim wearing
422
+ a circle. `requireHttps` is set. Recovery from an expired authorization is the
423
+ ordinary `auth_required` → `authorize_connector` route, which returns the
424
+ consent URL for OAuth and the `/credentials` handoff for a declared slot.
420
425
 
421
426
  *Why:* one route back from an expired credential is what keeps a failed call
422
427
  from becoming an abandoned task. *Cost:* wrong-tool selection.
423
428
 
424
- ### P10 — There is no credential test; the equivalent check happens at construction
429
+ ### P10 — Nothing probes a credential unasked; a declared slot may be tested on request
425
430
 
426
- A proxy declares no operator credential slot and implements neither
427
- `testCredential` nor `testCredentials`. `remoteMcp()` has no `credential`
428
- option, and neither shape of proxy credential is vault-managed: OAuth lives in
431
+ A proxy declares an operator credential slot exactly when its auth is `{ type:
432
+ "credential" }`, and then it inherits H12 whole
433
+ ([#439](https://github.com/zackbart/connecta/issues/439)). The other two shapes
434
+ declare no slot and hold nothing for the credentials page: OAuth lives in
429
435
  connector-scoped storage and is exercised by the authorization flow itself,
430
- while a headless key arrives as deployment configuration in `headers`, so there
431
- is nothing for the operator credentials page to hold or test. H12's guarantee is
432
- still owed, and a proxy pays it in two other places: construction throws when a
433
- recognizable credential contradicts the declared mode (P4), and a dead or
434
- revoked credential fails loudly at use as `auth_required` with the
435
- `authorize_connector` route attached (P9). Connecta never probes a downstream to
436
- see whether a credential is still alive — that shape is `removed` in the ethos
437
- ([#179](https://github.com/zackbart/connecta/issues/179)). A provider that later
438
- does take a vault-managed secret inherits H12 whole.
436
+ while a `headers` key arrives as deployment configuration. H12 is owed in every
437
+ shape, and a proxy pays it in two places that do not depend on a slot:
438
+ construction throws when a recognizable credential contradicts the declared mode
439
+ (P4) a check a vault-managed key cannot get, because there is nothing in the
440
+ deployment file to read and a dead, revoked, or absent credential fails loudly
441
+ at use as `auth_required` with the `authorize_connector` route attached (P9).
442
+
443
+ `testCredential` exists only behind the operator-pressed Test action on
444
+ `/credentials`, and only for a declared slot. It connects with the stored value
445
+ and reports how many tools the downstream served, which is the whole honest
446
+ check for a proxy: which account, project, or mode a key reaches is the
447
+ provider's answer, not Connecta's. That is not the shape
448
+ [#179](https://github.com/zackbart/connecta/issues/179) removed. What was
449
+ removed is the *unasked* probe — a liveness call every deployment pays on a
450
+ schedule or at startup to answer a question only a misconfigured one has. A
451
+ human clicking Test has asked, `api()` has had that button since the vault
452
+ existed, and nothing here probes on its own: no timer, no warmup, no check on
453
+ the read path.
439
454
 
440
455
  *Why:* an unasked-for liveness probe spends a call on every deployment to answer
441
- a question only a misconfigured one has. *Cost:* result size.
456
+ a question only a misconfigured one has; a requested one spends a call the
457
+ person requesting it chose. *Cost:* result size.
442
458
 
443
459
  ### P11 — Connecta classifies the transport; the downstream owns the tool error
444
460
 
@@ -666,8 +682,8 @@ than by reading:
666
682
  | P4 | endpoint or mode option exists, with the documented default (or no default, where none is safe) |
667
683
  | P5 | reads and writes are named lists; an unlisted tool resolves to not-read-only; a reviewed destructive name beats a contradictory `readOnlyHint: true` |
668
684
  | P6, P8 | the guide contains the catalog-varies note and the id-resolution rule |
669
- | P9 | `auth` defaults to OAuth and `requireHttps` is set |
670
- | P10 | no `credential`, `testCredential`, or `testCredentials` on the wrapper; the mode/key contradiction throws at construction instead |
685
+ | P9 | `auth` defaults to OAuth and `requireHttps` is set; a credential-auth shape frames the key the way the provider's MCP documentation does |
686
+ | P10 | a `credential` slot exactly when auth is `{ type: "credential" }`; `testCredential` runs only from the operator's Test action, never on a timer or a read path; the mode/key contradiction still throws at construction |
671
687
  | P11 | an authorization failure surfaces as `auth_required`; a downstream tool error is returned unchanged, with no code chosen from its prose |
672
688
  | P12 | a declared budget matches a citable documented limit, or the absence is justified in the guide |
673
689
  | P13 | classification lists are maintained in one place per provider and built into the manifest the wrapper classifies from, so the drift check compares against the same fact the caller is served |
@@ -80,6 +80,26 @@ connectors: [
80
80
  ]
81
81
  ```
82
82
 
83
+ Neither key has to be a runtime secret. Declare the slot instead and each
84
+ connector's key is pasted, tested, and rotated on `/credentials`:
85
+
86
+ ```ts
87
+ connectors: [
88
+ revenuecat("bepresent_ios", {
89
+ purpose: "Subscription state for the BePresent iOS project",
90
+ auth: { type: "credential", credential: { label: "API v2 secret key" } },
91
+ }),
92
+ revenuecat("biblescroll", {
93
+ purpose: "Subscription state for the BibleScroll project",
94
+ auth: { type: "credential", credential: { label: "API v2 secret key" } },
95
+ }),
96
+ ]
97
+ ```
98
+
99
+ Two ids, two slots, two single-project catalogs — the `credential` option is
100
+ optional, and omitting it gives the same "API v2 secret key" label. See
101
+ [storage and credentials](./storage-and-credentials.md#a-remote-mcp-connectors-static-credential).
102
+
83
103
  That is config-as-code doing what an account model would otherwise do: one
84
104
  credential per connector, each with its own catalog, storage namespace, health,
85
105
  and admission counters. The two share a title, because Connecta cannot know
@@ -116,7 +136,9 @@ Keys are prefixed `sk_`, are issued read-only or write-enabled, and can be
116
136
  revoked at any time by a project Admin. RevenueCat's setup guidance is to "use
117
137
  a write-enabled key if you plan to create/modify resources"; "a read-only key
118
138
  works if you only need to view data". Keep the key in the runtime's secret
119
- store, never in the deployment file.
139
+ store, never in the deployment file — or declare
140
+ `auth: { type: "credential" }` and let the operator hold it in the vault
141
+ instead, which is the shape the two-project example above uses.
120
142
 
121
143
  **Connecta does not filter writes for a read-only key.** It has no way to tell
122
144
  which kind a key is without spending a call, so every write in the catalog is
@@ -34,6 +34,61 @@ Credential mutation is intentionally narrower than MCP access:
34
34
  The vault is read for each call. Once an operator saves a replacement,
35
35
  the agent can retry immediately without restarting or redeploying Connecta.
36
36
 
37
+ ## A remote MCP connector's static credential
38
+
39
+ `remoteMcp()` accepts a third auth shape beside OAuth and literal headers:
40
+
41
+ ```ts
42
+ remoteMcp("revenuecat_bepresent", {
43
+ url: "https://mcp.revenuecat.ai/mcp",
44
+ auth: { type: "credential", credential: { label: "API v2 secret key" } },
45
+ });
46
+ ```
47
+
48
+ The connector, its endpoint, and the credential *slot* stay declared in code;
49
+ only the secret arrives through `/credentials`. That is the same boundary
50
+ `api()` has always had, and the reason a project-wide key no longer has to be a
51
+ Worker secret or an environment variable
52
+ ([#439](https://github.com/zackbart/connecta/issues/439)).
53
+
54
+ `header` defaults to `Authorization` and `scheme` to `Bearer`. `scheme: null`
55
+ sends the stored value verbatim, which is what Linear's personal API keys
56
+ expect. A scheme whose last token is `Basic` declares HTTP Basic credentials, so
57
+ the stored `user:secret` is base64-encoded first — `"Basic"` produces
58
+ `Basic <base64>`, and Mixpanel's documented `"Bearer Basic"` produces
59
+ `Bearer Basic <base64>`. There is one reserved `value` field and no multi-field
60
+ header composition: named `credential.fields` are refused at construction.
61
+
62
+ A stored value is checked before anything frames it: a line break or other
63
+ control character — what a key pasted across two lines leaves behind — is
64
+ refused as `auth_required` with a message naming the problem and never the
65
+ value. That check exists because the runtime that rejects such a header quotes
66
+ the whole offending value back in its `TypeError`, and that message would
67
+ otherwise reach the agent, the operator page, and the activity log. Behind it,
68
+ any error whose message quotes the credential or the header it became is
69
+ discarded whole and replaced; nothing is masked or truncated, because a
70
+ redaction that keeps part of a secret is still a leak.
71
+
72
+ An empty slot is not a boot failure and not a silently absent connector. The
73
+ connector is present, its status reads `auth_required`, calls fail with the same
74
+ typed error a missing OAuth grant produces, and `authorize_connector` returns
75
+ the `/credentials` handoff. With no vault configured at all, the failure names
76
+ `credentials.encryptionKey`, and Connecta already warned at startup.
77
+
78
+ The vault is read before any cached downstream client is trusted, so a rotation
79
+ lands on the next call rather than the next deploy. Connecta compares a SHA-256
80
+ digest of the value the cached client connected with; a different digest closes
81
+ that client and reconnects. The plaintext lives in the connect attempt's local
82
+ scope, never on connector state, never in a log, and never in a status or error
83
+ message. A cleartext `http://` destination warns at construction here exactly as
84
+ it does for literal headers — who owns the secret changed, not what the wire
85
+ carries.
86
+
87
+ `/credentials`' Test action connects with the stored value and reports how many
88
+ tools the downstream served. That is the whole honest check for a proxy: which
89
+ account, project, or mode the key reaches is the provider's answer, not
90
+ Connecta's.
91
+
37
92
  ## Downstream OAuth
38
93
 
39
94
  `remoteMcp()` stores dynamic client registration, tokens, PKCE material, state,
@@ -95,12 +95,32 @@ Use a restricted key, not a secret key, and scope it to the operations the
95
95
  agent actually needs; Stripe's own guidance is to "limit your agent's access to
96
96
  exactly the functionality it requires". Keep it in the runtime's secret store.
97
97
 
98
+ The same key can come from `/credentials` instead:
99
+
100
+ ```ts
101
+ stripe("stripe_sandbox", {
102
+ mode: "sandbox",
103
+ purpose: "Automated billing rehearsal",
104
+ auth: { type: "credential" },
105
+ });
106
+ ```
107
+
108
+ `mode` is required either way — a static key answers for exactly one
109
+ environment and cannot report which. The literal-header form is checked against
110
+ the key's `_live_`/`_test_` prefix at construction; an operator-managed key is
111
+ not in the deployment file to read, so the declared mode stands alone and a key
112
+ pointed at the other environment fails at Stripe. Declare the mode carefully:
113
+ that check is the one guard Connecta can offer, and this shape does not get it.
114
+ See
115
+ [storage and credentials](./storage-and-credentials.md#a-remote-mcp-connectors-static-credential).
116
+
98
117
  Organization accounts in one OAuth session are not Stripe Connect connected
99
118
  accounts. Connect platforms can act as a connected account with
100
119
  `connectedAccount`, which adds Stripe's documented `Stripe-Account` header at
101
120
  connector construction. Stripe does not support OAuth for connected-account
102
- calls, so this requires a restricted key through `headers` auth and throws
103
- otherwise:
121
+ calls, and `Stripe-Account` is a second header beside the credential's own,
122
+ which the operator-managed shape does not assemble — so this requires a
123
+ restricted key through `headers` auth and throws otherwise:
104
124
 
105
125
  ```ts
106
126
  stripe("merchant_42", {
@@ -57,7 +57,7 @@ exist so far:
57
57
  | --- | --- | --- |
58
58
  | **pre-template** | before 0.10.2 | no `connecta init` existed; hand-written, or copied from the retired `examples/node` |
59
59
  | **A** | 0.10.2 – 0.15.1 | `.env.example`, `.gitignore`, `AGENTS.md`, `CLAUDE.md`, `README.md`, `package.json`, `src/index.ts`, `tsconfig.json` |
60
- | **B** | 0.16.0 – 0.18.1 | adds `.dockerignore`, `Dockerfile`, `docker-compose.yml`, and `src/file-activity.ts`; `src/index.ts` grows the four commented operator blocks; `.env.example` ships `CONNECTA_TOKEN=` empty |
60
+ | **B** | 0.16.0 – 0.18.2 | adds `.dockerignore`, `Dockerfile`, `docker-compose.yml`, and `src/file-activity.ts`; `src/index.ts` grows the four commented operator blocks; `.env.example` ships `CONNECTA_TOKEN=` empty |
61
61
 
62
62
  Generation A is a decade in template years and identifying it precisely does
63
63
  not matter, because you are about to reconstruct it exactly rather than guess
@@ -106,7 +106,7 @@ know what to preserve, once to know what to re-verify at the end.
106
106
  ### Bump the pin and install
107
107
 
108
108
  ```sh
109
- npm pkg set dependencies.@zackbart/connecta=0.18.1
109
+ npm pkg set dependencies.@zackbart/connecta=0.18.2
110
110
  npm install
111
111
  ```
112
112
 
@@ -130,7 +130,7 @@ Generate the *current* template beside the base you already made, into the same
130
130
  `$SCRATCH`:
131
131
 
132
132
  ```sh
133
- (cd "$SCRATCH" && npx @zackbart/connecta@0.18.1 init current)
133
+ (cd "$SCRATCH" && npx @zackbart/connecta@0.18.2 init current)
134
134
  ```
135
135
 
136
136
  You now have a three-way merge with a real base: `$SCRATCH/base` is what this
@@ -186,7 +186,7 @@ A deployment older than 0.10.2 has no base to diff against. Do not try to
186
186
  manufacture one. Instead:
187
187
 
188
188
  1. `SCRATCH=$(mktemp -d)`, then
189
- `(cd "$SCRATCH" && npx @zackbart/connecta@0.18.1 init current)` — there is no
189
+ `(cd "$SCRATCH" && npx @zackbart/connecta@0.18.2 init current)` — there is no
190
190
  `base` leg here, only the current template to read from.
191
191
  2. Copy `$SCRATCH/current` into the deployment file by file, **skipping
192
192
  `src/index.ts`**.
@@ -207,6 +207,23 @@ first, so cross them bottom-up: start at the oldest one still above this
207
207
  deployment's pin and work back up the page, because each boundary assumes the
208
208
  older ones are already done.
209
209
 
210
+ ### 0.18.1 → 0.18.2
211
+
212
+ Nothing throws for an existing deployment, and the version bump alone crosses
213
+ it. The release adds a third `auth` shape to `remoteMcp()` and every maintained
214
+ hosted connection — `{ type: "credential" }` — under which the connector
215
+ declares an operator slot on `/credentials` and reads the pasted value on each
216
+ request. A deployment carrying a static key as a runtime secret
217
+ (`auth: { type: "headers", headers: { Authorization: env.KEY } }`) keeps
218
+ working unchanged; moving it behind `/credentials` is an edit to the connector's
219
+ `auth` and one paste on the operator page, and needs `credentials.encryptionKey`
220
+ configured — a deployment without a vault gets a startup warning and
221
+ `recovery: "unavailable"` at use for that connector, not a boot failure. Two
222
+ Linear notes: the `headers` example in `documentation/linear.md` now shows
223
+ `Bearer ${key}` (Linear's MCP server documents that framing), and the credential
224
+ shape sends `Bearer` by default; a `headers` connector already sending a bare
225
+ key is untouched.
226
+
210
227
  ### 0.18.0 → 0.18.1
211
228
 
212
229
  Nothing throws, no option moves, and every deployment crosses this on the
@@ -123,6 +123,11 @@ function build(env: Env) {
123
123
  auth: {
124
124
  type: "headers",
125
125
  headers: { Authorization: `Bearer ${env.DOWNSTREAM_TOKEN}` },
126
+ // The vault-backed alternative for a downstream that authenticates
127
+ // with a static key: the operator pastes it at /credentials and
128
+ // rotates it there, so no Worker secret holds it.
129
+ // type: "credential",
130
+ // credential: { label: "Notion internal integration token" },
126
131
  },
127
132
  }),
128
133
  api("echo", {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zackbart/connecta",
3
- "version": "0.18.1",
3
+ "version": "0.18.2",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "description": "One MCP to rule them all — a single MCP endpoint aggregating many downstream connectors behind a code-first surface of seven meta-tools.",
@@ -15,7 +15,7 @@
15
15
  "typecheck": "tsc --noEmit"
16
16
  },
17
17
  "dependencies": {
18
- "@zackbart/connecta": "0.18.1",
18
+ "@zackbart/connecta": "0.18.2",
19
19
  "quickjs-emscripten": "0.32.0"
20
20
  },
21
21
  "devDependencies": {