@zackbart/connecta 0.5.0 → 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 +358 -0
- package/README.md +53 -12
- 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/remote-mcp.d.ts.map +1 -1
- package/dist/connectors/remote-mcp.js +8 -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.map +1 -1
- package/dist/execute.js +16 -4
- package/dist/execute.js.map +1 -1
- package/dist/index.d.ts +46 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +96 -13
- package/dist/index.js.map +1 -1
- package/dist/meta-tools.d.ts +56 -5
- package/dist/meta-tools.d.ts.map +1 -1
- package/dist/meta-tools.js +249 -92
- package/dist/meta-tools.js.map +1 -1
- package/dist/registry.d.ts +62 -0
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +85 -1
- package/dist/registry.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +292 -37
- package/dist/server.js.map +1 -1
- package/dist/skills.d.ts +1 -1
- package/dist/skills.d.ts.map +1 -1
- package/dist/skills.js +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 +95 -1
- package/dist/toolkits.d.ts.map +1 -1
- package/dist/toolkits.js +190 -5
- package/dist/toolkits.js.map +1 -1
- package/dist/types.d.ts +70 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/ui.d.ts +35 -0
- package/dist/ui.d.ts.map +1 -1
- package/dist/ui.js +87 -3
- package/dist/ui.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/src/auth/bearer.ts +35 -1
- package/src/auth/clerk.ts +202 -5
- package/src/connectors/remote-mcp.ts +9 -0
- package/src/credential-health.ts +736 -0
- package/src/execute.ts +28 -4
- package/src/index.ts +176 -20
- package/src/meta-tools.ts +286 -109
- package/src/registry.ts +125 -1
- package/src/server.ts +349 -34
- package/src/skills.ts +1 -1
- package/src/timeout.ts +49 -0
- package/src/toolkits.ts +241 -6
- package/src/types.ts +76 -1
- package/src/ui.ts +98 -3
- package/src/version.ts +1 -1
package/src/server.ts
CHANGED
|
@@ -12,7 +12,11 @@ import type {
|
|
|
12
12
|
import { InvalidActivityCursorError } from "./activity.js";
|
|
13
13
|
import type { CredentialVault } from "./credentials.js";
|
|
14
14
|
import { ScopedRegistry, type Registry, type RegistryView } from "./registry.js";
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
resolveIdentityBinding,
|
|
17
|
+
TOOLKIT_NAME_RE,
|
|
18
|
+
type Toolkit,
|
|
19
|
+
} from "./toolkits.js";
|
|
16
20
|
import type {
|
|
17
21
|
ConnectorCredentialConfig,
|
|
18
22
|
ConnectorCredentialValues,
|
|
@@ -20,6 +24,7 @@ import type {
|
|
|
20
24
|
Executor,
|
|
21
25
|
InboundAuth,
|
|
22
26
|
Logger,
|
|
27
|
+
ToolkitBinding,
|
|
23
28
|
} from "./types.js";
|
|
24
29
|
import { CONNECTA_FAVICON_ICO } from "./favicon.js";
|
|
25
30
|
import {
|
|
@@ -36,6 +41,35 @@ const CORS_HEADERS = {
|
|
|
36
41
|
"Content-Type, Authorization, mcp-protocol-version, mcp-session-id",
|
|
37
42
|
};
|
|
38
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
|
+
|
|
39
73
|
export interface ServerOptions {
|
|
40
74
|
registry: Registry;
|
|
41
75
|
auth: InboundAuth[];
|
|
@@ -161,16 +195,29 @@ function html(
|
|
|
161
195
|
);
|
|
162
196
|
}
|
|
163
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
|
+
|
|
164
208
|
async function authorize(
|
|
165
209
|
request: Request,
|
|
166
210
|
baseUrl: string,
|
|
167
211
|
auth: InboundAuth[],
|
|
212
|
+
logger: Logger,
|
|
168
213
|
): Promise<
|
|
169
214
|
| {
|
|
170
215
|
ok: true;
|
|
171
216
|
actor: ActivityActor;
|
|
172
217
|
providerKind?: string;
|
|
173
218
|
userId?: string;
|
|
219
|
+
/** The admitting identity's toolkit binding, if it has one (§16). */
|
|
220
|
+
toolkitBinding?: ToolkitBinding;
|
|
174
221
|
}
|
|
175
222
|
| { ok: false; response: Response }
|
|
176
223
|
> {
|
|
@@ -182,6 +229,24 @@ async function authorize(
|
|
|
182
229
|
const result = await provider.authorize(request, baseUrl);
|
|
183
230
|
if (result.ok) {
|
|
184
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
|
+
}
|
|
185
250
|
return {
|
|
186
251
|
ok: true,
|
|
187
252
|
actor: {
|
|
@@ -190,6 +255,7 @@ async function authorize(
|
|
|
190
255
|
},
|
|
191
256
|
providerKind: provider.kind,
|
|
192
257
|
...(result.userId ? { userId: result.userId } : {}),
|
|
258
|
+
...(binding.binding ? { toolkitBinding: binding.binding } : {}),
|
|
193
259
|
};
|
|
194
260
|
}
|
|
195
261
|
lastResponse = result.response;
|
|
@@ -266,12 +332,23 @@ async function authorizeUiAdmin(
|
|
|
266
332
|
request: Request,
|
|
267
333
|
baseUrl: string,
|
|
268
334
|
auth: InboundAuth[],
|
|
335
|
+
logger: Logger,
|
|
269
336
|
): Promise<{ ok: true; userId: string } | { ok: false; response: Response }> {
|
|
270
|
-
// Credential mutation is intentionally narrower than /mcp and /ui/data:
|
|
271
|
-
//
|
|
272
|
-
//
|
|
273
|
-
|
|
274
|
-
|
|
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) {
|
|
275
352
|
return {
|
|
276
353
|
ok: false,
|
|
277
354
|
response: privateJson(
|
|
@@ -280,18 +357,46 @@ async function authorizeUiAdmin(
|
|
|
280
357
|
),
|
|
281
358
|
};
|
|
282
359
|
}
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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(
|
|
289
369
|
{ error: "authenticated user required" },
|
|
290
370
|
{ status: 403 },
|
|
291
|
-
)
|
|
292
|
-
|
|
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 };
|
|
293
393
|
}
|
|
294
|
-
return {
|
|
394
|
+
return {
|
|
395
|
+
ok: false,
|
|
396
|
+
response:
|
|
397
|
+
lastResponse ??
|
|
398
|
+
privateJson({ error: "forbidden" }, { status: 403 }),
|
|
399
|
+
};
|
|
295
400
|
}
|
|
296
401
|
|
|
297
402
|
function isSameOrigin(request: Request, baseUrl: string): boolean {
|
|
@@ -424,7 +529,12 @@ async function handleCredentialRequest(
|
|
|
424
529
|
{ status: 403 },
|
|
425
530
|
);
|
|
426
531
|
}
|
|
427
|
-
const admin = await authorizeUiAdmin(
|
|
532
|
+
const admin = await authorizeUiAdmin(
|
|
533
|
+
request,
|
|
534
|
+
baseUrl,
|
|
535
|
+
opts.auth,
|
|
536
|
+
opts.logger,
|
|
537
|
+
);
|
|
428
538
|
if (!admin.ok) return admin.response;
|
|
429
539
|
|
|
430
540
|
const connector = opts.registry.getConnector(connectorId);
|
|
@@ -464,6 +574,13 @@ async function handleCredentialRequest(
|
|
|
464
574
|
}
|
|
465
575
|
result = await connector.testCredential!(value, ctx);
|
|
466
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
|
+
});
|
|
467
584
|
return privateJson(result);
|
|
468
585
|
} catch (err) {
|
|
469
586
|
return privateJson({ ok: false, message: msg(err) });
|
|
@@ -491,6 +608,9 @@ async function handleCredentialRequest(
|
|
|
491
608
|
admin.userId,
|
|
492
609
|
);
|
|
493
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);
|
|
494
614
|
return privateJson({ credential: metadata });
|
|
495
615
|
} catch (err) {
|
|
496
616
|
return privateJson({ error: msg(err) }, { status: 400 });
|
|
@@ -500,6 +620,7 @@ async function handleCredentialRequest(
|
|
|
500
620
|
if (request.method === "DELETE") {
|
|
501
621
|
await opts.credentialVault.delete(connectorId);
|
|
502
622
|
opts.registry.invalidate(connectorId);
|
|
623
|
+
await opts.registry.clearCredentialHealth(connectorId);
|
|
503
624
|
return new Response(null, {
|
|
504
625
|
status: 204,
|
|
505
626
|
headers: {
|
|
@@ -523,35 +644,150 @@ interface McpScope {
|
|
|
523
644
|
}
|
|
524
645
|
|
|
525
646
|
/**
|
|
526
|
-
*
|
|
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):
|
|
527
706
|
*
|
|
528
707
|
* - absent → the full registry, byte-identical to a deployment with no toolkits
|
|
529
|
-
* - known → a `ScopedRegistry` over that toolkit (the one
|
|
708
|
+
* - known → a `ScopedRegistry` over that toolkit (the one visibility boundary)
|
|
530
709
|
* - anything else, including `?toolkit=` with an empty value → an explicit
|
|
531
|
-
*
|
|
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.
|
|
532
716
|
*
|
|
533
|
-
*
|
|
534
|
-
*
|
|
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.
|
|
535
725
|
*/
|
|
536
726
|
function resolveToolkitScope(
|
|
537
727
|
url: URL,
|
|
538
728
|
registry: Registry,
|
|
539
729
|
toolkits: ReadonlyMap<string, Toolkit> | undefined,
|
|
730
|
+
logger: Logger,
|
|
731
|
+
identity: { actor: ActivityActor; binding?: ToolkitBinding },
|
|
540
732
|
):
|
|
541
733
|
| { ok: true; scope: McpScope }
|
|
542
734
|
| { ok: false; response: Response } {
|
|
543
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
|
+
|
|
544
778
|
if (requested === null) return { ok: true, scope: { registry } };
|
|
545
779
|
const toolkit = toolkits?.get(requested);
|
|
546
|
-
if (toolkit)
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
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
|
+
);
|
|
555
791
|
const label =
|
|
556
792
|
requested.length <= MAX_ECHOED_TOOLKIT_NAME &&
|
|
557
793
|
TOOLKIT_NAME_RE.test(requested)
|
|
@@ -581,6 +817,29 @@ function resolveToolkitScope(
|
|
|
581
817
|
};
|
|
582
818
|
}
|
|
583
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
|
+
|
|
584
843
|
async function serveMcp(
|
|
585
844
|
request: Request,
|
|
586
845
|
opts: ServerOptions,
|
|
@@ -666,6 +925,10 @@ async function handleOAuthCallback(
|
|
|
666
925
|
try {
|
|
667
926
|
await connector.finishAuth(code, context);
|
|
668
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);
|
|
669
932
|
return html(
|
|
670
933
|
`Connected "${id}". You can close this window.`,
|
|
671
934
|
200,
|
|
@@ -691,6 +954,35 @@ export function createFetchHandler(
|
|
|
691
954
|
const url = new URL(request.url);
|
|
692
955
|
const baseUrl = publicUrl ?? url.origin;
|
|
693
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
|
+
};
|
|
694
986
|
// Container and orchestrator probes reach /health over plain HTTP on
|
|
695
987
|
// loopback, where no proxy has set X-Forwarded-Proto. Redirecting them to
|
|
696
988
|
// the public origin would make an internal liveness check depend on
|
|
@@ -770,6 +1062,7 @@ export function createFetchHandler(
|
|
|
770
1062
|
headers: {
|
|
771
1063
|
"Content-Type": "image/svg+xml",
|
|
772
1064
|
"Cache-Control": "public, max-age=86400",
|
|
1065
|
+
...INERT_ICON_HEADERS,
|
|
773
1066
|
},
|
|
774
1067
|
});
|
|
775
1068
|
}
|
|
@@ -779,6 +1072,7 @@ export function createFetchHandler(
|
|
|
779
1072
|
headers: {
|
|
780
1073
|
"Content-Type": "image/x-icon",
|
|
781
1074
|
"Cache-Control": "public, max-age=86400",
|
|
1075
|
+
...INERT_ICON_HEADERS,
|
|
782
1076
|
},
|
|
783
1077
|
});
|
|
784
1078
|
}
|
|
@@ -807,8 +1101,14 @@ export function createFetchHandler(
|
|
|
807
1101
|
}
|
|
808
1102
|
|
|
809
1103
|
if (path === "/ui/data") {
|
|
810
|
-
const authz = await authorize(request, baseUrl, auth);
|
|
1104
|
+
const authz = await authorize(request, baseUrl, auth, opts.logger);
|
|
811
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();
|
|
812
1112
|
const data = await buildUiData(
|
|
813
1113
|
registry,
|
|
814
1114
|
baseUrl,
|
|
@@ -827,8 +1127,11 @@ export function createFetchHandler(
|
|
|
827
1127
|
if (request.method !== "GET") {
|
|
828
1128
|
return privateJson({ error: "method not allowed" }, { status: 405 });
|
|
829
1129
|
}
|
|
830
|
-
const authz = await authorize(request, baseUrl, auth);
|
|
1130
|
+
const authz = await authorize(request, baseUrl, auth, opts.logger);
|
|
831
1131
|
if (!authz.ok) return authz.response;
|
|
1132
|
+
if (isToolkitRestricted(authz.toolkitBinding)) {
|
|
1133
|
+
return restrictedOperatorSurface();
|
|
1134
|
+
}
|
|
832
1135
|
if (
|
|
833
1136
|
opts.activityReadGate &&
|
|
834
1137
|
!(await opts.activityReadGate(authz.actor))
|
|
@@ -866,10 +1169,22 @@ export function createFetchHandler(
|
|
|
866
1169
|
if (path === "/mcp") {
|
|
867
1170
|
// Authenticate BEFORE resolving ?toolkit=: an unauthenticated caller
|
|
868
1171
|
// must not be able to probe which toolkit names exist.
|
|
869
|
-
const authz = await authorize(request, baseUrl, auth);
|
|
1172
|
+
const authz = await authorize(request, baseUrl, auth, opts.logger);
|
|
870
1173
|
if (!authz.ok) return withMcpCors(authz.response);
|
|
871
|
-
const selected = resolveToolkitScope(
|
|
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
|
+
);
|
|
872
1186
|
if (!selected.ok) return withMcpCors(selected.response);
|
|
1187
|
+
sweepCredentials();
|
|
873
1188
|
return withMcpCors(
|
|
874
1189
|
await serveMcp(
|
|
875
1190
|
request,
|
package/src/skills.ts
CHANGED
|
@@ -16,7 +16,7 @@ export const USAGE_SKILL = `# Connecta usage
|
|
|
16
16
|
- Truncated result: retry with \`fields\` when possible; otherwise page it with \`get_result\`.
|
|
17
17
|
- \`auth_required\`: use \`authorize_connector\`, have the operator complete consent, then confirm with \`list_connectors\`.
|
|
18
18
|
|
|
19
|
-
Use \`list_connectors({ probe: false })\` for a fast inventory. Use \`probe: true\` only when diagnosing live health or authorization.
|
|
19
|
+
Use \`list_connectors({ probe: false })\` for a fast inventory. Use \`probe: true\` only when diagnosing live health or authorization. The fast inventory already reports a connector whose stored credential failed a proactive check as \`auth_required\` (with \`credentialCheck\` and the URL to open), so trust it and authorize up front rather than probing to confirm.
|
|
20
20
|
|
|
21
21
|
## Code mode
|
|
22
22
|
|
package/src/timeout.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// The deadline vocabulary shared by every non-call downstream probe: the
|
|
2
|
+
// discovery meta-tools' catalog fan-out (src/meta-tools.ts) and the credential
|
|
3
|
+
// liveness checks (src/credential-health.ts). One definition so a "probe" means
|
|
4
|
+
// the same thing, and is bounded the same way, wherever one is issued.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Generous default bound for a single downstream probe/catalog call. High enough
|
|
8
|
+
* to trip only on a pathological hang, not a realistically slow probe.
|
|
9
|
+
*/
|
|
10
|
+
export const DEFAULT_PROBE_TIMEOUT_MS = 30_000;
|
|
11
|
+
|
|
12
|
+
/** A finite, positive integer number of milliseconds, or undefined. */
|
|
13
|
+
export function normalizeTimeoutMs(
|
|
14
|
+
value: number | undefined,
|
|
15
|
+
): number | undefined {
|
|
16
|
+
if (value === undefined || !Number.isFinite(value) || !(value > 0)) {
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
return Math.max(1, Math.trunc(value));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Reject `promise` after `ms` if it has not settled, so one hung downstream
|
|
24
|
+
* cannot stall a whole fan-out. NOTE: this bounds only the caller-facing wait —
|
|
25
|
+
* the registry probe methods take no AbortSignal, so the underlying fetch is
|
|
26
|
+
* NOT cancelled and keeps running in the background. Real cancellation
|
|
27
|
+
* (AbortSignal plumbed through the registry) is a deferred follow-up.
|
|
28
|
+
*/
|
|
29
|
+
export function withTimeout<T>(
|
|
30
|
+
promise: Promise<T>,
|
|
31
|
+
ms: number,
|
|
32
|
+
label: string,
|
|
33
|
+
): Promise<T> {
|
|
34
|
+
return new Promise<T>((resolve, reject) => {
|
|
35
|
+
const timer = setTimeout(() => {
|
|
36
|
+
reject(new Error(`${label} timed out after ${ms}ms`));
|
|
37
|
+
}, ms);
|
|
38
|
+
promise.then(
|
|
39
|
+
(value) => {
|
|
40
|
+
clearTimeout(timer);
|
|
41
|
+
resolve(value);
|
|
42
|
+
},
|
|
43
|
+
(err) => {
|
|
44
|
+
clearTimeout(timer);
|
|
45
|
+
reject(err);
|
|
46
|
+
},
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
}
|