@zackbart/connecta 0.4.1 → 0.6.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 +527 -0
- package/README.md +83 -7
- package/SECURITY.md +10 -6
- package/dist/activity.d.ts +8 -0
- package/dist/activity.d.ts.map +1 -1
- package/dist/activity.js +1 -0
- package/dist/activity.js.map +1 -1
- package/dist/auth/bearer.d.ts +10 -3
- package/dist/auth/bearer.d.ts.map +1 -1
- package/dist/auth/bearer.js +21 -0
- package/dist/auth/bearer.js.map +1 -1
- package/dist/auth/clerk.d.ts +26 -1
- package/dist/auth/clerk.d.ts.map +1 -1
- package/dist/auth/clerk.js +161 -4
- package/dist/auth/clerk.js.map +1 -1
- package/dist/connectors/api.d.ts +13 -0
- package/dist/connectors/api.d.ts.map +1 -1
- package/dist/connectors/api.js +2 -0
- package/dist/connectors/api.js.map +1 -1
- package/dist/connectors/remote-mcp.d.ts +13 -0
- package/dist/connectors/remote-mcp.d.ts.map +1 -1
- package/dist/connectors/remote-mcp.js +10 -0
- package/dist/connectors/remote-mcp.js.map +1 -1
- package/dist/credential-health.d.ts +212 -0
- package/dist/credential-health.d.ts.map +1 -0
- package/dist/credential-health.js +535 -0
- package/dist/credential-health.js.map +1 -0
- package/dist/execute.d.ts +4 -4
- package/dist/execute.d.ts.map +1 -1
- package/dist/execute.js +16 -4
- package/dist/execute.js.map +1 -1
- package/dist/index.d.ts +77 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +112 -2
- package/dist/index.js.map +1 -1
- package/dist/meta-tools.d.ts +76 -7
- package/dist/meta-tools.d.ts.map +1 -1
- package/dist/meta-tools.js +328 -98
- package/dist/meta-tools.js.map +1 -1
- package/dist/registry.d.ts +245 -2
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +377 -27
- package/dist/registry.js.map +1 -1
- package/dist/server.d.ts +7 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +342 -27
- package/dist/server.js.map +1 -1
- package/dist/skills.d.ts +53 -2
- package/dist/skills.d.ts.map +1 -1
- package/dist/skills.js +162 -2
- package/dist/skills.js.map +1 -1
- package/dist/timeout.d.ts +16 -0
- package/dist/timeout.d.ts.map +1 -0
- package/dist/timeout.js +38 -0
- package/dist/timeout.js.map +1 -0
- package/dist/toolkits.d.ts +138 -0
- package/dist/toolkits.d.ts.map +1 -0
- package/dist/toolkits.js +319 -0
- package/dist/toolkits.js.map +1 -0
- package/dist/types.d.ts +90 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/ui.d.ts +63 -0
- package/dist/ui.d.ts.map +1 -1
- package/dist/ui.js +176 -11
- package/dist/ui.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +5 -2
- package/src/activity.ts +9 -0
- package/src/auth/bearer.ts +35 -1
- package/src/auth/clerk.ts +202 -5
- package/src/connectors/api.ts +15 -0
- package/src/connectors/remote-mcp.ts +24 -0
- package/src/credential-health.ts +736 -0
- package/src/execute.ts +32 -8
- package/src/index.ts +226 -2
- package/src/meta-tools.ts +397 -119
- package/src/registry.ts +540 -29
- package/src/server.ts +431 -25
- package/src/skills.ts +185 -2
- package/src/timeout.ts +49 -0
- package/src/toolkits.ts +450 -0
- package/src/types.ts +96 -2
- package/src/ui.ts +190 -11
- package/src/version.ts +1 -1
package/src/server.ts
CHANGED
|
@@ -11,7 +11,12 @@ import type {
|
|
|
11
11
|
} from "./activity.js";
|
|
12
12
|
import { InvalidActivityCursorError } from "./activity.js";
|
|
13
13
|
import type { CredentialVault } from "./credentials.js";
|
|
14
|
-
import type
|
|
14
|
+
import { ScopedRegistry, type Registry, type RegistryView } from "./registry.js";
|
|
15
|
+
import {
|
|
16
|
+
resolveIdentityBinding,
|
|
17
|
+
TOOLKIT_NAME_RE,
|
|
18
|
+
type Toolkit,
|
|
19
|
+
} from "./toolkits.js";
|
|
15
20
|
import type {
|
|
16
21
|
ConnectorCredentialConfig,
|
|
17
22
|
ConnectorCredentialValues,
|
|
@@ -19,6 +24,7 @@ import type {
|
|
|
19
24
|
Executor,
|
|
20
25
|
InboundAuth,
|
|
21
26
|
Logger,
|
|
27
|
+
ToolkitBinding,
|
|
22
28
|
} from "./types.js";
|
|
23
29
|
import { CONNECTA_FAVICON_ICO } from "./favicon.js";
|
|
24
30
|
import {
|
|
@@ -35,6 +41,35 @@ const CORS_HEADERS = {
|
|
|
35
41
|
"Content-Type, Authorization, mcp-protocol-version, mcp-session-id",
|
|
36
42
|
};
|
|
37
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Headers that make an operator-supplied favicon body inert on this origin.
|
|
46
|
+
* The SVG route is the sharp one: `image/svg+xml` is an *active* content type,
|
|
47
|
+
* so a `<script>` inside a branding SVG would run on the deployment origin the
|
|
48
|
+
* moment anyone navigated straight to `/favicon.svg` — strictly more powerful
|
|
49
|
+
* than the `favicon.href` vector the branding gates close, because the payload
|
|
50
|
+
* is same-origin. Neutralizing the response rather than inspecting the body
|
|
51
|
+
* keeps every valid static SVG (the built-in mark included) byte-identical:
|
|
52
|
+
*
|
|
53
|
+
* - `sandbox` (no tokens ⇒ every restriction) drops the document into an opaque
|
|
54
|
+
* origin with scripting off, so even a script that ran would have nothing to
|
|
55
|
+
* reach.
|
|
56
|
+
* - `default-src 'none'` denies script, network, and framing outright.
|
|
57
|
+
* - `style-src 'unsafe-inline'` is the single allowance: the default mark styles
|
|
58
|
+
* itself inline to follow the OS colour scheme, and CSS cannot script.
|
|
59
|
+
* - `nosniff` keeps the declared type authoritative in both directions — an SVG
|
|
60
|
+
* can never be re-read as HTML, and `.ico` bytes can never be re-read as SVG.
|
|
61
|
+
*
|
|
62
|
+
* `.ico` bodies are deliberately in scope: they are inert bytes rather than
|
|
63
|
+
* active content, so they are still served verbatim, but they carry the same
|
|
64
|
+
* headers so the invariant is "every favicon route is neutralized" rather than
|
|
65
|
+
* "whichever route got attention".
|
|
66
|
+
*/
|
|
67
|
+
const INERT_ICON_HEADERS = {
|
|
68
|
+
"Content-Security-Policy":
|
|
69
|
+
"default-src 'none'; style-src 'unsafe-inline'; sandbox",
|
|
70
|
+
"X-Content-Type-Options": "nosniff",
|
|
71
|
+
};
|
|
72
|
+
|
|
38
73
|
export interface ServerOptions {
|
|
39
74
|
registry: Registry;
|
|
40
75
|
auth: InboundAuth[];
|
|
@@ -57,6 +92,11 @@ export interface ServerOptions {
|
|
|
57
92
|
credentialVault?: CredentialVault;
|
|
58
93
|
/** Optional browser UI and OAuth result-page labels. */
|
|
59
94
|
branding?: ConnectaBranding;
|
|
95
|
+
/**
|
|
96
|
+
* Validated named scopes, selected per connection with `?toolkit=<name>` on
|
|
97
|
+
* `/mcp`. Omit (or leave empty) and every connection sees the full registry.
|
|
98
|
+
*/
|
|
99
|
+
toolkits?: ReadonlyMap<string, Toolkit>;
|
|
60
100
|
}
|
|
61
101
|
|
|
62
102
|
function msg(err: unknown): string {
|
|
@@ -155,16 +195,29 @@ function html(
|
|
|
155
195
|
);
|
|
156
196
|
}
|
|
157
197
|
|
|
198
|
+
/**
|
|
199
|
+
* Refusal for an identity whose toolkit binding cannot be trusted — a malformed
|
|
200
|
+
* declaration, or a malformed per-identity binding out of `authorize`. The
|
|
201
|
+
* caller is authenticated, so this is a 403, and it is deliberately opaque: the
|
|
202
|
+
* cause is an operator bug, and the operator reads it in the log, not the client.
|
|
203
|
+
*/
|
|
204
|
+
function unusableBinding(): Response {
|
|
205
|
+
return privateJson({ error: "forbidden" }, { status: 403 });
|
|
206
|
+
}
|
|
207
|
+
|
|
158
208
|
async function authorize(
|
|
159
209
|
request: Request,
|
|
160
210
|
baseUrl: string,
|
|
161
211
|
auth: InboundAuth[],
|
|
212
|
+
logger: Logger,
|
|
162
213
|
): Promise<
|
|
163
214
|
| {
|
|
164
215
|
ok: true;
|
|
165
216
|
actor: ActivityActor;
|
|
166
217
|
providerKind?: string;
|
|
167
218
|
userId?: string;
|
|
219
|
+
/** The admitting identity's toolkit binding, if it has one (§16). */
|
|
220
|
+
toolkitBinding?: ToolkitBinding;
|
|
168
221
|
}
|
|
169
222
|
| { ok: false; response: Response }
|
|
170
223
|
> {
|
|
@@ -176,6 +229,24 @@ async function authorize(
|
|
|
176
229
|
const result = await provider.authorize(request, baseUrl);
|
|
177
230
|
if (result.ok) {
|
|
178
231
|
const subjectId = result.subjectId ?? result.userId;
|
|
232
|
+
// Re-validate both halves and cap the per-identity one by the provider's
|
|
233
|
+
// declaration (see resolveIdentityBinding). A binding that does not
|
|
234
|
+
// type-check at runtime refuses the request rather than evaporating:
|
|
235
|
+
// dropping it would hand the caller the full registry, which is the one
|
|
236
|
+
// outcome a binding exists to prevent.
|
|
237
|
+
const binding = resolveIdentityBinding(
|
|
238
|
+
provider.toolkitBinding,
|
|
239
|
+
result.toolkitBinding,
|
|
240
|
+
);
|
|
241
|
+
if (!binding.ok) {
|
|
242
|
+
logger.warn(
|
|
243
|
+
`[connecta] refused a request admitted by inbound auth provider ` +
|
|
244
|
+
`"${provider.kind}" with 403: ${binding.reason}. Until it is fixed ` +
|
|
245
|
+
"this provider cannot admit anyone, because connecta cannot tell " +
|
|
246
|
+
"which toolkits the identity may use.",
|
|
247
|
+
);
|
|
248
|
+
return { ok: false, response: unusableBinding() };
|
|
249
|
+
}
|
|
179
250
|
return {
|
|
180
251
|
ok: true,
|
|
181
252
|
actor: {
|
|
@@ -184,6 +255,7 @@ async function authorize(
|
|
|
184
255
|
},
|
|
185
256
|
providerKind: provider.kind,
|
|
186
257
|
...(result.userId ? { userId: result.userId } : {}),
|
|
258
|
+
...(binding.binding ? { toolkitBinding: binding.binding } : {}),
|
|
187
259
|
};
|
|
188
260
|
}
|
|
189
261
|
lastResponse = result.response;
|
|
@@ -260,12 +332,23 @@ async function authorizeUiAdmin(
|
|
|
260
332
|
request: Request,
|
|
261
333
|
baseUrl: string,
|
|
262
334
|
auth: InboundAuth[],
|
|
335
|
+
logger: Logger,
|
|
263
336
|
): Promise<{ ok: true; userId: string } | { ok: false; response: Response }> {
|
|
264
|
-
// Credential mutation is intentionally narrower than /mcp and /ui/data:
|
|
265
|
-
//
|
|
266
|
-
//
|
|
267
|
-
|
|
268
|
-
|
|
337
|
+
// Credential mutation is intentionally narrower than /mcp and /ui/data: only
|
|
338
|
+
// an interactive Clerk provider may admit it. A static bearer token is useful
|
|
339
|
+
// for headless tool calls but must not become a vault-admin key.
|
|
340
|
+
//
|
|
341
|
+
// EVERY Clerk provider gets a turn, the way the /mcp gate does, because the
|
|
342
|
+
// documented per-team pattern is several `clerkAuth(...)`s that differ only in
|
|
343
|
+
// `gate` and `toolkits` (§16). Stopping at the first would make admission
|
|
344
|
+
// depend on config order: the team-bound provider listed first would refuse
|
|
345
|
+
// the operator outright, and a refusal here — a failed gate, a missing user, a
|
|
346
|
+
// toolkit-bound identity — is exactly the case where a later provider is the
|
|
347
|
+
// one meant to admit. The last refusal is returned if none do.
|
|
348
|
+
const providers = auth.filter(
|
|
349
|
+
(candidate) => candidate.uiAuth?.kind === "clerk",
|
|
350
|
+
);
|
|
351
|
+
if (providers.length === 0) {
|
|
269
352
|
return {
|
|
270
353
|
ok: false,
|
|
271
354
|
response: privateJson(
|
|
@@ -274,18 +357,46 @@ async function authorizeUiAdmin(
|
|
|
274
357
|
),
|
|
275
358
|
};
|
|
276
359
|
}
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
360
|
+
let lastResponse: Response | null = null;
|
|
361
|
+
for (const provider of providers) {
|
|
362
|
+
const result = await provider.authorize(request, baseUrl);
|
|
363
|
+
if (!result.ok) {
|
|
364
|
+
lastResponse = result.response;
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
if (!result.userId) {
|
|
368
|
+
lastResponse = privateJson(
|
|
283
369
|
{ error: "authenticated user required" },
|
|
284
370
|
{ status: 403 },
|
|
285
|
-
)
|
|
286
|
-
|
|
371
|
+
);
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
const binding = resolveIdentityBinding(
|
|
375
|
+
provider.toolkitBinding,
|
|
376
|
+
result.toolkitBinding,
|
|
377
|
+
);
|
|
378
|
+
if (!binding.ok) {
|
|
379
|
+
logger.warn(
|
|
380
|
+
`[connecta] refused a credential-API request admitted by inbound auth ` +
|
|
381
|
+
`provider "${provider.kind}" with 403: ${binding.reason}.`,
|
|
382
|
+
);
|
|
383
|
+
lastResponse = unusableBinding();
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
// A toolkit-bound identity is a team's credential, not a vault admin key:
|
|
387
|
+
// credentials are deployment-wide, so writing one reaches every toolkit.
|
|
388
|
+
if (isToolkitRestricted(binding.binding)) {
|
|
389
|
+
lastResponse = restrictedOperatorSurface();
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
return { ok: true, userId: result.userId };
|
|
287
393
|
}
|
|
288
|
-
return {
|
|
394
|
+
return {
|
|
395
|
+
ok: false,
|
|
396
|
+
response:
|
|
397
|
+
lastResponse ??
|
|
398
|
+
privateJson({ error: "forbidden" }, { status: 403 }),
|
|
399
|
+
};
|
|
289
400
|
}
|
|
290
401
|
|
|
291
402
|
function isSameOrigin(request: Request, baseUrl: string): boolean {
|
|
@@ -418,7 +529,12 @@ async function handleCredentialRequest(
|
|
|
418
529
|
{ status: 403 },
|
|
419
530
|
);
|
|
420
531
|
}
|
|
421
|
-
const admin = await authorizeUiAdmin(
|
|
532
|
+
const admin = await authorizeUiAdmin(
|
|
533
|
+
request,
|
|
534
|
+
baseUrl,
|
|
535
|
+
opts.auth,
|
|
536
|
+
opts.logger,
|
|
537
|
+
);
|
|
422
538
|
if (!admin.ok) return admin.response;
|
|
423
539
|
|
|
424
540
|
const connector = opts.registry.getConnector(connectorId);
|
|
@@ -458,6 +574,13 @@ async function handleCredentialRequest(
|
|
|
458
574
|
}
|
|
459
575
|
result = await connector.testCredential!(value, ctx);
|
|
460
576
|
}
|
|
577
|
+
// The operator just ran the very check the liveness sweep runs; record it
|
|
578
|
+
// so the cached status surfaces agree with what /ui just showed them.
|
|
579
|
+
await opts.registry.recordCredentialHealth(connectorId, {
|
|
580
|
+
state: result.ok ? "ok" : "auth_required",
|
|
581
|
+
checkedAt: new Date().toISOString(),
|
|
582
|
+
...(result.message ? { message: result.message } : {}),
|
|
583
|
+
});
|
|
461
584
|
return privateJson(result);
|
|
462
585
|
} catch (err) {
|
|
463
586
|
return privateJson({ ok: false, message: msg(err) });
|
|
@@ -485,6 +608,9 @@ async function handleCredentialRequest(
|
|
|
485
608
|
admin.userId,
|
|
486
609
|
);
|
|
487
610
|
opts.registry.invalidate(connectorId);
|
|
611
|
+
// The credential the last verdict judged is gone; judging its replacement
|
|
612
|
+
// is the next check's job, not this one's.
|
|
613
|
+
await opts.registry.clearCredentialHealth(connectorId);
|
|
488
614
|
return privateJson({ credential: metadata });
|
|
489
615
|
} catch (err) {
|
|
490
616
|
return privateJson({ error: msg(err) }, { status: 400 });
|
|
@@ -494,6 +620,7 @@ async function handleCredentialRequest(
|
|
|
494
620
|
if (request.method === "DELETE") {
|
|
495
621
|
await opts.credentialVault.delete(connectorId);
|
|
496
622
|
opts.registry.invalidate(connectorId);
|
|
623
|
+
await opts.registry.clearCredentialHealth(connectorId);
|
|
497
624
|
return new Response(null, {
|
|
498
625
|
status: 204,
|
|
499
626
|
headers: {
|
|
@@ -506,11 +633,219 @@ async function handleCredentialRequest(
|
|
|
506
633
|
return privateJson({ error: "method not allowed" }, { status: 405 });
|
|
507
634
|
}
|
|
508
635
|
|
|
636
|
+
/** Length beyond which a rejected toolkit name is not echoed back. */
|
|
637
|
+
const MAX_ECHOED_TOOLKIT_NAME = 64;
|
|
638
|
+
|
|
639
|
+
/** What one MCP connection may see: the full registry, or one toolkit's view. */
|
|
640
|
+
interface McpScope {
|
|
641
|
+
registry: RegistryView;
|
|
642
|
+
/** Set only under `?toolkit=`; recorded on activity events. */
|
|
643
|
+
toolkitId?: string;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* Bounded, escaped form of a caller-influenced value (a rejected toolkit name,
|
|
648
|
+
* an identity id) for the operator log. Goes through JSON.stringify so a
|
|
649
|
+
* caller-controlled newline or control character cannot forge a log line, plus a
|
|
650
|
+
* hand-rolled escape for U+2028/U+2029, which JSON.stringify leaves raw even
|
|
651
|
+
* though a log reader treats them as line terminators. Truncated to the same
|
|
652
|
+
* length the response body echoes at, so an oversized value cannot flood the log
|
|
653
|
+
* either.
|
|
654
|
+
*/
|
|
655
|
+
function loggableValue(requested: string): string {
|
|
656
|
+
const bounded = requested.slice(0, MAX_ECHOED_TOOLKIT_NAME);
|
|
657
|
+
const escaped = JSON.stringify(bounded).replace(
|
|
658
|
+
/[\u2028\u2029]/g,
|
|
659
|
+
(ch) => `\\u${ch.charCodeAt(0).toString(16)}`,
|
|
660
|
+
);
|
|
661
|
+
return escaped + (bounded.length < requested.length ? " (truncated)" : "");
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/** The one refusal a bound identity ever sees. Constant on purpose — see below. */
|
|
665
|
+
const TOOLKIT_FORBIDDEN_BODY = JSON.stringify({
|
|
666
|
+
jsonrpc: "2.0",
|
|
667
|
+
id: null,
|
|
668
|
+
error: {
|
|
669
|
+
code: -32600,
|
|
670
|
+
message:
|
|
671
|
+
"Not permitted to use the requested toolkit. This credential is bound " +
|
|
672
|
+
"to a specific toolkit — check the ?toolkit= value in this deployment's " +
|
|
673
|
+
"MCP endpoint URL with the operator.",
|
|
674
|
+
},
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* 403 for every binding refusal, with a body that does not depend on WHY.
|
|
679
|
+
*
|
|
680
|
+
* A bound identity asking for a toolkit it may not open, for a toolkit that does
|
|
681
|
+
* not exist, or for no toolkit at all gets byte-identical responses, so a team
|
|
682
|
+
* credential cannot be used to enumerate the org's other teams — the boundary
|
|
683
|
+
* would leak the very structure it exists to hide. The operator log below is
|
|
684
|
+
* where the three cases are told apart.
|
|
685
|
+
*/
|
|
686
|
+
function toolkitForbidden(): Response {
|
|
687
|
+
return new Response(TOOLKIT_FORBIDDEN_BODY, {
|
|
688
|
+
status: 403,
|
|
689
|
+
headers: {
|
|
690
|
+
"Content-Type": "application/json",
|
|
691
|
+
"Cache-Control": "no-store",
|
|
692
|
+
},
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/** How a rejected connection is named in the operator log. */
|
|
697
|
+
function identityLabel(actor: ActivityActor): string {
|
|
698
|
+
return actor.id ? `${actor.kind} ${loggableValue(actor.id)}` : actor.kind;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* Resolve `?toolkit=<name>` into the registry view this connection may see,
|
|
703
|
+
* enforcing the caller's toolkit binding (§16) on the way.
|
|
704
|
+
*
|
|
705
|
+
* For an UNBOUND identity (no binding configured — the pre-#37 shape):
|
|
706
|
+
*
|
|
707
|
+
* - absent → the full registry, byte-identical to a deployment with no toolkits
|
|
708
|
+
* - known → a `ScopedRegistry` over that toolkit (the one visibility boundary)
|
|
709
|
+
* - anything else, including `?toolkit=` with an empty value → an explicit
|
|
710
|
+
* 404. Never a silent fallback to the full registry.
|
|
711
|
+
*
|
|
712
|
+
* For a BOUND identity, membership is checked FIRST and refusal is a flat 403:
|
|
713
|
+
* a toolkit outside the binding, an unknown name, and (without `unscoped`) an
|
|
714
|
+
* omitted `?toolkit=` are all refused before any `ScopedRegistry` is built, and
|
|
715
|
+
* all three produce the same response.
|
|
716
|
+
*
|
|
717
|
+
* Neither error enumerates the configured toolkits: the name selects a scope, so
|
|
718
|
+
* a wrong guess gets a flat refusal, not a directory.
|
|
719
|
+
*
|
|
720
|
+
* Because of that — and because SDK clients treat a 404/403 on the transport
|
|
721
|
+
* endpoint as a transport failure and discard the body — every rejection is also
|
|
722
|
+
* logged operator-side (issue #47), which is the channel that actually reaches a
|
|
723
|
+
* human. The log line may name the configured or bound toolkits; the response
|
|
724
|
+
* still may not.
|
|
725
|
+
*/
|
|
726
|
+
function resolveToolkitScope(
|
|
727
|
+
url: URL,
|
|
728
|
+
registry: Registry,
|
|
729
|
+
toolkits: ReadonlyMap<string, Toolkit> | undefined,
|
|
730
|
+
logger: Logger,
|
|
731
|
+
identity: { actor: ActivityActor; binding?: ToolkitBinding },
|
|
732
|
+
):
|
|
733
|
+
| { ok: true; scope: McpScope }
|
|
734
|
+
| { ok: false; response: Response } {
|
|
735
|
+
const requested = url.searchParams.get("toolkit");
|
|
736
|
+
const binding = identity.binding;
|
|
737
|
+
const scopeFor = (toolkit: Toolkit) => ({
|
|
738
|
+
ok: true as const,
|
|
739
|
+
scope: {
|
|
740
|
+
registry: new ScopedRegistry(registry, toolkit),
|
|
741
|
+
toolkitId: toolkit.name,
|
|
742
|
+
},
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
if (binding) {
|
|
746
|
+
const who = identityLabel(identity.actor);
|
|
747
|
+
const bound = `Bound toolkits: ${binding.toolkits.join(", ") || "(none)"}${
|
|
748
|
+
binding.unscoped ? ", plus unscoped access" : ""
|
|
749
|
+
}.`;
|
|
750
|
+
if (requested === null) {
|
|
751
|
+
if (binding.unscoped) return { ok: true, scope: { registry } };
|
|
752
|
+
logger.warn(
|
|
753
|
+
`[connecta] refused an unscoped /mcp connection from ${who} with 403: ` +
|
|
754
|
+
"its toolkit binding does not allow the full registry. " +
|
|
755
|
+
bound +
|
|
756
|
+
" The client sees a transport-level failure and never the reason, so " +
|
|
757
|
+
"give it an MCP endpoint URL with a ?toolkit= value it is bound to.",
|
|
758
|
+
);
|
|
759
|
+
return { ok: false, response: toolkitForbidden() };
|
|
760
|
+
}
|
|
761
|
+
const permitted = binding.toolkits.includes(requested);
|
|
762
|
+
const toolkit = permitted ? toolkits?.get(requested) : undefined;
|
|
763
|
+
if (toolkit) return scopeFor(toolkit);
|
|
764
|
+
logger.warn(
|
|
765
|
+
`[connecta] refused an /mcp connection from ${who} with 403: it asked ` +
|
|
766
|
+
`for toolkit ${loggableValue(requested)}, which ` +
|
|
767
|
+
(permitted
|
|
768
|
+
? "its binding allows but this deployment does not configure"
|
|
769
|
+
: "its toolkit binding does not include") +
|
|
770
|
+
". " +
|
|
771
|
+
bound +
|
|
772
|
+
" The client sees a transport-level failure and never the reason, so " +
|
|
773
|
+
"check the ?toolkit= value in its MCP endpoint URL.",
|
|
774
|
+
);
|
|
775
|
+
return { ok: false, response: toolkitForbidden() };
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
if (requested === null) return { ok: true, scope: { registry } };
|
|
779
|
+
const toolkit = toolkits?.get(requested);
|
|
780
|
+
if (toolkit) return scopeFor(toolkit);
|
|
781
|
+
const configured = toolkits && toolkits.size > 0 ? [...toolkits.keys()] : [];
|
|
782
|
+
logger.warn(
|
|
783
|
+
"[connecta] rejected an /mcp connection asking for unknown toolkit " +
|
|
784
|
+
`${loggableValue(requested)} with 404. ` +
|
|
785
|
+
(configured.length > 0
|
|
786
|
+
? `Configured toolkits: ${configured.join(", ")}.`
|
|
787
|
+
: "This deployment configures no toolkits, so no ?toolkit= value is accepted.") +
|
|
788
|
+
" The client sees a transport-level failure and never the reason, so " +
|
|
789
|
+
"check the ?toolkit= value in its MCP endpoint URL.",
|
|
790
|
+
);
|
|
791
|
+
const label =
|
|
792
|
+
requested.length <= MAX_ECHOED_TOOLKIT_NAME &&
|
|
793
|
+
TOOLKIT_NAME_RE.test(requested)
|
|
794
|
+
? `"${requested}"`
|
|
795
|
+
: "requested";
|
|
796
|
+
return {
|
|
797
|
+
ok: false,
|
|
798
|
+
response: new Response(
|
|
799
|
+
JSON.stringify({
|
|
800
|
+
jsonrpc: "2.0",
|
|
801
|
+
id: null,
|
|
802
|
+
error: {
|
|
803
|
+
code: -32600,
|
|
804
|
+
message:
|
|
805
|
+
`Unknown toolkit ${label}. Check the ?toolkit= value in this ` +
|
|
806
|
+
"deployment's MCP endpoint URL with the operator.",
|
|
807
|
+
},
|
|
808
|
+
}),
|
|
809
|
+
{
|
|
810
|
+
status: 404,
|
|
811
|
+
headers: {
|
|
812
|
+
"Content-Type": "application/json",
|
|
813
|
+
"Cache-Control": "no-store",
|
|
814
|
+
},
|
|
815
|
+
},
|
|
816
|
+
),
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* True when this identity is confined to one or more toolkits — bound, without
|
|
822
|
+
* `unscoped`. Such a credential belongs to a team's agent, not to the operator
|
|
823
|
+
* running the deployment, so the deployment-wide operator surfaces (`/ui/data`,
|
|
824
|
+
* `/ui/activity`, the credential API) refuse it: their payloads describe every
|
|
825
|
+
* connector in the org, which is exactly what the binding exists to withhold.
|
|
826
|
+
*/
|
|
827
|
+
function isToolkitRestricted(binding: ToolkitBinding | undefined): boolean {
|
|
828
|
+
return Boolean(binding && !binding.unscoped);
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/** The refusal the deployment-wide operator surfaces give a bound identity. */
|
|
832
|
+
function restrictedOperatorSurface(): Response {
|
|
833
|
+
return privateJson(
|
|
834
|
+
{
|
|
835
|
+
error:
|
|
836
|
+
"this credential is bound to a toolkit and may not read " +
|
|
837
|
+
"deployment-wide operator data",
|
|
838
|
+
},
|
|
839
|
+
{ status: 403 },
|
|
840
|
+
);
|
|
841
|
+
}
|
|
842
|
+
|
|
509
843
|
async function serveMcp(
|
|
510
844
|
request: Request,
|
|
511
845
|
opts: ServerOptions,
|
|
512
846
|
baseUrl: string,
|
|
513
847
|
actor: ActivityActor,
|
|
848
|
+
scope: McpScope,
|
|
514
849
|
runtimeContext?: RuntimeExecutionContext,
|
|
515
850
|
): Promise<Response> {
|
|
516
851
|
// Fresh McpServer + transport per request (SDK ≥1.26 requirement), stateless.
|
|
@@ -526,20 +861,24 @@ async function serveMcp(
|
|
|
526
861
|
...(opts.activityDeploymentId
|
|
527
862
|
? { deploymentId: opts.activityDeploymentId }
|
|
528
863
|
: {}),
|
|
864
|
+
...(scope.toolkitId ? { toolkitId: scope.toolkitId } : {}),
|
|
529
865
|
...(runtimeContext?.waitUntil
|
|
530
866
|
? { defer: runtimeContext.waitUntil.bind(runtimeContext) }
|
|
531
867
|
: {}),
|
|
532
868
|
logger: opts.logger,
|
|
533
869
|
}
|
|
534
870
|
: undefined;
|
|
535
|
-
|
|
871
|
+
// `scope.registry` is the connection's VIEW — the full registry, or one
|
|
872
|
+
// toolkit's ScopedRegistry. Nothing below may reach for `opts.registry`.
|
|
873
|
+
const registry = scope.registry;
|
|
874
|
+
registerMetaTools(server, registry, {
|
|
536
875
|
baseUrl,
|
|
537
876
|
activity,
|
|
538
877
|
defaultToolTimeoutMs: opts.defaultToolTimeoutMs,
|
|
539
878
|
probeTimeoutMs: opts.probeTimeoutMs,
|
|
540
879
|
});
|
|
541
880
|
if (opts.executor) {
|
|
542
|
-
registerExecuteTool(server,
|
|
881
|
+
registerExecuteTool(server, registry, {
|
|
543
882
|
baseUrl,
|
|
544
883
|
executor: opts.executor,
|
|
545
884
|
logger: opts.logger,
|
|
@@ -586,6 +925,10 @@ async function handleOAuthCallback(
|
|
|
586
925
|
try {
|
|
587
926
|
await connector.finishAuth(code, context);
|
|
588
927
|
await registry.invalidateStored(id);
|
|
928
|
+
// Recovery, without a restart: the grant this connector was reported dead
|
|
929
|
+
// for has just been replaced, so drop the verdict rather than let a stale
|
|
930
|
+
// `auth_required` survive until the next scheduled check.
|
|
931
|
+
await registry.clearCredentialHealth(id);
|
|
589
932
|
return html(
|
|
590
933
|
`Connected "${id}". You can close this window.`,
|
|
591
934
|
200,
|
|
@@ -611,6 +954,35 @@ export function createFetchHandler(
|
|
|
611
954
|
const url = new URL(request.url);
|
|
612
955
|
const baseUrl = publicUrl ?? url.origin;
|
|
613
956
|
const path = url.pathname;
|
|
957
|
+
|
|
958
|
+
/**
|
|
959
|
+
* Piggyback a DUE credential liveness sweep on traffic that has already been
|
|
960
|
+
* authenticated (issue #24). Started beside the request and never awaited by
|
|
961
|
+
* it: it must not add latency or change a result, so it is handed to
|
|
962
|
+
* `ctx.waitUntil` where the runtime has one (Workers, and the Node adapter's
|
|
963
|
+
* shim) to settle after the response. The registry answers `undefined`
|
|
964
|
+
* unless a sweep is actually due, so the ordinary request pays nothing.
|
|
965
|
+
*/
|
|
966
|
+
const sweepCredentials = (): void => {
|
|
967
|
+
// Belt and braces: a rejected sweep is already absorbed below, and this
|
|
968
|
+
// catches the synchronous half — arming the gate, or a connector list that
|
|
969
|
+
// throws while deciding whether anything is due. Nothing about a
|
|
970
|
+
// background health check may turn a served request into a 500.
|
|
971
|
+
try {
|
|
972
|
+
const sweep = registry.sweepCredentialHealthIfDue(baseUrl);
|
|
973
|
+
if (!sweep) return;
|
|
974
|
+
const settled = sweep.then(
|
|
975
|
+
() => {},
|
|
976
|
+
(err) => {
|
|
977
|
+
opts.logger.warn("[connecta] credential health sweep failed", err);
|
|
978
|
+
},
|
|
979
|
+
);
|
|
980
|
+
if (runtimeContext?.waitUntil) runtimeContext.waitUntil(settled);
|
|
981
|
+
else void settled;
|
|
982
|
+
} catch (err) {
|
|
983
|
+
opts.logger.warn("[connecta] credential health sweep failed", err);
|
|
984
|
+
}
|
|
985
|
+
};
|
|
614
986
|
// Container and orchestrator probes reach /health over plain HTTP on
|
|
615
987
|
// loopback, where no proxy has set X-Forwarded-Proto. Redirecting them to
|
|
616
988
|
// the public origin would make an internal liveness check depend on
|
|
@@ -690,6 +1062,7 @@ export function createFetchHandler(
|
|
|
690
1062
|
headers: {
|
|
691
1063
|
"Content-Type": "image/svg+xml",
|
|
692
1064
|
"Cache-Control": "public, max-age=86400",
|
|
1065
|
+
...INERT_ICON_HEADERS,
|
|
693
1066
|
},
|
|
694
1067
|
});
|
|
695
1068
|
}
|
|
@@ -699,6 +1072,7 @@ export function createFetchHandler(
|
|
|
699
1072
|
headers: {
|
|
700
1073
|
"Content-Type": "image/x-icon",
|
|
701
1074
|
"Cache-Control": "public, max-age=86400",
|
|
1075
|
+
...INERT_ICON_HEADERS,
|
|
702
1076
|
},
|
|
703
1077
|
});
|
|
704
1078
|
}
|
|
@@ -727,8 +1101,14 @@ export function createFetchHandler(
|
|
|
727
1101
|
}
|
|
728
1102
|
|
|
729
1103
|
if (path === "/ui/data") {
|
|
730
|
-
const authz = await authorize(request, baseUrl, auth);
|
|
1104
|
+
const authz = await authorize(request, baseUrl, auth, opts.logger);
|
|
731
1105
|
if (!authz.ok) return authz.response;
|
|
1106
|
+
if (isToolkitRestricted(authz.toolkitBinding)) {
|
|
1107
|
+
return restrictedOperatorSurface();
|
|
1108
|
+
}
|
|
1109
|
+
// After the restriction check, not before: an identity that may not
|
|
1110
|
+
// read this surface should not get to trigger background work from it.
|
|
1111
|
+
sweepCredentials();
|
|
732
1112
|
const data = await buildUiData(
|
|
733
1113
|
registry,
|
|
734
1114
|
baseUrl,
|
|
@@ -747,8 +1127,11 @@ export function createFetchHandler(
|
|
|
747
1127
|
if (request.method !== "GET") {
|
|
748
1128
|
return privateJson({ error: "method not allowed" }, { status: 405 });
|
|
749
1129
|
}
|
|
750
|
-
const authz = await authorize(request, baseUrl, auth);
|
|
1130
|
+
const authz = await authorize(request, baseUrl, auth, opts.logger);
|
|
751
1131
|
if (!authz.ok) return authz.response;
|
|
1132
|
+
if (isToolkitRestricted(authz.toolkitBinding)) {
|
|
1133
|
+
return restrictedOperatorSurface();
|
|
1134
|
+
}
|
|
752
1135
|
if (
|
|
753
1136
|
opts.activityReadGate &&
|
|
754
1137
|
!(await opts.activityReadGate(authz.actor))
|
|
@@ -784,11 +1167,34 @@ export function createFetchHandler(
|
|
|
784
1167
|
}
|
|
785
1168
|
|
|
786
1169
|
if (path === "/mcp") {
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
1170
|
+
// Authenticate BEFORE resolving ?toolkit=: an unauthenticated caller
|
|
1171
|
+
// must not be able to probe which toolkit names exist.
|
|
1172
|
+
const authz = await authorize(request, baseUrl, auth, opts.logger);
|
|
1173
|
+
if (!authz.ok) return withMcpCors(authz.response);
|
|
1174
|
+
const selected = resolveToolkitScope(
|
|
1175
|
+
url,
|
|
1176
|
+
registry,
|
|
1177
|
+
opts.toolkits,
|
|
1178
|
+
opts.logger,
|
|
1179
|
+
{
|
|
1180
|
+
actor: authz.actor,
|
|
1181
|
+
...(authz.toolkitBinding
|
|
1182
|
+
? { binding: authz.toolkitBinding }
|
|
1183
|
+
: {}),
|
|
1184
|
+
},
|
|
1185
|
+
);
|
|
1186
|
+
if (!selected.ok) return withMcpCors(selected.response);
|
|
1187
|
+
sweepCredentials();
|
|
1188
|
+
return withMcpCors(
|
|
1189
|
+
await serveMcp(
|
|
1190
|
+
request,
|
|
1191
|
+
opts,
|
|
1192
|
+
baseUrl,
|
|
1193
|
+
authz.actor,
|
|
1194
|
+
selected.scope,
|
|
1195
|
+
runtimeContext,
|
|
1196
|
+
),
|
|
1197
|
+
);
|
|
792
1198
|
}
|
|
793
1199
|
|
|
794
1200
|
// Connector-owned public routes, dispatched last: a connector can add a
|