@zackbart/connecta 0.18.1 → 0.18.3
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 +80 -0
- package/README.md +4 -0
- package/dist/catalog-service.d.ts +4 -0
- package/dist/catalog-service.js +22 -6
- package/dist/connectors/remote-mcp.d.ts +49 -2
- package/dist/connectors/remote-mcp.js +302 -7
- package/dist/invocation.js +9 -3
- package/dist/providers/linear.d.ts +7 -1
- package/dist/providers/linear.js +12 -2
- package/dist/providers/mixpanel.d.ts +5 -1
- package/dist/providers/mixpanel.js +13 -2
- package/dist/providers/revenuecat.d.ts +3 -1
- package/dist/providers/revenuecat.js +12 -3
- package/dist/providers/stripe.d.ts +9 -3
- package/dist/providers/stripe.js +24 -5
- package/dist/registry.d.ts +7 -0
- package/dist/registry.js +9 -0
- package/dist/result-shapes.d.ts +13 -0
- package/dist/result-shapes.js +331 -0
- package/dist/skills.js +3 -3
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/documentation/architecture.md +5 -2
- package/documentation/code-mode.md +6 -6
- package/documentation/connectors.md +35 -0
- package/documentation/linear.md +25 -4
- package/documentation/meta-tools.md +22 -3
- package/documentation/mixpanel.md +19 -0
- package/documentation/operations.md +3 -1
- package/documentation/provider-conventions.md +37 -21
- package/documentation/revenuecat.md +23 -1
- package/documentation/storage-and-credentials.md +55 -0
- package/documentation/stripe.md +22 -2
- package/documentation/upgrading.md +32 -4
- package/ethos.md +3 -3
- package/examples/worker/src/index.ts +5 -0
- package/package.json +1 -1
- package/templates/node/package.json +1 -1
|
@@ -3,6 +3,26 @@ import { KvOAuthProvider } from "../auth/downstream-oauth.js";
|
|
|
3
3
|
import { MAX_CATALOG_TOOLS } from "../catalog-limits.js";
|
|
4
4
|
import { ConnectorCallError } from "../errors.js";
|
|
5
5
|
import { CONNECTA_VERSION } from "../version.js";
|
|
6
|
+
/**
|
|
7
|
+
* Apply a maintained provider's slot copy and header framing to credential
|
|
8
|
+
* auth the deployment left bare.
|
|
9
|
+
*
|
|
10
|
+
* A provider knows what its own key is called and how the endpoint expects it
|
|
11
|
+
* framed; a deployment that states either one keeps its answer. Every other
|
|
12
|
+
* auth shape passes through untouched, so a provider can hand this its whole
|
|
13
|
+
* `auth` option without branching first.
|
|
14
|
+
*/
|
|
15
|
+
export function withCredentialDefaults(auth, defaults) {
|
|
16
|
+
if (auth.type !== "credential")
|
|
17
|
+
return auth;
|
|
18
|
+
return {
|
|
19
|
+
...auth,
|
|
20
|
+
credential: auth.credential ?? defaults.credential,
|
|
21
|
+
...(auth.scheme === undefined && defaults.scheme !== undefined
|
|
22
|
+
? { scheme: defaults.scheme }
|
|
23
|
+
: {}),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
6
26
|
/**
|
|
7
27
|
* How long a downstream gets to answer the session-termination DELETE before
|
|
8
28
|
* teardown stops waiting. This is a network round-trip budget, deliberately
|
|
@@ -160,6 +180,65 @@ async function terminateSession(transport, logger, connectorId) {
|
|
|
160
180
|
});
|
|
161
181
|
});
|
|
162
182
|
}
|
|
183
|
+
const encoder = new TextEncoder();
|
|
184
|
+
/** Base64 of a UTF-8 string, Web-API only so the core still runs on workerd. */
|
|
185
|
+
function base64Utf8(value) {
|
|
186
|
+
let binary = "";
|
|
187
|
+
for (const byte of encoder.encode(value)) {
|
|
188
|
+
binary += String.fromCharCode(byte);
|
|
189
|
+
}
|
|
190
|
+
return btoa(binary);
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Hex SHA-256 of a credential, used only to notice that a cached client
|
|
194
|
+
* connected with a value the vault no longer holds. WebCrypto rather than a
|
|
195
|
+
* `node:` hash: the whole core has to keep running unchanged on Workers.
|
|
196
|
+
*/
|
|
197
|
+
async function digestOf(value) {
|
|
198
|
+
const bytes = new Uint8Array(await crypto.subtle.digest("SHA-256", encoder.encode(value)));
|
|
199
|
+
let hex = "";
|
|
200
|
+
for (const byte of bytes)
|
|
201
|
+
hex += byte.toString(16).padStart(2, "0");
|
|
202
|
+
return hex;
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Assemble the one header a credential-auth connector sends.
|
|
206
|
+
*
|
|
207
|
+
* A scheme ending in `Basic` names HTTP Basic credentials wherever a provider
|
|
208
|
+
* nests it, so the `user:secret` it frames is base64-encoded — that is what
|
|
209
|
+
* makes plain `Basic` and Mixpanel's `Bearer Basic` one rule instead of two.
|
|
210
|
+
*/
|
|
211
|
+
function credentialHeaderValue(scheme, value) {
|
|
212
|
+
if (scheme === null)
|
|
213
|
+
return value;
|
|
214
|
+
return /(?:^|\s)basic$/i.test(scheme)
|
|
215
|
+
? `${scheme} ${base64Utf8(value)}`
|
|
216
|
+
: `${scheme} ${value}`;
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* True when a stored credential carries a character a header cannot.
|
|
220
|
+
*
|
|
221
|
+
* The fetch specification refuses NUL, CR, and LF outright, and the runtime
|
|
222
|
+
* that refuses them says so in a `TypeError` that quotes the whole offending
|
|
223
|
+
* value — a message that would otherwise travel to the agent. The remaining C0
|
|
224
|
+
* controls and DEL are refused here too: no real API key contains one, and a
|
|
225
|
+
* paste that picked one up is a paste to redo rather than a request to send.
|
|
226
|
+
* Leading and trailing whitespace is already gone by the time this runs.
|
|
227
|
+
*
|
|
228
|
+
* A scan rather than a regular expression, because a character class over the
|
|
229
|
+
* control range is exactly what `no-control-regex` exists to flag, and the
|
|
230
|
+
* suppression would be less readable than the loop it suppressed.
|
|
231
|
+
*/
|
|
232
|
+
function carriesIllegalHeaderChar(value) {
|
|
233
|
+
for (let index = 0; index < value.length; index++) {
|
|
234
|
+
const code = value.charCodeAt(index);
|
|
235
|
+
if (code <= 0x1f || code === 0x7f)
|
|
236
|
+
return true;
|
|
237
|
+
}
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
/** RFC 9110 field-name token, checked once at construction. */
|
|
241
|
+
const HEADER_NAME_TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
|
163
242
|
function isLoopbackHost(hostname) {
|
|
164
243
|
return (hostname === "localhost" ||
|
|
165
244
|
hostname === "127.0.0.1" ||
|
|
@@ -273,6 +352,29 @@ export function remoteMcp(id, opts) {
|
|
|
273
352
|
const closedScopes = new WeakSet();
|
|
274
353
|
const isOauth = opts.auth?.type === "oauth";
|
|
275
354
|
const logger = opts.logger ?? console;
|
|
355
|
+
const credentialAuth = opts.auth?.type === "credential" ? opts.auth : undefined;
|
|
356
|
+
if (credentialAuth?.credential?.fields?.length) {
|
|
357
|
+
throw new Error(`[connecta] connector "${id}" credential auth declares named fields; ` +
|
|
358
|
+
"this shape sends one secret in one header and reads the reserved " +
|
|
359
|
+
"`value` field only.");
|
|
360
|
+
}
|
|
361
|
+
const credentialConfig = credentialAuth?.credential ?? {
|
|
362
|
+
label: "API key",
|
|
363
|
+
};
|
|
364
|
+
const credentialHeader = credentialAuth?.header?.trim() || "Authorization";
|
|
365
|
+
// A structural mistake in the deployment file, beside the `fields` refusal
|
|
366
|
+
// above: an unsendable header name is not worth discovering on the first
|
|
367
|
+
// request, where only a runtime error can report it.
|
|
368
|
+
if (credentialAuth && !HEADER_NAME_TOKEN.test(credentialHeader)) {
|
|
369
|
+
throw new Error(`[connecta] connector "${id}" credential auth declares header ` +
|
|
370
|
+
`"${credentialHeader}", which is not a valid HTTP field name.`);
|
|
371
|
+
}
|
|
372
|
+
// `undefined` means "not stated" and takes the bearer default; `null` and
|
|
373
|
+
// `""` both mean "send the stored value verbatim", which some providers
|
|
374
|
+
// require for a bare API key.
|
|
375
|
+
const credentialScheme = credentialAuth === undefined || credentialAuth.scheme === undefined
|
|
376
|
+
? "Bearer"
|
|
377
|
+
: (credentialAuth.scheme?.trim() ?? "") || null;
|
|
276
378
|
// Check the destination scheme once at construction: buildTransport (and the
|
|
277
379
|
// SDK's fetch) attach any static credentials to every request, so an http://
|
|
278
380
|
// endpoint sends bearer tokens / API keys in cleartext. Loopback is exempt
|
|
@@ -283,7 +385,10 @@ export function remoteMcp(id, opts) {
|
|
|
283
385
|
if (opts.requireHttps) {
|
|
284
386
|
throw new Error(`[connecta] connector "${id}" url ${opts.url} is not https:// (and not loopback) — refusing to connect (requireHttps).`);
|
|
285
387
|
}
|
|
286
|
-
|
|
388
|
+
// Both static shapes send a secret on every request; that an operator
|
|
389
|
+
// typed one into /credentials rather than a deployment file changes who
|
|
390
|
+
// owns it, not whether the wire carries it in the clear.
|
|
391
|
+
if (opts.auth?.type === "headers" || credentialAuth) {
|
|
287
392
|
logger.warn(`[connecta] connector "${id}" sends static credentials to ${opts.url} over a non-https:// connection — those tokens will be transmitted in cleartext.`);
|
|
288
393
|
}
|
|
289
394
|
}
|
|
@@ -295,6 +400,77 @@ export function remoteMcp(id, opts) {
|
|
|
295
400
|
}
|
|
296
401
|
}
|
|
297
402
|
const operatorDisconnectedError = () => new OperatorDisconnectedError();
|
|
403
|
+
/**
|
|
404
|
+
* The credential slot is declared but the vault has nothing in it (or has no
|
|
405
|
+
* key to read it with). Deliberately the same `auth_required` code an absent
|
|
406
|
+
* OAuth grant produces: the agent's next move is `authorize_connector`
|
|
407
|
+
* either way, and that tool reads `connector.credential` to hand the
|
|
408
|
+
* operator `/credentials` instead of a consent URL.
|
|
409
|
+
*/
|
|
410
|
+
class CredentialRequiredError extends ConnectorCallError {
|
|
411
|
+
constructor(message) {
|
|
412
|
+
super("auth_required", message);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Read this request's credential. Called before every connect and before
|
|
417
|
+
* trusting any cached client, so an operator's replacement is picked up
|
|
418
|
+
* without a redeploy. The value stays in the caller's local scope.
|
|
419
|
+
*/
|
|
420
|
+
const readCredential = async (ctx) => {
|
|
421
|
+
if (!ctx.credential) {
|
|
422
|
+
throw new CredentialRequiredError(`Connector "${id}" needs an operator-managed credential, but ` +
|
|
423
|
+
"credential storage is not configured. Set " +
|
|
424
|
+
"credentials.encryptionKey and redeploy.");
|
|
425
|
+
}
|
|
426
|
+
// A stored-shape mismatch already arrives as a typed auth_required from
|
|
427
|
+
// the registry's accessor; nothing to reclassify here.
|
|
428
|
+
const value = (await ctx.credential.get())?.trim();
|
|
429
|
+
if (!value) {
|
|
430
|
+
throw new CredentialRequiredError(`Connector "${id}" has no stored credential — call ` +
|
|
431
|
+
`authorize_connector({ connector: "${id}" }) and follow the ` +
|
|
432
|
+
"operator handoff it returns.");
|
|
433
|
+
}
|
|
434
|
+
// Refuse a value a header cannot carry BEFORE anything frames it. A
|
|
435
|
+
// wrapped newline in a pasted key is the ordinary way this happens, and
|
|
436
|
+
// the runtime that rejects the header quotes the whole value back in its
|
|
437
|
+
// TypeError — a message that travels to status, to the activity log, and
|
|
438
|
+
// to the agent. So the check lives here, and says only what is wrong.
|
|
439
|
+
if (carriesIllegalHeaderChar(value)) {
|
|
440
|
+
throw new CredentialRequiredError(`Connector "${id}"'s stored credential contains a character a header ` +
|
|
441
|
+
"cannot carry (a line break or other control character) — re-enter " +
|
|
442
|
+
"it on /credentials. The value is not shown or logged.");
|
|
443
|
+
}
|
|
444
|
+
return value;
|
|
445
|
+
};
|
|
446
|
+
/**
|
|
447
|
+
* Replace, never edit.
|
|
448
|
+
*
|
|
449
|
+
* Any error whose message quotes the credential — the raw value or the
|
|
450
|
+
* framed header it becomes — is discarded whole and replaced with a fixed
|
|
451
|
+
* sentence. Nothing is substringed, masked, or truncated out of the original:
|
|
452
|
+
* a redaction that keeps part of a secret is still a leak, and the original
|
|
453
|
+
* error is not worth one. This is defense in depth behind the validation
|
|
454
|
+
* above, which is what keeps an unsendable value from reaching a transport
|
|
455
|
+
* at all.
|
|
456
|
+
*/
|
|
457
|
+
const withoutCredential = (err, ...secrets) => {
|
|
458
|
+
const quoted = secrets.filter((secret) => typeof secret === "string" && secret !== "");
|
|
459
|
+
if (quoted.length === 0)
|
|
460
|
+
return err;
|
|
461
|
+
const seen = new Set();
|
|
462
|
+
let current = err;
|
|
463
|
+
while (current instanceof Error && !seen.has(current)) {
|
|
464
|
+
seen.add(current);
|
|
465
|
+
if (quoted.some((secret) => current instanceof Error && current.message.includes(secret))) {
|
|
466
|
+
return new CredentialRequiredError(`Connector "${id}" could not send its stored credential as a ` +
|
|
467
|
+
"header — re-enter it on /credentials. The value is not shown or " +
|
|
468
|
+
"logged.");
|
|
469
|
+
}
|
|
470
|
+
current = current.cause;
|
|
471
|
+
}
|
|
472
|
+
return err;
|
|
473
|
+
};
|
|
298
474
|
const scopeEndedError = () => new Error(`Connector "${id}" scope ended during connection.`);
|
|
299
475
|
const requestOptions = (ctx) => ctx.timeoutMs || ctx.signal
|
|
300
476
|
? {
|
|
@@ -334,6 +510,7 @@ export function remoteMcp(id, opts) {
|
|
|
334
510
|
authRequired: false,
|
|
335
511
|
provider: null,
|
|
336
512
|
connectedGeneration: null,
|
|
513
|
+
credentialDigest: null,
|
|
337
514
|
closed: false,
|
|
338
515
|
};
|
|
339
516
|
states.set(scope, state);
|
|
@@ -345,7 +522,13 @@ export function remoteMcp(id, opts) {
|
|
|
345
522
|
return state.provider;
|
|
346
523
|
};
|
|
347
524
|
const newProvider = (ctx) => new KvOAuthProvider(id, ctx.storage, `${ctx.baseUrl}/oauth/callback/${id}`);
|
|
348
|
-
const buildTransport = (ctx, provider
|
|
525
|
+
const buildTransport = (ctx, provider,
|
|
526
|
+
/**
|
|
527
|
+
* This attempt's assembled header value, for credential auth only — framed
|
|
528
|
+
* by the caller so the raw secret is not passed around twice. Never
|
|
529
|
+
* retained past the transport it configures.
|
|
530
|
+
*/
|
|
531
|
+
credentialFramed = null) => {
|
|
349
532
|
if (opts._transportFactory)
|
|
350
533
|
return opts._transportFactory(ctx);
|
|
351
534
|
const url = new URL(opts.url);
|
|
@@ -356,7 +539,11 @@ export function remoteMcp(id, opts) {
|
|
|
356
539
|
fetch: guardedFetch,
|
|
357
540
|
});
|
|
358
541
|
}
|
|
359
|
-
const headers = opts.auth?.type === "headers"
|
|
542
|
+
const headers = opts.auth?.type === "headers"
|
|
543
|
+
? opts.auth.headers
|
|
544
|
+
: credentialAuth && credentialFramed !== null
|
|
545
|
+
? { [credentialHeader]: credentialFramed }
|
|
546
|
+
: undefined;
|
|
360
547
|
return new StreamableHTTPClientTransport(url, {
|
|
361
548
|
...(headers ? { requestInit: { headers } } : {}),
|
|
362
549
|
fetch: guardedFetch,
|
|
@@ -370,6 +557,7 @@ export function remoteMcp(id, opts) {
|
|
|
370
557
|
state.authRequired = false;
|
|
371
558
|
state.provider = null;
|
|
372
559
|
state.connectedGeneration = null;
|
|
560
|
+
state.credentialDigest = null;
|
|
373
561
|
// `closed` is deliberately not cleared — see ConnectionState.
|
|
374
562
|
};
|
|
375
563
|
const ensureConnected = async (ctx, state) => {
|
|
@@ -415,6 +603,43 @@ export function remoteMcp(id, opts) {
|
|
|
415
603
|
reset(state);
|
|
416
604
|
}
|
|
417
605
|
}
|
|
606
|
+
// The static-credential counterpart of the epoch read above, and
|
|
607
|
+
// deliberately beside it: the vault is read before any cached client is
|
|
608
|
+
// trusted, so an operator's rotation on /credentials takes effect on the
|
|
609
|
+
// next call rather than the next deploy. Compared by digest — the
|
|
610
|
+
// plaintext lives in this function's scope and never reaches `state`.
|
|
611
|
+
let credentialValue = null;
|
|
612
|
+
let credentialFramed = null;
|
|
613
|
+
let credentialDigest = null;
|
|
614
|
+
if (credentialAuth) {
|
|
615
|
+
credentialValue = await readCredential(ctx);
|
|
616
|
+
credentialFramed = credentialHeaderValue(credentialScheme, credentialValue);
|
|
617
|
+
credentialDigest = await digestOf(credentialValue);
|
|
618
|
+
// Gated on a connect in flight as well as a cached client, exactly like
|
|
619
|
+
// the epoch read above: a rotation that lands while the first caller is
|
|
620
|
+
// still connecting must not let the second one ride the key the vault
|
|
621
|
+
// has already replaced.
|
|
622
|
+
if ((state.client || state.connecting) &&
|
|
623
|
+
state.credentialDigest !== null &&
|
|
624
|
+
state.credentialDigest !== credentialDigest) {
|
|
625
|
+
if (state.closed)
|
|
626
|
+
throw scopeEndedError();
|
|
627
|
+
const connecting = state.connecting;
|
|
628
|
+
const client = state.client;
|
|
629
|
+
const transport = state.transport;
|
|
630
|
+
reset(state);
|
|
631
|
+
void connecting?.catch(() => { });
|
|
632
|
+
try {
|
|
633
|
+
if (client)
|
|
634
|
+
await client.close();
|
|
635
|
+
else
|
|
636
|
+
await transport?.close();
|
|
637
|
+
}
|
|
638
|
+
catch {
|
|
639
|
+
// The rotated-away client is discarded either way.
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
}
|
|
418
643
|
if (state.closed)
|
|
419
644
|
throw scopeEndedError();
|
|
420
645
|
if (state.client)
|
|
@@ -458,7 +683,7 @@ export function remoteMcp(id, opts) {
|
|
|
458
683
|
// below as one structured, non-retryable connector failure.
|
|
459
684
|
inputRequired: { autoFulfill: false },
|
|
460
685
|
});
|
|
461
|
-
const t = buildTransport(ctx, provider);
|
|
686
|
+
const t = buildTransport(ctx, provider, credentialFramed);
|
|
462
687
|
if (!ownsAttempt())
|
|
463
688
|
await abandon(t);
|
|
464
689
|
state.transport = t;
|
|
@@ -506,7 +731,11 @@ export function remoteMcp(id, opts) {
|
|
|
506
731
|
if (err instanceof UnauthorizedError) {
|
|
507
732
|
throw authRequiredError(err);
|
|
508
733
|
}
|
|
509
|
-
|
|
734
|
+
// Defense in depth for the one error class that can quote the
|
|
735
|
+
// credential: a runtime refusing the assembled header. `readCredential`
|
|
736
|
+
// already rejects a value that cannot ride one, so reaching this is a
|
|
737
|
+
// gap in that check rather than a routine outcome.
|
|
738
|
+
throw withoutCredential(err, credentialValue, credentialFramed);
|
|
510
739
|
}
|
|
511
740
|
finally {
|
|
512
741
|
// Force reset may have abandoned this attempt and installed a new one
|
|
@@ -517,6 +746,10 @@ export function remoteMcp(id, opts) {
|
|
|
517
746
|
}
|
|
518
747
|
})();
|
|
519
748
|
state.connecting = attempt;
|
|
749
|
+
// Published with the attempt, not with its result: a rotation that lands
|
|
750
|
+
// while this connect is in flight has to be visible to the next caller,
|
|
751
|
+
// which would otherwise wait on a client bound to the older key.
|
|
752
|
+
state.credentialDigest = credentialDigest;
|
|
520
753
|
}
|
|
521
754
|
return state.connecting;
|
|
522
755
|
};
|
|
@@ -562,6 +795,54 @@ export function remoteMcp(id, opts) {
|
|
|
562
795
|
? { callAdmission: opts.callAdmission }
|
|
563
796
|
: {}),
|
|
564
797
|
...(opts.usageGuide !== undefined ? { usageGuide: opts.usageGuide } : {}),
|
|
798
|
+
// Declaring the slot is what makes the rest of the operator surface work:
|
|
799
|
+
// /credentials renders it, the shape check compares against it, and
|
|
800
|
+
// authorize_connector reads it to return the operator handoff rather than
|
|
801
|
+
// an OAuth URL this connector has none of.
|
|
802
|
+
...(credentialAuth
|
|
803
|
+
? {
|
|
804
|
+
credential: credentialConfig,
|
|
805
|
+
/**
|
|
806
|
+
* The honest test for a proxy is the catalog: connect with the
|
|
807
|
+
* stored value and count what the downstream serves. Nothing else
|
|
808
|
+
* here is connecta's to verify — the credential's scope, project,
|
|
809
|
+
* and mode are the provider's answer, not ours.
|
|
810
|
+
*/
|
|
811
|
+
testCredential: async (value, ctx) => {
|
|
812
|
+
try {
|
|
813
|
+
// The connect below reads the vault itself — the header is
|
|
814
|
+
// assembled deep inside `ensureConnected`, and handing a
|
|
815
|
+
// candidate down that path would mean threading a second secret
|
|
816
|
+
// through the whole connection state. `/ui/credentials/<id>/test`
|
|
817
|
+
// reads the stored value and passes it here, so today the two are
|
|
818
|
+
// the same string. Check rather than assume: a route that later
|
|
819
|
+
// tested an unsaved candidate would otherwise silently report on
|
|
820
|
+
// the old value, which is the one answer worse than refusing.
|
|
821
|
+
const stored = (await ctx.credential?.get())?.trim();
|
|
822
|
+
if (stored !== value.trim()) {
|
|
823
|
+
return {
|
|
824
|
+
ok: false,
|
|
825
|
+
message: "This connector tests the credential that is currently " +
|
|
826
|
+
"saved. Save the value first, then test it.",
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
const tools = await connector.listTools(ctx);
|
|
830
|
+
return {
|
|
831
|
+
ok: true,
|
|
832
|
+
message: `Connected — the downstream served ${tools.length} tool${tools.length === 1 ? "" : "s"}.`,
|
|
833
|
+
};
|
|
834
|
+
}
|
|
835
|
+
catch (err) {
|
|
836
|
+
return { ok: false, message: msg(err) };
|
|
837
|
+
}
|
|
838
|
+
finally {
|
|
839
|
+
// A test owns the scope it just opened; leaving the session for
|
|
840
|
+
// the downstream to age out is not this button's to spend.
|
|
841
|
+
await connector.closeScope?.(ctx);
|
|
842
|
+
}
|
|
843
|
+
},
|
|
844
|
+
}
|
|
845
|
+
: {}),
|
|
565
846
|
// `tools/list` is cursor-paginated: the server chooses the page size and
|
|
566
847
|
// signals "there is more" with a `nextCursor`, which the SDK's
|
|
567
848
|
// Client.listTools() returns without following. Collect the whole chain
|
|
@@ -744,6 +1025,7 @@ export function remoteMcp(id, opts) {
|
|
|
744
1025
|
state.connecting = null;
|
|
745
1026
|
state.authRequired = false;
|
|
746
1027
|
state.connectedGeneration = null;
|
|
1028
|
+
state.credentialDigest = null;
|
|
747
1029
|
// Ask the downstream to drop its session first — closing only aborts our
|
|
748
1030
|
// side, and the DELETE that frees the server's rides on the very
|
|
749
1031
|
// AbortSignal the close is about to trip.
|
|
@@ -766,12 +1048,25 @@ export function remoteMcp(id, opts) {
|
|
|
766
1048
|
return { state: "ok" };
|
|
767
1049
|
}
|
|
768
1050
|
catch (err) {
|
|
1051
|
+
// An empty slot, or one with no vault behind it, is reported the way a
|
|
1052
|
+
// missing grant is: present, unauthenticated, and repairable — never a
|
|
1053
|
+
// boot failure and never a silently absent connector.
|
|
1054
|
+
if (err instanceof CredentialRequiredError) {
|
|
1055
|
+
return { state: "auth_required", message: err.message };
|
|
1056
|
+
}
|
|
769
1057
|
if (state.authRequired) {
|
|
770
|
-
|
|
1058
|
+
// Only an OAuth connector has a pending consent URL to offer. A
|
|
1059
|
+
// credential connector's downstream 401 is repaired on /credentials,
|
|
1060
|
+
// so do not reach into OAuth storage to look for one.
|
|
1061
|
+
const url = isOauth
|
|
1062
|
+
? await getProvider(ctx, state).pendingAuthorizationUrl()
|
|
1063
|
+
: undefined;
|
|
771
1064
|
return {
|
|
772
1065
|
state: "auth_required",
|
|
773
1066
|
...(url !== undefined ? { authorizationUrl: url } : {}),
|
|
774
|
-
message:
|
|
1067
|
+
message: credentialAuth
|
|
1068
|
+
? "Authorization required — the downstream rejected this connector's stored credential."
|
|
1069
|
+
: "Authorization required — open the URL to connect.",
|
|
775
1070
|
};
|
|
776
1071
|
}
|
|
777
1072
|
if (err instanceof OperatorDisconnectedError) {
|
package/dist/invocation.js
CHANGED
|
@@ -288,6 +288,7 @@ export class InvocationService {
|
|
|
288
288
|
}
|
|
289
289
|
const maxRetries = Math.min(2, Math.max(0, Math.trunc(context.maxRetries ?? 0)));
|
|
290
290
|
let result;
|
|
291
|
+
let observedResult;
|
|
291
292
|
while (true) {
|
|
292
293
|
attempts++;
|
|
293
294
|
let permit;
|
|
@@ -362,9 +363,8 @@ export class InvocationService {
|
|
|
362
363
|
// reports the same downstream-failure wording, and the throw lands
|
|
363
364
|
// inside the attempt where it stays retry-eligible and feeds health.
|
|
364
365
|
assertRawMcpSuccess(resolved.connector.kind, raw);
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
: raw;
|
|
366
|
+
observedResult = unwrapMcpResult(resolved.connector.kind, raw);
|
|
367
|
+
result = context.unwrapResult ? observedResult : raw;
|
|
368
368
|
}
|
|
369
369
|
finally {
|
|
370
370
|
connectorMs += Date.now() - connectorStarted;
|
|
@@ -440,6 +440,12 @@ export class InvocationService {
|
|
|
440
440
|
const value = context.processResult
|
|
441
441
|
? await context.processResult(result, resolved)
|
|
442
442
|
: result;
|
|
443
|
+
try {
|
|
444
|
+
this.registry.observeOutputShape(resolved.connector.id, resolved.definition, observedResult);
|
|
445
|
+
}
|
|
446
|
+
catch {
|
|
447
|
+
// Shape learning is advisory. It cannot change a completed call.
|
|
448
|
+
}
|
|
443
449
|
resultProcessingMs += Date.now() - processingStarted;
|
|
444
450
|
const diagnostics = timing();
|
|
445
451
|
const friction = context.activityFriction?.(value);
|
|
@@ -32,7 +32,13 @@ export interface LinearOptions {
|
|
|
32
32
|
* ([#342](https://github.com/zackbart/connecta/issues/342)).
|
|
33
33
|
*/
|
|
34
34
|
access: LinearAccess;
|
|
35
|
-
/**
|
|
35
|
+
/**
|
|
36
|
+
* OAuth by default. A Linear personal API key works either as a literal
|
|
37
|
+
* header or as an operator-managed credential (`{ type: "credential" }`).
|
|
38
|
+
* Linear's MCP documentation asks for `Authorization: Bearer <yourtoken>`
|
|
39
|
+
* for both API keys and OAuth tokens (https://linear.app/docs/mcp), which is
|
|
40
|
+
* the framing default, so the credential shape needs no `scheme` of its own.
|
|
41
|
+
*/
|
|
36
42
|
auth?: RemoteMcpAuth;
|
|
37
43
|
/** Workspace-specific conventions appended to the maintained provider guide. */
|
|
38
44
|
instructions?: string;
|
package/dist/providers/linear.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { remoteMcp, } from "../connectors/remote-mcp.js";
|
|
1
|
+
import { remoteMcp, withCredentialDefaults, } from "../connectors/remote-mcp.js";
|
|
2
2
|
import { vettedCatalog, withVettedCatalog } from "../catalog-drift.js";
|
|
3
3
|
/**
|
|
4
4
|
* Linear publishes two hosted endpoints. `read-only` is not a client-side
|
|
@@ -189,7 +189,17 @@ export function linear(id, options) {
|
|
|
189
189
|
description: access === "read-only"
|
|
190
190
|
? `Linear issue tracking and project planning (read-only) — ${purpose}`
|
|
191
191
|
: `Linear issue tracking and project planning — ${purpose}`,
|
|
192
|
-
|
|
192
|
+
// Linear's MCP endpoint takes an API key the same way it takes an OAuth
|
|
193
|
+
// token — `Authorization: Bearer <yourtoken>` — so only the slot copy is
|
|
194
|
+
// provider-specific and the bearer framing default stands. The bare-header
|
|
195
|
+
// convention belongs to Linear's GraphQL API, not to this endpoint.
|
|
196
|
+
auth: withCredentialDefaults(options.auth ?? { type: "oauth" }, {
|
|
197
|
+
credential: {
|
|
198
|
+
label: "Personal API key",
|
|
199
|
+
description: "A Linear personal API key. It carries the issuing user's full workspace access and is stored encrypted; the read-only endpoint still limits what it can reach.",
|
|
200
|
+
placeholder: "lin_api_…",
|
|
201
|
+
},
|
|
202
|
+
}),
|
|
193
203
|
requireHttps: true,
|
|
194
204
|
usageGuide: {
|
|
195
205
|
content: usageGuide(purpose, access, options.instructions),
|
|
@@ -18,7 +18,11 @@ export interface MixpanelOptions {
|
|
|
18
18
|
* rather than a convenient one.
|
|
19
19
|
*/
|
|
20
20
|
region?: MixpanelRegion;
|
|
21
|
-
/**
|
|
21
|
+
/**
|
|
22
|
+
* OAuth by default; static headers support Mixpanel service accounts, and
|
|
23
|
+
* `{ type: "credential" }` takes the same service account as an
|
|
24
|
+
* operator-managed `username:secret` that Connecta frames for the endpoint.
|
|
25
|
+
*/
|
|
22
26
|
auth?: RemoteMcpAuth;
|
|
23
27
|
/** Account-specific conventions appended to the maintained provider guide. */
|
|
24
28
|
instructions?: string;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { remoteMcp, } from "../connectors/remote-mcp.js";
|
|
1
|
+
import { remoteMcp, withCredentialDefaults, } from "../connectors/remote-mcp.js";
|
|
2
2
|
import { vettedCatalog, withVettedCatalog } from "../catalog-drift.js";
|
|
3
3
|
export const MIXPANEL_MCP_ENDPOINTS = {
|
|
4
4
|
us: "https://mcp.mixpanel.com/mcp",
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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:
|
|
28
|
-
type: "
|
|
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. */
|
package/dist/providers/stripe.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { remoteMcp, } from "../connectors/remote-mcp.js";
|
|
1
|
+
import { remoteMcp, withCredentialDefaults, } from "../connectors/remote-mcp.js";
|
|
2
2
|
import { vettedCatalog, withVettedCatalog } from "../catalog-drift.js";
|
|
3
3
|
/** 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
|
|
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
|
|
203
|
-
throw new Error(`stripe("${id}") with headers auth requires mode
|
|
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
|
}
|