@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.
Files changed (85) hide show
  1. package/CHANGELOG.md +527 -0
  2. package/README.md +83 -7
  3. package/SECURITY.md +10 -6
  4. package/dist/activity.d.ts +8 -0
  5. package/dist/activity.d.ts.map +1 -1
  6. package/dist/activity.js +1 -0
  7. package/dist/activity.js.map +1 -1
  8. package/dist/auth/bearer.d.ts +10 -3
  9. package/dist/auth/bearer.d.ts.map +1 -1
  10. package/dist/auth/bearer.js +21 -0
  11. package/dist/auth/bearer.js.map +1 -1
  12. package/dist/auth/clerk.d.ts +26 -1
  13. package/dist/auth/clerk.d.ts.map +1 -1
  14. package/dist/auth/clerk.js +161 -4
  15. package/dist/auth/clerk.js.map +1 -1
  16. package/dist/connectors/api.d.ts +13 -0
  17. package/dist/connectors/api.d.ts.map +1 -1
  18. package/dist/connectors/api.js +2 -0
  19. package/dist/connectors/api.js.map +1 -1
  20. package/dist/connectors/remote-mcp.d.ts +13 -0
  21. package/dist/connectors/remote-mcp.d.ts.map +1 -1
  22. package/dist/connectors/remote-mcp.js +10 -0
  23. package/dist/connectors/remote-mcp.js.map +1 -1
  24. package/dist/credential-health.d.ts +212 -0
  25. package/dist/credential-health.d.ts.map +1 -0
  26. package/dist/credential-health.js +535 -0
  27. package/dist/credential-health.js.map +1 -0
  28. package/dist/execute.d.ts +4 -4
  29. package/dist/execute.d.ts.map +1 -1
  30. package/dist/execute.js +16 -4
  31. package/dist/execute.js.map +1 -1
  32. package/dist/index.d.ts +77 -2
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +112 -2
  35. package/dist/index.js.map +1 -1
  36. package/dist/meta-tools.d.ts +76 -7
  37. package/dist/meta-tools.d.ts.map +1 -1
  38. package/dist/meta-tools.js +328 -98
  39. package/dist/meta-tools.js.map +1 -1
  40. package/dist/registry.d.ts +245 -2
  41. package/dist/registry.d.ts.map +1 -1
  42. package/dist/registry.js +377 -27
  43. package/dist/registry.js.map +1 -1
  44. package/dist/server.d.ts +7 -1
  45. package/dist/server.d.ts.map +1 -1
  46. package/dist/server.js +342 -27
  47. package/dist/server.js.map +1 -1
  48. package/dist/skills.d.ts +53 -2
  49. package/dist/skills.d.ts.map +1 -1
  50. package/dist/skills.js +162 -2
  51. package/dist/skills.js.map +1 -1
  52. package/dist/timeout.d.ts +16 -0
  53. package/dist/timeout.d.ts.map +1 -0
  54. package/dist/timeout.js +38 -0
  55. package/dist/timeout.js.map +1 -0
  56. package/dist/toolkits.d.ts +138 -0
  57. package/dist/toolkits.d.ts.map +1 -0
  58. package/dist/toolkits.js +319 -0
  59. package/dist/toolkits.js.map +1 -0
  60. package/dist/types.d.ts +90 -1
  61. package/dist/types.d.ts.map +1 -1
  62. package/dist/ui.d.ts +63 -0
  63. package/dist/ui.d.ts.map +1 -1
  64. package/dist/ui.js +176 -11
  65. package/dist/ui.js.map +1 -1
  66. package/dist/version.d.ts +1 -1
  67. package/dist/version.js +1 -1
  68. package/package.json +5 -2
  69. package/src/activity.ts +9 -0
  70. package/src/auth/bearer.ts +35 -1
  71. package/src/auth/clerk.ts +202 -5
  72. package/src/connectors/api.ts +15 -0
  73. package/src/connectors/remote-mcp.ts +24 -0
  74. package/src/credential-health.ts +736 -0
  75. package/src/execute.ts +32 -8
  76. package/src/index.ts +226 -2
  77. package/src/meta-tools.ts +397 -119
  78. package/src/registry.ts +540 -29
  79. package/src/server.ts +431 -25
  80. package/src/skills.ts +185 -2
  81. package/src/timeout.ts +49 -0
  82. package/src/toolkits.ts +450 -0
  83. package/src/types.ts +96 -2
  84. package/src/ui.ts +190 -11
  85. 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 { Registry } from "./registry.js";
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
- // only the interactive Clerk provider may admit it. A static bearer token is
266
- // useful for headless tool calls but must not become a vault-admin key.
267
- const provider = auth.find((candidate) => candidate.uiAuth?.kind === "clerk");
268
- if (!provider) {
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
- const result = await provider.authorize(request, baseUrl);
278
- if (!result.ok) return result;
279
- if (!result.userId) {
280
- return {
281
- ok: false,
282
- response: privateJson(
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 { ok: true, userId: result.userId };
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(request, baseUrl, opts.auth);
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
- registerMetaTools(server, opts.registry, {
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, opts.registry, {
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
- const authz = await authorize(request, baseUrl, auth);
788
- const response = authz.ok
789
- ? await serveMcp(request, opts, baseUrl, authz.actor, runtimeContext)
790
- : authz.response;
791
- return withMcpCors(response);
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