@zackbart/connecta 0.6.0 → 0.7.0
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 +403 -0
- package/README.md +163 -308
- package/dist/auth/bearer.d.ts +4 -3
- package/dist/auth/bearer.d.ts.map +1 -1
- package/dist/auth/bearer.js +10 -8
- package/dist/auth/bearer.js.map +1 -1
- package/dist/auth/clerk.d.ts +8 -7
- package/dist/auth/clerk.d.ts.map +1 -1
- package/dist/auth/clerk.js +27 -8
- package/dist/auth/clerk.js.map +1 -1
- package/dist/connector-scope.d.ts +13 -0
- package/dist/connector-scope.d.ts.map +1 -0
- package/dist/connector-scope.js +35 -0
- package/dist/connector-scope.js.map +1 -0
- package/dist/connectors/api.d.ts +5 -5
- package/dist/connectors/api.d.ts.map +1 -1
- package/dist/connectors/remote-mcp.d.ts +3 -3
- package/dist/connectors/remote-mcp.d.ts.map +1 -1
- package/dist/connectors/remote-mcp.js +309 -10
- package/dist/connectors/remote-mcp.js.map +1 -1
- package/dist/credential-health.d.ts +20 -9
- package/dist/credential-health.d.ts.map +1 -1
- package/dist/credential-health.js +127 -63
- package/dist/credential-health.js.map +1 -1
- package/dist/credentials.d.ts +84 -1
- package/dist/credentials.d.ts.map +1 -1
- package/dist/credentials.js +109 -2
- package/dist/credentials.js.map +1 -1
- package/dist/index.d.ts +83 -82
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +101 -31
- package/dist/index.js.map +1 -1
- package/dist/meta-tools.d.ts +3 -3
- package/dist/meta-tools.d.ts.map +1 -1
- package/dist/meta-tools.js +16 -7
- package/dist/meta-tools.js.map +1 -1
- package/dist/registry.d.ts +3 -2
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +4 -3
- package/dist/registry.js.map +1 -1
- package/dist/server.d.ts +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +154 -52
- package/dist/server.js.map +1 -1
- package/dist/skills.js +2 -2
- package/dist/skills.js.map +1 -1
- package/dist/toolkits.js +1 -1
- package/dist/types.d.ts +51 -26
- package/dist/types.d.ts.map +1 -1
- package/dist/ui.d.ts +52 -21
- package/dist/ui.d.ts.map +1 -1
- package/dist/ui.js +665 -196
- package/dist/ui.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +3 -2
- package/src/auth/bearer.ts +10 -8
- package/src/auth/clerk.ts +28 -9
- package/src/connector-scope.ts +41 -0
- package/src/connectors/api.ts +5 -5
- package/src/connectors/remote-mcp.ts +348 -25
- package/src/credential-health.ts +151 -71
- package/src/credentials.ts +166 -3
- package/src/index.ts +202 -113
- package/src/meta-tools.ts +22 -7
- package/src/registry.ts +4 -3
- package/src/server.ts +197 -71
- package/src/skills.ts +2 -2
- package/src/toolkits.ts +1 -1
- package/src/types.ts +51 -26
- package/src/ui.ts +703 -195
- package/src/version.ts +1 -1
package/src/server.ts
CHANGED
|
@@ -10,6 +10,11 @@ import type {
|
|
|
10
10
|
ActivityStore,
|
|
11
11
|
} from "./activity.js";
|
|
12
12
|
import { InvalidActivityCursorError } from "./activity.js";
|
|
13
|
+
import {
|
|
14
|
+
credentialTestRule,
|
|
15
|
+
describeCredentialTestMismatch,
|
|
16
|
+
storedCredentialShape,
|
|
17
|
+
} from "./credentials.js";
|
|
13
18
|
import type { CredentialVault } from "./credentials.js";
|
|
14
19
|
import { ScopedRegistry, type Registry, type RegistryView } from "./registry.js";
|
|
15
20
|
import {
|
|
@@ -18,6 +23,7 @@ import {
|
|
|
18
23
|
type Toolkit,
|
|
19
24
|
} from "./toolkits.js";
|
|
20
25
|
import type {
|
|
26
|
+
ConnectorContext,
|
|
21
27
|
ConnectorCredentialConfig,
|
|
22
28
|
ConnectorCredentialValues,
|
|
23
29
|
ConnectaBranding,
|
|
@@ -30,6 +36,8 @@ import { CONNECTA_FAVICON_ICO } from "./favicon.js";
|
|
|
30
36
|
import {
|
|
31
37
|
buildUiData,
|
|
32
38
|
CONNECTA_FAVICON_SVG,
|
|
39
|
+
credentialManagementCapability,
|
|
40
|
+
operatorPageForPath,
|
|
33
41
|
resolveBranding,
|
|
34
42
|
renderUiHtml,
|
|
35
43
|
} from "./ui.js";
|
|
@@ -88,7 +96,7 @@ export interface ServerOptions {
|
|
|
88
96
|
probeTimeoutMs?: number;
|
|
89
97
|
/** When set, the execute_code meta-tool is registered on top of the nine. */
|
|
90
98
|
executor?: Executor;
|
|
91
|
-
/** Encrypted connector-credential storage backing the
|
|
99
|
+
/** Encrypted connector-credential storage backing the Credentials page. */
|
|
92
100
|
credentialVault?: CredentialVault;
|
|
93
101
|
/** Optional browser UI and OAuth result-page labels. */
|
|
94
102
|
branding?: ConnectaBranding;
|
|
@@ -103,7 +111,7 @@ function msg(err: unknown): string {
|
|
|
103
111
|
return err instanceof Error ? err.message : String(err);
|
|
104
112
|
}
|
|
105
113
|
|
|
106
|
-
/** Per-request base64 nonce for
|
|
114
|
+
/** Per-request base64 nonce for an operator shell's scripts (Node 20+ and Workers). */
|
|
107
115
|
function uiScriptNonce(): string {
|
|
108
116
|
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
|
109
117
|
let binary = "";
|
|
@@ -186,7 +194,7 @@ function html(
|
|
|
186
194
|
<h1>Connection status</h1>
|
|
187
195
|
<div class="copy">
|
|
188
196
|
<p>${escapeHtml(body)}</p>
|
|
189
|
-
<p><a href="/
|
|
197
|
+
<p><a href="/">Return to ${escapeHtml(brand.productName)}</a></p>
|
|
190
198
|
</div>
|
|
191
199
|
</main>
|
|
192
200
|
</body>
|
|
@@ -216,7 +224,7 @@ async function authorize(
|
|
|
216
224
|
actor: ActivityActor;
|
|
217
225
|
providerKind?: string;
|
|
218
226
|
userId?: string;
|
|
219
|
-
/** The admitting identity's toolkit binding
|
|
227
|
+
/** The admitting identity's toolkit binding (docs/toolkits.md). */
|
|
220
228
|
toolkitBinding?: ToolkitBinding;
|
|
221
229
|
}
|
|
222
230
|
| { ok: false; response: Response }
|
|
@@ -312,10 +320,10 @@ function withSecurityHeaders(
|
|
|
312
320
|
if (requestUrl.protocol === "https:") {
|
|
313
321
|
headers.set("Strict-Transport-Security", "max-age=31536000");
|
|
314
322
|
}
|
|
315
|
-
if (path === "/ui") {
|
|
316
|
-
//
|
|
317
|
-
// includes frame-ancestors 'none'); only fall back to the
|
|
318
|
-
// directive when no CSP is present (
|
|
323
|
+
if (operatorPageForPath(path) || path === "/ui") {
|
|
324
|
+
// Operator HTML responses ship their own nonce-based script CSP (which
|
|
325
|
+
// already includes frame-ancestors 'none'); only fall back to the
|
|
326
|
+
// framing-only directive when no CSP is present (for example redirects).
|
|
319
327
|
if (!headers.has("Content-Security-Policy")) {
|
|
320
328
|
headers.set("Content-Security-Policy", "frame-ancestors 'none'");
|
|
321
329
|
}
|
|
@@ -340,11 +348,11 @@ async function authorizeUiAdmin(
|
|
|
340
348
|
//
|
|
341
349
|
// EVERY Clerk provider gets a turn, the way the /mcp gate does, because the
|
|
342
350
|
// documented per-team pattern is several `clerkAuth(...)`s that differ only in
|
|
343
|
-
// `gate` and `toolkits` (
|
|
344
|
-
// depend on config order: the team-bound provider listed first
|
|
345
|
-
// the operator outright, and a refusal here — a failed gate, a
|
|
346
|
-
// toolkit-bound identity — is exactly the case where a later
|
|
347
|
-
// one meant to admit. The last refusal is returned if none do.
|
|
351
|
+
// `gate` and `toolkits` (docs/toolkits.md). Stopping at the first would make
|
|
352
|
+
// admission depend on config order: the team-bound provider listed first
|
|
353
|
+
// would refuse the operator outright, and a refusal here — a failed gate, a
|
|
354
|
+
// missing user, a toolkit-bound identity — is exactly the case where a later
|
|
355
|
+
// provider is the one meant to admit. The last refusal is returned if none do.
|
|
348
356
|
const providers = auth.filter(
|
|
349
357
|
(candidate) => candidate.uiAuth?.kind === "clerk",
|
|
350
358
|
);
|
|
@@ -546,36 +554,48 @@ async function handleCredentialRequest(
|
|
|
546
554
|
if (request.method !== "POST") {
|
|
547
555
|
return privateJson({ error: "method not allowed" }, { status: 405 });
|
|
548
556
|
}
|
|
549
|
-
|
|
557
|
+
// The declared credential shape picks the hook — the same single rule the
|
|
558
|
+
// Credentials page asks for its Test affordance, so a shown button reaches
|
|
559
|
+
// that reads the shape the credential was stored in.
|
|
560
|
+
const rule = credentialTestRule(connector);
|
|
561
|
+
if (!rule.mode) {
|
|
550
562
|
return privateJson(
|
|
551
|
-
{
|
|
563
|
+
{
|
|
564
|
+
error: rule.mismatch
|
|
565
|
+
? "this connector cannot test its credential: " +
|
|
566
|
+
describeCredentialTestMismatch(rule.mismatch)
|
|
567
|
+
: "this connector does not support credential testing",
|
|
568
|
+
},
|
|
552
569
|
{ status: 400 },
|
|
553
570
|
);
|
|
554
571
|
}
|
|
555
572
|
try {
|
|
573
|
+
const values = await opts.credentialVault.getAll(connectorId);
|
|
574
|
+
const shape = storedCredentialShape(connector.credential, values);
|
|
575
|
+
if (shape.state === "missing") {
|
|
576
|
+
return privateJson(
|
|
577
|
+
{
|
|
578
|
+
error:
|
|
579
|
+
rule.mode === "multiple"
|
|
580
|
+
? "configure the credentials before testing them"
|
|
581
|
+
: "configure the credential before testing it",
|
|
582
|
+
},
|
|
583
|
+
{ status: 409 },
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
if (shape.state === "mismatch") {
|
|
587
|
+
return privateJson({ error: shape.message }, { status: 409 });
|
|
588
|
+
}
|
|
589
|
+
const storedValues = values!;
|
|
556
590
|
const ctx = opts.registry.contextFor(connectorId, baseUrl);
|
|
557
591
|
let result;
|
|
558
|
-
if (
|
|
559
|
-
|
|
560
|
-
if (!values) {
|
|
561
|
-
return privateJson(
|
|
562
|
-
{ error: "configure the credentials before testing them" },
|
|
563
|
-
{ status: 409 },
|
|
564
|
-
);
|
|
565
|
-
}
|
|
566
|
-
result = await connector.testCredentials(values, ctx);
|
|
592
|
+
if (rule.mode === "multiple") {
|
|
593
|
+
result = await connector.testCredentials!(storedValues, ctx);
|
|
567
594
|
} else {
|
|
568
|
-
|
|
569
|
-
if (!value) {
|
|
570
|
-
return privateJson(
|
|
571
|
-
{ error: "configure the credential before testing it" },
|
|
572
|
-
{ status: 409 },
|
|
573
|
-
);
|
|
574
|
-
}
|
|
575
|
-
result = await connector.testCredential!(value, ctx);
|
|
595
|
+
result = await connector.testCredential!(storedValues.value, ctx);
|
|
576
596
|
}
|
|
577
597
|
// The operator just ran the very check the liveness sweep runs; record it
|
|
578
|
-
// so
|
|
598
|
+
// so cached status surfaces agree with what the operator page showed.
|
|
579
599
|
await opts.registry.recordCredentialHealth(connectorId, {
|
|
580
600
|
state: result.ok ? "ok" : "auth_required",
|
|
581
601
|
checkedAt: new Date().toISOString(),
|
|
@@ -645,12 +665,12 @@ interface McpScope {
|
|
|
645
665
|
|
|
646
666
|
/**
|
|
647
667
|
* Bounded, escaped form of a caller-influenced value (a rejected toolkit name,
|
|
648
|
-
*
|
|
649
|
-
* caller-controlled newline or control character
|
|
650
|
-
* hand-rolled escape for U+2028/U+2029, which
|
|
651
|
-
* though a log reader treats them as line
|
|
652
|
-
*
|
|
653
|
-
* either.
|
|
668
|
+
* identity id, or OAuth callback connector id) for the operator log. Goes
|
|
669
|
+
* through JSON.stringify so a caller-controlled newline or control character
|
|
670
|
+
* cannot forge a log line, plus a hand-rolled escape for U+2028/U+2029, which
|
|
671
|
+
* JSON.stringify leaves raw even though a log reader treats them as line
|
|
672
|
+
* terminators. Truncated to a small shared cap (also the toolkit response's echo
|
|
673
|
+
* limit), so an oversized value cannot flood the log either.
|
|
654
674
|
*/
|
|
655
675
|
function loggableValue(requested: string): string {
|
|
656
676
|
const bounded = requested.slice(0, MAX_ECHOED_TOOLKIT_NAME);
|
|
@@ -700,7 +720,7 @@ function identityLabel(actor: ActivityActor): string {
|
|
|
700
720
|
|
|
701
721
|
/**
|
|
702
722
|
* Resolve `?toolkit=<name>` into the registry view this connection may see,
|
|
703
|
-
* enforcing the caller's toolkit binding (
|
|
723
|
+
* enforcing the caller's toolkit binding (docs/toolkits.md) on the way.
|
|
704
724
|
*
|
|
705
725
|
* For an UNBOUND identity (no binding configured — the pre-#37 shape):
|
|
706
726
|
*
|
|
@@ -892,35 +912,105 @@ async function serveMcp(
|
|
|
892
912
|
return transport.handleRequest(request);
|
|
893
913
|
}
|
|
894
914
|
|
|
915
|
+
/**
|
|
916
|
+
* Pay the storage read a real downstream-OAuth refusal pays, on the refusal
|
|
917
|
+
* paths that would otherwise pay nothing.
|
|
918
|
+
*
|
|
919
|
+
* Identical bodies do not hide a connector id if the clock still sorts them.
|
|
920
|
+
* `KvOAuthProvider.verifyState` reads `oauth:state` before it can fail, so a
|
|
921
|
+
* configured id costs one storage round trip — on the Workers deployment shape
|
|
922
|
+
* that is a real KV read, tens of milliseconds cold — while an id that names
|
|
923
|
+
* nothing used to return having touched no I/O at all. That gap is an oracle:
|
|
924
|
+
* sample the two and a wordlist recovers the connector list the flat 400 was
|
|
925
|
+
* meant to withhold. So the zero-I/O refusals read the same key in the same
|
|
926
|
+
* `conn:<id>:` namespace, which for an unconfigured id is simply a miss.
|
|
927
|
+
*
|
|
928
|
+
* This is deliberately *not* a constant-time claim, and docs/connectors.md says
|
|
929
|
+
* so in prose: a hit and a miss are not identical in a KV store, and a connector
|
|
930
|
+
* shipping its own `verifyState` may do more or less work than one read. What it
|
|
931
|
+
* removes is the order-of-magnitude "no I/O versus a round trip" difference,
|
|
932
|
+
* which is the only part of the signal that makes enumeration cheap.
|
|
933
|
+
*
|
|
934
|
+
* A throwing read is swallowed: the refusal is the answer either way, and
|
|
935
|
+
* turning it into a 500 would hand back exactly the distinguishable response
|
|
936
|
+
* this whole path exists to deny.
|
|
937
|
+
*/
|
|
938
|
+
async function equalizeRefusalCost(context: ConnectorContext): Promise<void> {
|
|
939
|
+
try {
|
|
940
|
+
await context.storage.get("oauth:state");
|
|
941
|
+
} catch {
|
|
942
|
+
// Deliberately ignored — see above.
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
|
|
895
946
|
async function handleOAuthCallback(
|
|
896
947
|
url: URL,
|
|
897
948
|
registry: Registry,
|
|
898
949
|
baseUrl: string,
|
|
950
|
+
logger: Logger,
|
|
899
951
|
branding?: ConnectaBranding,
|
|
900
952
|
): Promise<Response> {
|
|
901
|
-
const id = url.pathname.slice("/oauth/callback/".length);
|
|
902
|
-
const connector = registry.getConnector(id);
|
|
903
|
-
if (!connector || !connector.finishAuth) {
|
|
904
|
-
return html(`Unknown connector "${id}".`, 404, branding);
|
|
905
|
-
}
|
|
906
953
|
const error = url.searchParams.get("error");
|
|
907
954
|
if (error) return html(`Authorization denied: ${error}`, 400, branding);
|
|
908
955
|
const code = url.searchParams.get("code");
|
|
909
956
|
if (!code) return html("Missing authorization code.", 400, branding);
|
|
957
|
+
const id = url.pathname.slice("/oauth/callback/".length);
|
|
958
|
+
const connector = registry.getConnector(id);
|
|
959
|
+
// Safe to build before we know the id names anything: `contextFor` is a pure
|
|
960
|
+
// constructor — a namespaced storage view over `conn:<id>:` and, only for a
|
|
961
|
+
// connector that declares one, a lazy credential accessor. It neither throws
|
|
962
|
+
// nor touches storage for an unknown id, which is what lets the refusals
|
|
963
|
+
// below borrow it to equalize their cost.
|
|
910
964
|
const context = registry.contextFor(id, baseUrl);
|
|
965
|
+
const refused = () =>
|
|
966
|
+
html(
|
|
967
|
+
"Authorization could not be completed. Re-run authorization from " +
|
|
968
|
+
"connecta and try again.",
|
|
969
|
+
400,
|
|
970
|
+
branding,
|
|
971
|
+
);
|
|
972
|
+
if (!connector || !connector.finishAuth) {
|
|
973
|
+
await equalizeRefusalCost(context);
|
|
974
|
+
return refused();
|
|
975
|
+
}
|
|
911
976
|
// CSRF / login-fixation guard: this route is intentionally public, so verify
|
|
912
977
|
// the `state` matches the flow connecta started BEFORE exchanging the code.
|
|
913
|
-
if (connector.verifyState) {
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
"
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
978
|
+
if (!connector.verifyState) {
|
|
979
|
+
await equalizeRefusalCost(context);
|
|
980
|
+
logger.warn(
|
|
981
|
+
`[connecta] refused an OAuth callback for connector ` +
|
|
982
|
+
`${loggableValue(id)} with 400: it implements finishAuth but no ` +
|
|
983
|
+
"verifyState, so connecta cannot establish that it started this flow. " +
|
|
984
|
+
"No authorization code was exchanged. Implement verifyState before " +
|
|
985
|
+
"trying again.",
|
|
986
|
+
);
|
|
987
|
+
return refused();
|
|
988
|
+
}
|
|
989
|
+
const state = url.searchParams.get("state");
|
|
990
|
+
let stateMatches: boolean;
|
|
991
|
+
try {
|
|
992
|
+
stateMatches = await connector.verifyState(state, context);
|
|
993
|
+
} catch (err) {
|
|
994
|
+
logger.warn(
|
|
995
|
+
`[connecta] refused an OAuth callback for connector ` +
|
|
996
|
+
`${loggableValue(id)} with 400: verifyState threw ` +
|
|
997
|
+
`${loggableValue(msg(err))}. No authorization code was exchanged. ` +
|
|
998
|
+
"Re-run authorization from connecta and check the verifier if it " +
|
|
999
|
+
"fails again.",
|
|
1000
|
+
);
|
|
1001
|
+
return refused();
|
|
1002
|
+
}
|
|
1003
|
+
if (!stateMatches) {
|
|
1004
|
+
logger.warn(
|
|
1005
|
+
`[connecta] refused an OAuth callback for connector ` +
|
|
1006
|
+
`${loggableValue(id)} with 400: ` +
|
|
1007
|
+
(state === null
|
|
1008
|
+
? "the state parameter was missing"
|
|
1009
|
+
: "the state did not match the pending authorization flow") +
|
|
1010
|
+
". No authorization code was exchanged. Re-run authorization from " +
|
|
1011
|
+
"connecta and try again.",
|
|
1012
|
+
);
|
|
1013
|
+
return refused();
|
|
924
1014
|
}
|
|
925
1015
|
try {
|
|
926
1016
|
await connector.finishAuth(code, context);
|
|
@@ -995,7 +1085,10 @@ export function createFetchHandler(
|
|
|
995
1085
|
new URL(publicUrl).protocol === "https:" &&
|
|
996
1086
|
url.protocol === "http:"
|
|
997
1087
|
) {
|
|
998
|
-
|
|
1088
|
+
// Canonicalize the legacy bookmark while upgrading it so an old /ui URL
|
|
1089
|
+
// reaches the new Connections entry point in one permanent redirect.
|
|
1090
|
+
const targetPath = path === "/ui" ? "/" : url.pathname;
|
|
1091
|
+
const target = new URL(`${targetPath}${url.search}`, publicUrl);
|
|
999
1092
|
return withSecurityHeaders(
|
|
1000
1093
|
new Response(null, {
|
|
1001
1094
|
status: 308,
|
|
@@ -1054,7 +1147,13 @@ export function createFetchHandler(
|
|
|
1054
1147
|
}
|
|
1055
1148
|
|
|
1056
1149
|
if (path.startsWith("/oauth/callback/")) {
|
|
1057
|
-
return handleOAuthCallback(
|
|
1150
|
+
return handleOAuthCallback(
|
|
1151
|
+
url,
|
|
1152
|
+
registry,
|
|
1153
|
+
baseUrl,
|
|
1154
|
+
opts.logger,
|
|
1155
|
+
opts.branding,
|
|
1156
|
+
);
|
|
1058
1157
|
}
|
|
1059
1158
|
|
|
1060
1159
|
if (request.method === "GET" && path === "/favicon.svg") {
|
|
@@ -1078,7 +1177,23 @@ export function createFetchHandler(
|
|
|
1078
1177
|
}
|
|
1079
1178
|
|
|
1080
1179
|
if (path === "/ui") {
|
|
1081
|
-
|
|
1180
|
+
if (request.method !== "GET") {
|
|
1181
|
+
return privateJson({ error: "method not allowed" }, { status: 405 });
|
|
1182
|
+
}
|
|
1183
|
+
const target = new URL(`/${url.search}`, baseUrl);
|
|
1184
|
+
return new Response(null, {
|
|
1185
|
+
status: 308,
|
|
1186
|
+
headers: { Location: target.toString() },
|
|
1187
|
+
});
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
const operatorPage = operatorPageForPath(path);
|
|
1191
|
+
if (operatorPage) {
|
|
1192
|
+
if (request.method !== "GET") {
|
|
1193
|
+
return privateJson({ error: "method not allowed" }, { status: 405 });
|
|
1194
|
+
}
|
|
1195
|
+
// Open shell — carries no operator data; everything comes from the
|
|
1196
|
+
// authenticated /ui/* APIs after the browser establishes a session.
|
|
1082
1197
|
const uiAuth = auth.find((provider) => provider.uiAuth)?.uiAuth;
|
|
1083
1198
|
const mcpUrl = new URL("/mcp", baseUrl).toString();
|
|
1084
1199
|
// Nonce the page's inline script (and the Clerk loader). 'strict-dynamic'
|
|
@@ -1088,16 +1203,19 @@ export function createFetchHandler(
|
|
|
1088
1203
|
// style/font/network needs and the page's inline <style> stay unrestricted
|
|
1089
1204
|
// — only script execution, the XSS sink, is gated.
|
|
1090
1205
|
const nonce = uiScriptNonce();
|
|
1091
|
-
return new Response(
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
"
|
|
1098
|
-
|
|
1206
|
+
return new Response(
|
|
1207
|
+
renderUiHtml(uiAuth, mcpUrl, opts.branding, nonce, operatorPage),
|
|
1208
|
+
{
|
|
1209
|
+
status: 200,
|
|
1210
|
+
headers: {
|
|
1211
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
1212
|
+
"Content-Security-Policy":
|
|
1213
|
+
`script-src 'nonce-${nonce}' 'strict-dynamic' https: 'unsafe-inline'; ` +
|
|
1214
|
+
"object-src 'none'; base-uri 'none'; frame-ancestors 'none'",
|
|
1215
|
+
"X-Content-Type-Options": "nosniff",
|
|
1216
|
+
},
|
|
1099
1217
|
},
|
|
1100
|
-
|
|
1218
|
+
);
|
|
1101
1219
|
}
|
|
1102
1220
|
|
|
1103
1221
|
if (path === "/ui/data") {
|
|
@@ -1109,16 +1227,24 @@ export function createFetchHandler(
|
|
|
1109
1227
|
// After the restriction check, not before: an identity that may not
|
|
1110
1228
|
// read this surface should not get to trigger background work from it.
|
|
1111
1229
|
sweepCredentials();
|
|
1230
|
+
const eligibleClerkOperator =
|
|
1231
|
+
authz.providerKind === "clerk" && Boolean(authz.userId);
|
|
1232
|
+
const credentialManagement = credentialManagementCapability({
|
|
1233
|
+
eligibleClerkOperator,
|
|
1234
|
+
hasCredentialSlots: registry
|
|
1235
|
+
.listConnectors()
|
|
1236
|
+
.some((connector) => Boolean(connector.credential)),
|
|
1237
|
+
hasCredentialVault: Boolean(opts.credentialVault),
|
|
1238
|
+
});
|
|
1112
1239
|
const data = await buildUiData(
|
|
1113
1240
|
registry,
|
|
1114
1241
|
baseUrl,
|
|
1115
1242
|
serverInfo,
|
|
1116
1243
|
// The static headless bearer may read connector health, but only a
|
|
1117
1244
|
// Clerk-authenticated operator receives credential metadata.
|
|
1118
|
-
|
|
1119
|
-
? opts.credentialVault
|
|
1120
|
-
: undefined,
|
|
1245
|
+
eligibleClerkOperator ? opts.credentialVault : undefined,
|
|
1121
1246
|
Boolean(opts.activity?.list),
|
|
1247
|
+
credentialManagement,
|
|
1122
1248
|
);
|
|
1123
1249
|
return privateJson(data);
|
|
1124
1250
|
}
|
package/src/skills.ts
CHANGED
|
@@ -235,12 +235,12 @@ export function resolveSkill(
|
|
|
235
235
|
return {
|
|
236
236
|
found: false,
|
|
237
237
|
message: connectorGuide(bare)
|
|
238
|
-
? `Unknown skill "${name}". Connector guides are fetched as "${connectorSkillName(name)}". Available: ${available()}.`
|
|
238
|
+
? `Unknown skill "${name}". Connector guides are fetched as "${connectorSkillName(name)}". Available skills: ${available()}.`
|
|
239
239
|
: `Connector "${name}" has no usage guide. Available skills: ${available()}.`,
|
|
240
240
|
};
|
|
241
241
|
}
|
|
242
242
|
return {
|
|
243
243
|
found: false,
|
|
244
|
-
message: `Unknown skill "${name}". Available: ${available()}.`,
|
|
244
|
+
message: `Unknown skill "${name}". Available skills: ${available()}.`,
|
|
245
245
|
};
|
|
246
246
|
}
|
package/src/toolkits.ts
CHANGED
|
@@ -129,7 +129,7 @@ function toolFilter(
|
|
|
129
129
|
* an allowlist is a scope the operator did not write, and a scope nobody wrote
|
|
130
130
|
* is not one an operator can reason about. (A definition scopes visibility only;
|
|
131
131
|
* WHICH identity may select it is the separate binding below — see the module
|
|
132
|
-
* header and
|
|
132
|
+
* header and docs/toolkits.md.) Tool names are checked only for connectors that expose
|
|
133
133
|
* `staticTools` (i.e. `api()`); a remote connector's catalog is fetched lazily
|
|
134
134
|
* over the network and is unknown at construction time.
|
|
135
135
|
*/
|
package/src/types.ts
CHANGED
|
@@ -63,7 +63,7 @@ export interface ConnectorCredentialFieldConfig {
|
|
|
63
63
|
name: string;
|
|
64
64
|
/** Short field label, e.g. "Account email". */
|
|
65
65
|
label: string;
|
|
66
|
-
/** Plain-language guidance shown in /
|
|
66
|
+
/** Plain-language guidance shown in /credentials. Never include the credential itself. */
|
|
67
67
|
description?: string;
|
|
68
68
|
/** Input placeholder, e.g. "you@example.com". */
|
|
69
69
|
placeholder?: string;
|
|
@@ -75,7 +75,7 @@ export interface ConnectorCredentialFieldConfig {
|
|
|
75
75
|
export interface ConnectorCredentialConfig {
|
|
76
76
|
/** Short group or field label, e.g. "API token" or "Service credentials". */
|
|
77
77
|
label: string;
|
|
78
|
-
/** Plain-language guidance shown in /
|
|
78
|
+
/** Plain-language guidance shown in /credentials. Never include the credential itself. */
|
|
79
79
|
description?: string;
|
|
80
80
|
/** Password-field placeholder, e.g. "Paste API token". */
|
|
81
81
|
placeholder?: string;
|
|
@@ -100,13 +100,14 @@ export interface ConnectorContext {
|
|
|
100
100
|
/**
|
|
101
101
|
* Read-only access to this connector's operator-managed credential. Present
|
|
102
102
|
* only when the connector declares `credential` and the deployment configures
|
|
103
|
-
* `
|
|
103
|
+
* `credentials.encryptionKey`.
|
|
104
104
|
*/
|
|
105
105
|
credential?: ConnectorCredentialAccess;
|
|
106
106
|
/**
|
|
107
107
|
* Identity shared by connector calls that belong to one inbound request.
|
|
108
108
|
* Connectors may use it to reuse request-safe resources within that request,
|
|
109
|
-
* but must never retain I/O resources beyond the scope's lifetime.
|
|
109
|
+
* but must never retain I/O resources beyond the scope's lifetime. For
|
|
110
|
+
* probe-only scopes the core owns, `Connector.closeScope` signals that end.
|
|
110
111
|
*
|
|
111
112
|
* Optional for custom/test contexts; the context object itself is the scope
|
|
112
113
|
* when omitted.
|
|
@@ -138,7 +139,7 @@ export interface Connector {
|
|
|
138
139
|
/**
|
|
139
140
|
* Max inline result size (bytes) for this connector's tools before
|
|
140
141
|
* call_tool/batch_call truncate and stash the full text for get_result
|
|
141
|
-
* paging. Overrides
|
|
142
|
+
* paging. Overrides `ConnectaConfig.calls.maxResultBytes`;
|
|
142
143
|
* omit to inherit it (which itself defaults to 50_000). Must be a whole
|
|
143
144
|
* number of bytes >= 1; anything else warns at startup and is ignored, so
|
|
144
145
|
* the connector inherits the deployment-wide cap.
|
|
@@ -152,14 +153,14 @@ export interface Connector {
|
|
|
152
153
|
* and imperative; it is read by agents, not operators.
|
|
153
154
|
*/
|
|
154
155
|
usageGuide?: string;
|
|
155
|
-
/** Optional operator-managed credential slot rendered
|
|
156
|
+
/** Optional operator-managed credential slot rendered on /credentials. */
|
|
156
157
|
credential?: ConnectorCredentialConfig;
|
|
157
|
-
/** Optional server-side check used by /
|
|
158
|
+
/** Optional server-side check used by /credentials' Test action. */
|
|
158
159
|
testCredential?(
|
|
159
160
|
value: string,
|
|
160
161
|
ctx: ConnectorContext,
|
|
161
162
|
): Promise<CredentialTestResult>;
|
|
162
|
-
/** Optional multi-field credential check used by /
|
|
163
|
+
/** Optional multi-field credential check used by /credentials' Test action. */
|
|
163
164
|
testCredentials?(
|
|
164
165
|
values: ConnectorCredentialValues,
|
|
165
166
|
ctx: ConnectorContext,
|
|
@@ -189,6 +190,18 @@ export interface Connector {
|
|
|
189
190
|
args: unknown,
|
|
190
191
|
ctx: ConnectorContext,
|
|
191
192
|
): Promise<unknown>;
|
|
193
|
+
/**
|
|
194
|
+
* Optional best-effort teardown for resources retained under
|
|
195
|
+
* `ctx.requestScope`. The core calls this at most once when a scope it created
|
|
196
|
+
* solely for probing ends, and never uses that scope again. Teardown gets a
|
|
197
|
+
* small, fixed best-effort completion window; a missing, rejected, or
|
|
198
|
+
* never-settling hook cannot change or hold open the operation's result
|
|
199
|
+
* beyond that bound.
|
|
200
|
+
*
|
|
201
|
+
* Per-request `/mcp` scopes are not closed through this hook: their
|
|
202
|
+
* request-local reuse remains in force until the request boundary.
|
|
203
|
+
*/
|
|
204
|
+
closeScope?(ctx: ConnectorContext): Promise<void>;
|
|
192
205
|
/** Optional connector-level health/auth status for list_connectors. */
|
|
193
206
|
status?(ctx: ConnectorContext): Promise<ConnectorStatus>;
|
|
194
207
|
/**
|
|
@@ -202,11 +215,11 @@ export interface Connector {
|
|
|
202
215
|
opts?: { force?: boolean },
|
|
203
216
|
): Promise<ConnectorStatus>;
|
|
204
217
|
/**
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
* complete consent with their own account.
|
|
218
|
+
* Verify the OAuth `state` returned to /oauth/callback/<id> against the value
|
|
219
|
+
* this connector generated when it started the flow. Required whenever
|
|
220
|
+
* `finishAuth` is present: the callback rejects before `finishAuth` when this
|
|
221
|
+
* hook is absent, throws, or returns false — otherwise anyone holding the
|
|
222
|
+
* pending URL could complete consent with their own account.
|
|
210
223
|
*/
|
|
211
224
|
verifyState?(state: string | null, ctx: ConnectorContext): Promise<boolean>;
|
|
212
225
|
/** Optional: complete a downstream OAuth flow (called by /oauth/callback/<id>). */
|
|
@@ -215,7 +228,8 @@ export interface Connector {
|
|
|
215
228
|
* Optional: serve a connector-owned HTTP route — for example a signed
|
|
216
229
|
* download link minted by one of the connector's tools. Called only after
|
|
217
230
|
* every built-in route misses, so a connector can never shadow `/mcp`,
|
|
218
|
-
* `/
|
|
231
|
+
* `/`, `/credentials`, `/activity`, `/health`, or the credential API. The
|
|
232
|
+
* first connector to return a
|
|
219
233
|
* Response wins, in registration order; return null to decline.
|
|
220
234
|
*
|
|
221
235
|
* These routes are PUBLIC: connecta applies no auth gate to them. A
|
|
@@ -254,9 +268,9 @@ export interface Executor {
|
|
|
254
268
|
|
|
255
269
|
/**
|
|
256
270
|
* Which toolkits one inbound identity may open — the membership half of the
|
|
257
|
-
* deployment=org / toolkit=team framing (
|
|
258
|
-
* engine: one identity → the toolkit names it may select, plus whether
|
|
259
|
-
* connect with no `?toolkit=` at all.
|
|
271
|
+
* deployment=org / toolkit=team framing (docs/toolkits.md). A mapping, never a
|
|
272
|
+
* policy engine: one identity → the toolkit names it may select, plus whether
|
|
273
|
+
* it may connect with no `?toolkit=` at all.
|
|
260
274
|
*
|
|
261
275
|
* An identity with NO binding is unbound and keeps the pre-binding behavior:
|
|
262
276
|
* any declared toolkit, or the full registry. A binding is enforced at connect
|
|
@@ -304,19 +318,30 @@ export type UiAuthConfig = {
|
|
|
304
318
|
kind: "clerk";
|
|
305
319
|
publishableKey: string;
|
|
306
320
|
/**
|
|
307
|
-
* Origin
|
|
321
|
+
* Origin the operator shell fetches its browser sign-in loader from. **Must be an absolute
|
|
308
322
|
* `https:` URL** — the value lands in a `<script src>`, so the gate is
|
|
309
323
|
* stricter than the branding href gate: no `http:`, no loopback exemption, and
|
|
310
324
|
* no root-relative form (a relative path is rejected, not resolved). The
|
|
311
325
|
* shipped `clerkAuth` adapter derives this from the publishable key and
|
|
312
326
|
* Clerk's Frontend API is always https, so nothing legitimate needs a
|
|
313
327
|
* carve-out. A value that fails the gate reaches neither the loader tag nor
|
|
314
|
-
* the page's inline auth config:
|
|
328
|
+
* the page's inline auth config: operator pages render without it and report
|
|
315
329
|
* that Clerk could not load, and `createConnecta` names the drop in a startup
|
|
316
330
|
* warning.
|
|
317
331
|
*/
|
|
318
332
|
frontendApiUrl: string;
|
|
333
|
+
/**
|
|
334
|
+
* Hosted Account Portal sign-in address, handed to `Clerk.load`. **Must be an
|
|
335
|
+
* absolute `https:` URL** — the same gate `frontendApiUrl` passes, because
|
|
336
|
+
* this value is where Clerk *navigates* the operator's browser. An Account
|
|
337
|
+
* Portal address is always https, so the stricter gate costs nothing real: a
|
|
338
|
+
* value that fails it (a `javascript:`/`data:` payload, a cleartext `http:`
|
|
339
|
+
* address, a relative path) reaches no part of the page, the shell signs in
|
|
340
|
+
* through Clerk's default instead, and `createConnecta` names the drop in a
|
|
341
|
+
* startup warning.
|
|
342
|
+
*/
|
|
319
343
|
signInUrl?: string;
|
|
344
|
+
/** Hosted Account Portal sign-up address. Gated exactly like `signInUrl`. */
|
|
320
345
|
signUpUrl?: string;
|
|
321
346
|
};
|
|
322
347
|
|
|
@@ -334,7 +359,7 @@ export interface ConnectaBranding {
|
|
|
334
359
|
ownerName?: string;
|
|
335
360
|
/** Optional link for the organization or owner label. */
|
|
336
361
|
ownerUrl?: string;
|
|
337
|
-
/**
|
|
362
|
+
/** Operator-page introduction and meta description. */
|
|
338
363
|
description?: string;
|
|
339
364
|
/**
|
|
340
365
|
* Browser tab title and page meta name. Defaults to
|
|
@@ -363,16 +388,16 @@ export interface ConnectaBranding {
|
|
|
363
388
|
export interface InboundAuth {
|
|
364
389
|
kind: string;
|
|
365
390
|
/**
|
|
366
|
-
* Optional browser sign-in configuration. When present,
|
|
391
|
+
* Optional browser sign-in configuration. When present, operator pages use it
|
|
367
392
|
* provider instead of asking the operator to paste a static bearer secret.
|
|
368
393
|
*/
|
|
369
394
|
uiAuth?: UiAuthConfig;
|
|
370
395
|
/**
|
|
371
|
-
* Optional toolkit binding for every identity this provider admits
|
|
372
|
-
* Declared statically so `createConnecta` can validate the
|
|
373
|
-
* `ConnectaConfig.toolkits` and throw on a typo — a binding
|
|
374
|
-
* not one an operator can reason about. An `authorize` result
|
|
375
|
-
* per identity with its own `toolkitBinding`.
|
|
396
|
+
* Optional toolkit binding for every identity this provider admits
|
|
397
|
+
* (docs/toolkits.md). Declared statically so `createConnecta` can validate the
|
|
398
|
+
* names against `ConnectaConfig.toolkits` and throw on a typo — a binding
|
|
399
|
+
* nobody wrote is not one an operator can reason about. An `authorize` result
|
|
400
|
+
* may narrow it per identity with its own `toolkitBinding`.
|
|
376
401
|
*/
|
|
377
402
|
toolkitBinding?: ToolkitBinding;
|
|
378
403
|
/** Serve/short-circuit .well-known + OPTIONS. Return null when not handled. */
|