@zackbart/connecta 0.32.0 → 0.33.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 (137) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/README.md +15 -7
  3. package/bin/connecta.mjs +24 -6
  4. package/bin/version.mjs +1 -1
  5. package/dist/access-tokens.d.ts +167 -23
  6. package/dist/access-tokens.js +676 -140
  7. package/dist/agent-identity.js +47 -0
  8. package/dist/auth/cloudflare-access.js +13 -5
  9. package/dist/call-admission-context.js +15 -0
  10. package/dist/call-admission.js +30 -43
  11. package/dist/call-recovery.js +6 -2
  12. package/dist/config-value-sources.js +11 -9
  13. package/dist/config.d.ts +37 -75
  14. package/dist/config.js +110 -36
  15. package/dist/connector-access.d.ts +4 -34
  16. package/dist/connector-access.js +15 -99
  17. package/dist/connector-caller.d.ts +7 -1
  18. package/dist/connector-caller.js +3 -2
  19. package/dist/connectors/api-connector.d.ts +27 -4
  20. package/dist/connectors/api-connector.js +2 -0
  21. package/dist/connectors/remote-mcp.js +2 -0
  22. package/dist/describe-config.d.ts +29 -7
  23. package/dist/describe-config.js +7 -17
  24. package/dist/index.d.ts +4 -3
  25. package/dist/index.js +29 -10
  26. package/dist/invocation.js +5 -3
  27. package/dist/membership.d.ts +51 -0
  28. package/dist/membership.js +121 -0
  29. package/dist/module-contracts.d.ts +23 -6
  30. package/dist/operator-record.d.ts +2 -2
  31. package/dist/operator-record.js +2 -4
  32. package/dist/operator-ui/agents.d.ts +74 -0
  33. package/dist/operator-ui/agents.js +104 -0
  34. package/dist/operator-ui/contract.d.ts +149 -28
  35. package/dist/operator-ui/endpoints.js +192 -0
  36. package/dist/operator-ui/fix-prompts.js +4 -4
  37. package/dist/operator-ui/generated.js +5 -4
  38. package/dist/operator-ui/member-page.js +112 -0
  39. package/dist/operator-ui/model.d.ts +14 -6
  40. package/dist/operator-ui/view.d.ts +84 -19
  41. package/dist/operator-ui/view.js +82 -21
  42. package/dist/page-styles.js +1 -1
  43. package/dist/provider.d.ts +61 -8
  44. package/dist/provider.js +46 -7
  45. package/dist/providers/_shared/google/workspace.d.ts +53 -16
  46. package/dist/providers/_shared/google/workspace.js +30 -8
  47. package/dist/providers/_shared/rest/tools.js +2 -2
  48. package/dist/providers/_shared/rest/value-safety.d.ts +50 -0
  49. package/dist/providers/_shared/rest/value-safety.js +699 -7
  50. package/dist/providers/ccb/index.js +1 -1
  51. package/dist/providers/cloudflare/index.js +3 -3
  52. package/dist/providers/cloudflare/rest.js +25 -3
  53. package/dist/providers/cloudflare/skill.generated.js +1 -1
  54. package/dist/providers/cloudflare/value-safety.js +32 -0
  55. package/dist/providers/docs/index.js +3 -3
  56. package/dist/providers/docs/skill.generated.js +2 -2
  57. package/dist/providers/drive/index.js +3 -3
  58. package/dist/providers/drive/skill.generated.js +2 -2
  59. package/dist/providers/forms/index.js +3 -3
  60. package/dist/providers/forms/skill.generated.js +2 -2
  61. package/dist/providers/gmail/index.js +10 -8
  62. package/dist/providers/gmail/skill.generated.js +2 -2
  63. package/dist/providers/mixpanel/index.js +1 -1
  64. package/dist/providers/notion/index.d.ts +7 -0
  65. package/dist/providers/notion/index.js +100 -11
  66. package/dist/providers/notion/rest.js +45 -5
  67. package/dist/providers/notion/skill.generated.js +2 -2
  68. package/dist/providers/notion/value-safety.js +30 -5
  69. package/dist/providers/overflow/index.js +1 -1
  70. package/dist/providers/planning-center/index.js +1 -1
  71. package/dist/providers/sheets/index.d.ts +3 -0
  72. package/dist/providers/sheets/index.js +6 -3
  73. package/dist/providers/sheets/skill.generated.js +2 -2
  74. package/dist/providers/slides/index.js +3 -3
  75. package/dist/providers/slides/skill.generated.js +2 -2
  76. package/dist/providers/stripe/rest.js +9 -2
  77. package/dist/providers/stripe/skill.generated.js +1 -1
  78. package/dist/providers/stripe/value-safety.js +22 -0
  79. package/dist/providers/vercel/rest.js +20 -2
  80. package/dist/providers/vercel/skill.generated.js +1 -1
  81. package/dist/providers/vercel/value-safety.js +22 -0
  82. package/dist/registry.d.ts +2 -3
  83. package/dist/registry.js +8 -9
  84. package/dist/routes/access-tokens.js +82 -75
  85. package/dist/routes/activity-disclosure.js +17 -18
  86. package/dist/routes/activity.js +34 -54
  87. package/dist/routes/actor-labels.js +33 -4
  88. package/dist/routes/agent-path.js +43 -0
  89. package/dist/routes/connect.js +16 -12
  90. package/dist/routes/credentials.js +16 -40
  91. package/dist/routes/mcp.js +145 -95
  92. package/dist/routes/oauth-management.js +11 -5
  93. package/dist/routes/oauth.js +3 -9
  94. package/dist/routes/operator.js +24 -23
  95. package/dist/routes/shared.d.ts +60 -20
  96. package/dist/routes/shared.js +187 -96
  97. package/dist/routes/ui-config.js +437 -97
  98. package/dist/routes/ui-data.js +2 -2
  99. package/dist/routes/ui.js +9 -16
  100. package/dist/runtime/call-admission.js +9 -0
  101. package/dist/server.js +5 -0
  102. package/dist/storage/keys.js +9 -1
  103. package/dist/types.d.ts +11 -0
  104. package/dist/ui.d.ts +1 -1
  105. package/dist/ui.js +16 -14
  106. package/dist/version.d.ts +1 -1
  107. package/dist/version.js +1 -1
  108. package/dist/workspace-address.js +9 -0
  109. package/dist/workspace-subject.js +23 -0
  110. package/documentation/README.md +46 -22
  111. package/documentation/architecture.md +355 -50
  112. package/documentation/auth.md +775 -243
  113. package/documentation/code-mode.md +14 -11
  114. package/documentation/deploying.md +121 -24
  115. package/documentation/integrating.md +92 -21
  116. package/documentation/meta-tools.md +14 -9
  117. package/documentation/migration-0.33.md +344 -0
  118. package/documentation/operating.md +3 -1
  119. package/documentation/operator-tests.md +115 -28
  120. package/documentation/operator-ui.md +326 -61
  121. package/documentation/provider-auth-migration.md +37 -23
  122. package/documentation/provider-migration-0.29.md +2 -2
  123. package/examples/worker/AGENTS.md +30 -8
  124. package/examples/worker/README.md +92 -53
  125. package/examples/worker/src/connecta.config.ts +36 -20
  126. package/examples/worker/wrangler.jsonc +3 -0
  127. package/package.json +3 -2
  128. package/templates/node/.env.example +3 -0
  129. package/templates/node/AGENTS.md +27 -6
  130. package/templates/node/Dockerfile +2 -2
  131. package/templates/node/README.md +67 -42
  132. package/templates/node/docker-compose.yml +3 -2
  133. package/templates/node/package.json +4 -3
  134. package/templates/node/src/adopt-token.ts +23 -0
  135. package/templates/node/src/connecta.config.ts +28 -12
  136. package/templates/node/src/rotate-token.ts +17 -0
  137. package/templates/node/src/provision-token.ts +0 -17
@@ -8,13 +8,13 @@ import { Duration, Effect, Exit, Option, Result, Scope } from "effect";
8
8
  import { registerExecuteTool } from "../execute.js";
9
9
  import { ExecutorAdmissionError } from "../executor-admission.js";
10
10
  import { registerMetaTools } from "../meta-tools.js";
11
- import { intersectAccess } from "../connector-access.js";
12
11
  import { CONNECTA_INSTRUCTIONS } from "../skills.js";
13
12
  import { registerSkills } from "./skills.js";
14
13
  import { failureRecord, logFailure } from "../operator-record.js";
15
14
  import { redactAgentOutput, sentSecretsForRequest } from "../sent-secrets.js";
16
15
  import { detach } from "../runtime/run.js";
17
- import { authorize, mayManageConnector, validateAuthPermissions, } from "./shared.js";
16
+ import { agentEndpointPool, isAgentPath } from "./agent-path.js";
17
+ import { admitsMaintainer, agentPool, authorize, authorizeAgent, mayManageConnector, memberPool, } from "./shared.js";
18
18
  /**
19
19
  * The SDK's own bound on the body it reads. Since server 2.1.0 the SDK
20
20
  * answers anything over 4 MiB with its own 413, which would quietly shadow
@@ -26,10 +26,22 @@ import { authorize, mayManageConnector, validateAuthPermissions, } from "./share
26
26
  const SDK_BODY_BOUND = { maxRequestBodySize: Number.MAX_SAFE_INTEGER };
27
27
  // Identity, not a status/header a custom auth response could imitate.
28
28
  const deadlineResponses = new WeakSet();
29
- async function isModernListen(request, signal) {
29
+ /**
30
+ * The most a `subscriptions/listen` classification reads. A listen is a small
31
+ * JSON-RPC request; this read happens before request admission, so it is
32
+ * bounded by this fixed cap rather than by the host's body limit (#857).
33
+ */
34
+ export const MAX_LISTEN_CLASSIFICATION_BYTES = 64 * 1024;
35
+ /**
36
+ * Whether an admitted, authenticated request is a modern `subscriptions/listen`.
37
+ * Reads at most `MAX_LISTEN_CLASSIFICATION_BYTES` of a clone, then stops and
38
+ * cancels the body: `"too_large"` past the cap, `"aborted"` when the request
39
+ * ended or its lifetime ran out while reading.
40
+ */
41
+ async function classifyListen(request, signal) {
30
42
  const reader = request.clone().body?.getReader();
31
43
  if (!reader)
32
- return false;
44
+ return "other";
33
45
  const onAbort = () => {
34
46
  void reader.cancel(signal.reason).catch(() => { });
35
47
  void request.body?.cancel(signal.reason).catch(() => { });
@@ -46,12 +58,15 @@ async function isModernListen(request, signal) {
46
58
  if (done)
47
59
  break;
48
60
  bytes += value.byteLength;
49
- if (bytes > SDK_BODY_BOUND.maxRequestBodySize)
50
- return false;
61
+ if (bytes > MAX_LISTEN_CLASSIFICATION_BYTES) {
62
+ void reader.cancel().catch(() => { });
63
+ void request.body?.cancel().catch(() => { });
64
+ return "too_large";
65
+ }
51
66
  text += decoder.decode(value, { stream: true });
52
67
  }
53
68
  if (signal.aborted)
54
- return false;
69
+ return "aborted";
55
70
  const protocolVersion = request.headers.get("MCP-Protocol-Version");
56
71
  const method = request.headers.get("Mcp-Method");
57
72
  const route = classifyInboundRequest({
@@ -60,11 +75,15 @@ async function isModernListen(request, signal) {
60
75
  ...(method !== null ? { mcpMethodHeader: method } : {}),
61
76
  body: JSON.parse(text + decoder.decode()),
62
77
  });
63
- return (route.kind === "modern" && route.messageKind === "request" && route.message.method === "subscriptions/listen");
78
+ return route.kind === "modern" && route.messageKind === "request" && route.message.method === "subscriptions/listen"
79
+ ? "listen"
80
+ : "other";
64
81
  }
65
82
  catch {
83
+ if (signal.aborted)
84
+ return "aborted";
66
85
  // Leave unreadable, malformed, and legacy bodies to the normal SDK path.
67
- return false;
86
+ return "other";
68
87
  }
69
88
  finally {
70
89
  signal.removeEventListener("abort", onAbort);
@@ -456,6 +475,21 @@ function serveMcp(request, requestSignal, requestScope, opts, baseUrl, actor, re
456
475
  return new Response(text, { status: response.status, statusText: response.statusText, headers });
457
476
  })));
458
477
  }
478
+ function mcpEndpoint(path) {
479
+ if (isAgentPath(path)) {
480
+ const pool = agentEndpointPool(path);
481
+ return pool === undefined ? { kind: "refused" } : { kind: "agent", pool };
482
+ }
483
+ if (path === "/mcp")
484
+ return { kind: "default" };
485
+ if (path.startsWith("/mcp/"))
486
+ return { kind: "pool", pool: path.slice("/mcp/".length) };
487
+ return null;
488
+ }
489
+ /** Whether `path` is an MCP or agent endpoint path, valid or not. */
490
+ function isMcpPath(path) {
491
+ return mcpEndpoint(path) !== null;
492
+ }
459
493
  export function createMcpRoute(opts) {
460
494
  const configuredOrigins = opts.config.allowedOrigins;
461
495
  const isExactOrigin = (value) => {
@@ -490,8 +524,7 @@ export function createMcpRoute(opts) {
490
524
  return hostname === "localhost" || hostname === "[::1]" || /^127\.\d+\.\d+\.\d+$/.test(hostname);
491
525
  };
492
526
  const rejectOrigin = (request) => {
493
- const path = new URL(request.url).pathname;
494
- if (path !== "/mcp" && !path.startsWith("/mcp/"))
527
+ if (!isMcpPath(new URL(request.url).pathname))
495
528
  return null;
496
529
  const origin = request.headers.get("Origin");
497
530
  if (origin === null || allowsOrigin(origin))
@@ -516,25 +549,37 @@ export function createMcpRoute(opts) {
516
549
  suppressedAdmissionWarnings = 0;
517
550
  };
518
551
  function routeMcp(context) {
519
- const { path, request, baseUrl, runtimeContext } = context;
552
+ const { path, request, baseUrl } = context;
520
553
  // Protected-resource metadata stays public, including when a grant is
521
554
  // configured, so a fresh OAuth client can discover how to authenticate.
522
- if (path !== "/mcp" && !path.startsWith("/mcp/"))
555
+ const endpoint = mcpEndpoint(path);
556
+ if (!endpoint)
523
557
  return Effect.succeed(null);
524
558
  // Admission, refusal bodies, SDK serialization and connector work share
525
559
  // one set. Connector contexts still receive an opaque scope, never the
526
560
  // inbound Request or its headers.
527
561
  const requestScope = {};
528
562
  const sentSecrets = sentSecretsForRequest(requestScope, sentSecretsForRequest(request));
529
- const poolName = path === "/mcp" ? undefined : path.slice("/mcp/".length);
563
+ const poolName = endpoint.kind === "pool" || endpoint.kind === "agent" ? endpoint.pool : undefined;
564
+ const agentEndpoint = endpoint.kind === "agent";
565
+ // Access attaches `ctx.access` even to a path it bypasses; an agent
566
+ // endpoint never reads it, so nothing past this point receives it.
567
+ const runtimeContext = agentEndpoint && context.runtimeContext
568
+ ? { waitUntil: context.runtimeContext.waitUntil.bind(context.runtimeContext) }
569
+ : context.runtimeContext;
530
570
  const origin = request.headers.get("Origin");
531
571
  const allowed = origin === null || allowsOrigin(origin);
532
572
  const cors = (response) => withMcpCors(response, request, configuredOrigins === "*" ? "*" : allowed ? origin : null);
573
+ const notFound = () => cors(mcpRefusal(sentSecrets, 404, -33004, "MCP endpoint not found."));
533
574
  // DNS-rebinding refusals cost neither a permit nor an auth lookup. This
534
575
  // local header check also guards OPTIONS before any provider metadata.
535
576
  const refusal = rejectOrigin(request);
536
577
  if (refusal)
537
578
  return Effect.succeed(refusal);
579
+ // Every other spelling in the agent namespace is the pool 404, before any
580
+ // admission or auth: no decoding, case folding, or slash collapsing.
581
+ if (endpoint.kind === "refused")
582
+ return Effect.succeed(notFound());
538
583
  if (request.method === "OPTIONS") {
539
584
  return Effect.succeed(cors(new Response(null, { status: 204 })));
540
585
  }
@@ -555,38 +600,30 @@ export function createMcpRoute(opts) {
555
600
  if (deadlineTimer !== undefined)
556
601
  clearTimeout(deadlineTimer);
557
602
  }));
558
- // Only a body-confirmed modern listen may skip admission. Trusting the
559
- // method header alone would let a legacy tools/call bypass the pool.
603
+ // A request naming `subscriptions/listen` defers its permit: nothing of
604
+ // its body is read until auth and endpoint admission pass, and then at
605
+ // most MAX_LISTEN_CLASSIFICATION_BYTES (#857). Only a body-confirmed
606
+ // modern listen goes without a permit; trusting the header alone would
607
+ // let a legacy tools/call bypass the pool, so anything else takes one
608
+ // after classification. Every other request takes its permit here,
609
+ // before auth, and no body is read before auth on any path.
560
610
  const startedAt = Date.now();
561
611
  const maxDurationMs = opts.requestAdmission.maxDurationMs;
562
- let listen = false;
563
- if (request.method === "POST" && request.headers.get("Mcp-Method") === "subscriptions/listen") {
564
- const classify = Effect.promise(() => isModernListen(request, localAbort.signal));
565
- const prepared = maxDurationMs === undefined
566
- ? Option.some(yield* classify)
567
- : yield* classify.pipe(Effect.timeoutOption(Duration.millis(maxDurationMs)));
568
- if (Option.isNone(prepared)) {
569
- localAbort.abort(new Error("MCP request lifetime exceeded."));
570
- return cors(mcpRefusal(sentSecrets, 504, -33003, "MCP request lifetime exceeded."));
571
- }
572
- listen = prepared.value;
573
- }
574
- const admission = listen
612
+ const listenHeader = request.method === "POST" && request.headers.get("Mcp-Method") === "subscriptions/listen";
613
+ const refuseAdmission = (error) => {
614
+ if (!(error instanceof ExecutorAdmissionError))
615
+ return Effect.fail(error);
616
+ if (error.code === "executor_cancelled")
617
+ return Effect.fail(request.signal.reason ?? error);
618
+ if (error.code === "executor_overloaded")
619
+ warnAdmissionRejected(error);
620
+ return Effect.succeed(cors(requestAdmissionFailure(sentSecrets, error)));
621
+ };
622
+ const admission = listenHeader
575
623
  ? Result.succeed(undefined)
576
624
  : yield* Effect.result(admitted(opts.requestAdmission, request.signal));
577
- if (Result.isFailure(admission)) {
578
- const error = admission.failure;
579
- if (!(error instanceof ExecutorAdmissionError)) {
580
- return yield* Effect.fail(error);
581
- }
582
- if (error.code === "executor_cancelled") {
583
- return yield* Effect.fail(request.signal.reason ?? error);
584
- }
585
- if (error.code === "executor_overloaded") {
586
- warnAdmissionRejected(error);
587
- }
588
- return cors(requestAdmissionFailure(sentSecrets, error));
589
- }
625
+ if (Result.isFailure(admission))
626
+ return yield* refuseAdmission(admission.failure);
590
627
  const remainingMs = admission.success
591
628
  ? admission.success.remainingMs()
592
629
  : maxDurationMs === undefined
@@ -613,8 +650,34 @@ export function createMcpRoute(opts) {
613
650
  // Effect `authorize` would put Effect in the /activity bundle, which
614
651
  // shares it, to save only the lookups an abandoned authorization
615
652
  // finishes on its own.
653
+ // Agent endpoints consult only the `cta_` adapter; human endpoints refuse `cta_`.
654
+ const admit = () => agentEndpoint
655
+ ? authorizeAgent(localRequest, baseUrl, opts.config.auth)
656
+ : authorize(localRequest, baseUrl, opts.config.auth, runtimeContext);
657
+ // Plain /mcp admits maintainers and serves the whole catalog; a pool
658
+ // admits its members, people at /mcp/<pool> and agents at
659
+ // /agent/<pool>, and serves exactly its tools (INV-4).
660
+ const scopeFor = (admitted) => {
661
+ if (poolName === undefined) {
662
+ return admitsMaintainer(opts, admitted)
663
+ ? { access: { connectorIds: "all" }, readOnly: opts.config.readOnly }
664
+ : undefined;
665
+ }
666
+ const pool = agentEndpoint
667
+ ? agentPool(opts, poolName, admitted.identity)
668
+ : memberPool(opts, poolName, admitted.identity);
669
+ // The owner travels with the scope, so both pool endpoints set the
670
+ // same caller facts (#837 P5: agents act as the pool owner).
671
+ return pool
672
+ ? {
673
+ access: pool.access,
674
+ readOnly: pool.readOnly,
675
+ ...(pool.owner !== undefined ? { poolOwner: pool.owner } : {}),
676
+ }
677
+ : undefined;
678
+ };
616
679
  const handled = Effect.gen(function* () {
617
- const authz = yield* Effect.promise(() => authorize(localRequest, baseUrl, opts.config.auth, runtimeContext, opts.config.identity));
680
+ const authz = yield* Effect.promise(admit);
618
681
  if (!authz.ok) {
619
682
  const response = authz.response;
620
683
  // Preserve the auth adapter's challenge and status. The MCP host must
@@ -641,79 +704,63 @@ export function createMcpRoute(opts) {
641
704
  },
642
705
  })), { status: response.status, headers }));
643
706
  }
644
- // A pool endpoint narrows the identity's own view and nothing else. An
645
- // undeclared name, a grant that refuses, and a grant that throws are
646
- // one identical 404 so a credential never enumerates the other pools;
707
+ // A non-maintainer, a non-member, and an undeclared pool are one
708
+ // identical 404 so a credential never enumerates the other pools;
647
709
  // the operator log is where the reason lives.
648
- let access = authz;
649
- let readOnly = opts.config.readOnly;
650
- if (poolName === undefined && opts.config.grant) {
651
- const denialReason = yield* Effect.promise(async () => {
652
- try {
653
- return (await opts.config.grant(authz.identity)) === true
654
- ? undefined
655
- : "default_grant_denied";
656
- }
657
- catch {
658
- return "default_grant_threw";
659
- }
660
- });
661
- if (denialReason !== undefined) {
662
- logFailure(opts.config.logger, "MCP default endpoint request denied", failureRecord({ reason: denialReason }));
663
- return cors(mcpRefusal(sentSecrets, 404, -33004, "MCP endpoint not found."));
664
- }
710
+ const scope = scopeFor(authz);
711
+ if (!scope) {
712
+ logFailure(opts.config.logger, poolName === undefined
713
+ ? "MCP default endpoint request denied"
714
+ : agentEndpoint
715
+ ? "MCP agent endpoint request denied"
716
+ : "MCP pool request denied", failureRecord({
717
+ reason: poolName === undefined
718
+ ? "not_maintainer"
719
+ : opts.pools.has(poolName)
720
+ ? "not_pool_member"
721
+ : "pool_not_declared",
722
+ }));
723
+ return notFound();
665
724
  }
666
- if (poolName !== undefined) {
667
- const pool = opts.pools?.get(poolName);
668
- const denialReason = !pool
669
- ? "pool_not_declared"
670
- : yield* Effect.promise(async () => {
671
- try {
672
- return (await pool.grant(authz.identity)) === true ? undefined : "pool_grant_denied";
673
- }
674
- catch {
675
- return "pool_grant_threw";
676
- }
677
- });
678
- if (!pool || denialReason !== undefined) {
679
- logFailure(opts.config.logger, "MCP pool request denied", failureRecord({ reason: denialReason ?? "pool_not_declared" }));
680
- return cors(mcpRefusal(sentSecrets, 404, -33004, "MCP endpoint not found."));
725
+ const { access, readOnly, poolOwner } = scope;
726
+ if (listenHeader) {
727
+ const verdict = yield* Effect.promise(() => classifyListen(localRequest, localAbort.signal));
728
+ // The deadline timer aborts a stalled read; its body is already cancelled.
729
+ if (verdict === "aborted") {
730
+ return cors(mcpRefusal(sentSecrets, 504, -33003, "MCP request lifetime exceeded."));
731
+ }
732
+ if (verdict === "too_large") {
733
+ return cors(mcpRefusal(sentSecrets, 413, -33006, "MCP request body too large."));
734
+ }
735
+ if (verdict === "other") {
736
+ const late = yield* Effect.result(admitted(opts.requestAdmission, request.signal));
737
+ if (Result.isFailure(late))
738
+ return yield* refuseAdmission(late.failure);
681
739
  }
682
- access = intersectAccess(authz, pool.access);
683
- readOnly = pool.readOnly;
684
740
  }
685
741
  let scopedRegistry;
686
742
  try {
687
- validateAuthPermissions(authz, opts.registry);
688
743
  scopedRegistry = opts.registry.scoped({
689
744
  connectorIds: access.connectorIds,
690
745
  ...(access.toolAccess ? { toolAccess: access.toolAccess } : {}),
691
- ...(access.guardedToolAccess ? { guardedToolAccess: access.guardedToolAccess } : {}),
692
746
  ...(authz.subjectKey ? { subjectKey: authz.subjectKey } : {}),
693
747
  ...(authz.principalKey ? { principalKey: authz.principalKey } : {}),
694
748
  endpoint: new URL(request.url).pathname,
695
749
  origin: request.headers.get("Origin"),
696
750
  currentResultAccess: async (address, classification, signal, access) => {
697
751
  signal?.throwIfAborted();
698
- const current = await authorize(localRequest, baseUrl, opts.config.auth, runtimeContext, opts.config.identity);
752
+ const current = await admit();
699
753
  if (!current.ok) {
700
754
  await current.response.body?.cancel().catch(() => { });
701
755
  return false;
702
756
  }
703
757
  if (current.subjectKey !== authz.subjectKey || current.principalKey !== authz.principalKey)
704
758
  return false;
705
- validateAuthPermissions(current, opts.registry);
706
- if (poolName === undefined && opts.config.grant && (await opts.config.grant(current.identity)) !== true)
759
+ // Paging re-admits the caller at this endpoint, as a new request would.
760
+ const currentScope = scopeFor(current);
761
+ if (!currentScope)
707
762
  return false;
708
- let currentAccess = current;
709
- let currentReadOnly = opts.config.readOnly;
710
- if (poolName !== undefined) {
711
- const pool = opts.pools.get(poolName);
712
- if (!pool || (await pool.grant(current.identity)) !== true)
713
- return false;
714
- currentAccess = intersectAccess(current, pool.access);
715
- currentReadOnly = pool.readOnly;
716
- }
763
+ const { access: currentAccess, readOnly: currentReadOnly } = currentScope;
717
764
  const view = opts.registry.scoped({
718
765
  ...currentAccess,
719
766
  ...(current.subjectKey ? { subjectKey: current.subjectKey } : {}),
@@ -747,7 +794,10 @@ export function createMcpRoute(opts) {
747
794
  // `authorize` admits an open deployment's every request as the
748
795
  // anonymous actor; only a provider's `ok` is an authentication.
749
796
  authenticated: opts.config.auth.length > 0,
797
+ // Both pool endpoints build the caller here from the same
798
+ // admitted scope, so an agent at /agent/<pool> carries the owner.
750
799
  ...(poolName !== undefined ? { pool: poolName } : {}),
800
+ ...(poolOwner !== undefined ? { poolOwner } : {}),
751
801
  },
752
802
  });
753
803
  }
@@ -759,7 +809,7 @@ export function createMcpRoute(opts) {
759
809
  }
760
810
  return cors(yield* serveMcp(localRequest, localAbort.signal, requestScope, opts, baseUrl, authz.actor, scopedRegistry, (id) => {
761
811
  const connector = scopedRegistry.getConnector(id);
762
- return Boolean(connector && mayManageConnector(authz, connector));
812
+ return Boolean(connector && mayManageConnector(opts, authz, connector));
763
813
  }, authz.principalKey, runtimeContext, readOnly, poolName, authz.identity.principal
764
814
  ? {
765
815
  kind: authz.actor.kind,
@@ -2,7 +2,7 @@ import { OAuthRevocationError } from "../auth/downstream-revocation.js";
2
2
  import { Effect, Result } from "effect";
3
3
  import { closeScope } from "../runtime/connector-scope.js";
4
4
  import { oauthConnectUrl, oauthConnectUnavailable } from "../oauth-handoff.js";
5
- import { authorizedPerson, refuse, serveOperator, visibleRegistry, Answer } from "./operator.js";
5
+ import { authorizedPerson, refuse, serveOperator, managementRegistry, Answer } from "./operator.js";
6
6
  import { failureRecord, logFailure } from "../operator-record.js";
7
7
  import { mayManageConnector, isSameOrigin, privateJson } from "./shared.js";
8
8
  /** A UI action signs its requested mode; the verified /connect visit applies it. */
@@ -20,14 +20,17 @@ function oauthManagementRequest(context, connectorId) {
20
20
  return yield* refuse("same-origin request required", 403);
21
21
  }
22
22
  const authz = yield* authorizedPerson(context, "OAuth management");
23
- const registry = yield* visibleRegistry(context, authz);
23
+ const registry = yield* managementRegistry(context, authz);
24
24
  const connector = registry.getConnector(connectorId);
25
25
  const disconnectAuth = connector?.disconnectAuth?.bind(connector);
26
- const startAuth = connector?.startAuth?.bind(connector);
27
- if (!connector || !disconnectAuth || !startAuth) {
26
+ // Each verb needs only its own hooks (#837): connecting needs the whole
27
+ // flow, through the callback's state check; disconnecting needs only
28
+ // disconnectAuth, so a grant provisioned out of band can still be dropped.
29
+ const connects = Boolean(connector?.startAuth && connector.finishAuth && connector.verifyState);
30
+ if (!connector || (!connects && !disconnectAuth)) {
28
31
  return yield* refuse("unknown OAuth connector", 404);
29
32
  }
30
- if (!mayManageConnector(authz, connector)) {
33
+ if (!mayManageConnector(opts, authz, connector)) {
31
34
  return yield* refuse("credential management is not permitted", 403);
32
35
  }
33
36
  if (connector.authScope === "personal" && !authz.principalKey) {
@@ -41,6 +44,9 @@ function oauthManagementRequest(context, connectorId) {
41
44
  if (mode === null) {
42
45
  return yield* refuse('mode must be "continue" or "restart"', 400);
43
46
  }
47
+ if (disconnecting ? !disconnectAuth : !connects) {
48
+ return yield* refuse("unknown OAuth connector", 404);
49
+ }
44
50
  if (!disconnecting) {
45
51
  const unavailable = oauthConnectUnavailable(opts);
46
52
  if (unavailable)
@@ -7,7 +7,7 @@ import { failureRecord, logFailure } from "../operator-record.js";
7
7
  import { oauthFlowKeys } from "../storage/keys.js";
8
8
  import { escapeHtml, renderPage, resolveBranding, STATUS_ICONS } from "../branding.js";
9
9
  import { oauthCallbackOutcome, providerErrorReason } from "../oauth-callback-outcome.js";
10
- import { authorizeUiIdentity, withSessionCookies, mayManageConnector, validateAuthPermissions, loggableValue, } from "./shared.js";
10
+ import { authorizeUiIdentity, withSessionCookies, mayManageConnector, loggableValue, } from "./shared.js";
11
11
  const TONE_MARKS = {
12
12
  ok: { className: " ok", icon: STATUS_ICONS.ok, label: "Connected" },
13
13
  declined: { className: "", icon: STATUS_ICONS.declined, label: "Not connected" },
@@ -167,7 +167,7 @@ async function finishOAuthCallback(context) {
167
167
  }
168
168
  try {
169
169
  const expectedPrincipalKey = callbackTarget?.principalKey;
170
- const browserIdentity = await authorizeUiIdentity(context.request, baseUrl, opts.config.auth, "OAuth callback", context.runtimeContext, opts.config.identity);
170
+ const browserIdentity = await authorizeUiIdentity(context.request, baseUrl, opts.config.auth, "OAuth callback", context.runtimeContext);
171
171
  if (!browserIdentity.ok) {
172
172
  if (browserIdentity.response.status === 401 && authorizationCredential(context.request).kind !== "absent")
173
173
  return browserIdentity.response;
@@ -179,13 +179,7 @@ async function finishOAuthCallback(context) {
179
179
  }
180
180
  if (!expectedPrincipalKey || browserIdentity.principalKey !== expectedPrincipalKey)
181
181
  return refused();
182
- try {
183
- validateAuthPermissions(browserIdentity, opts.registry);
184
- }
185
- catch {
186
- return refused();
187
- }
188
- if (!mayManageConnector(browserIdentity, connector))
182
+ if (!mayManageConnector(opts, browserIdentity, connector))
189
183
  return refused();
190
184
  // CSRF / login-fixation guard: this route is intentionally public, so verify
191
185
  // the `state` matches the flow connecta started BEFORE exchanging the code.
@@ -22,7 +22,8 @@
22
22
  // Imported by the /ui and /activity graphs only; never from the root entry.
23
23
  import { Effect } from "effect";
24
24
  import { runEdge } from "../runtime/run.js";
25
- import { authorize, authorizeUiIdentity, msg, privateJson, validateAuthPermissions, } from "./shared.js";
25
+ import { isMaintainer } from "../membership.js";
26
+ import { admitsMaintainer, authorize, authorizeUiIdentity, memberConnectors, privateJson, } from "./shared.js";
26
27
  /** A response the route has already decided on. */
27
28
  export class Answer {
28
29
  response;
@@ -40,42 +41,42 @@ function admitted(result) {
40
41
  }
41
42
  /** Any configured caller. The gate's refusal, challenge headers and all, is the answer. */
42
43
  export function authorized({ request, baseUrl, opts, runtimeContext }, partitionIdentity = true) {
43
- return Effect.suspend(() => admitted(authorize(request, baseUrl, opts.config.auth, runtimeContext, opts.config.identity, partitionIdentity)));
44
+ return Effect.suspend(() => admitted(authorize(request, baseUrl, opts.config.auth, runtimeContext, partitionIdentity)));
44
45
  }
45
46
  /** A signed-in person, for a route that changes how a connector authenticates. */
46
47
  export function authorizedPerson({ request, baseUrl, opts, runtimeContext }, purpose) {
47
- return Effect.suspend(() => admitted(authorizeUiIdentity(request, baseUrl, opts.config.auth, purpose, runtimeContext, opts.config.identity)));
48
+ return Effect.suspend(() => admitted(authorizeUiIdentity(request, baseUrl, opts.config.auth, purpose, runtimeContext)));
48
49
  }
49
- /** The partition and tool grants an identity carries into a registry view. */
50
- export function scopeFor(authz, connectorIds = authz.connectorIds) {
50
+ /** The partition an identity carries into a registry view of the given connectors. */
51
+ export function scopeFor(authz, connectorIds = "all") {
51
52
  return {
52
53
  connectorIds,
53
- ...(authz.toolAccess ? { toolAccess: authz.toolAccess } : {}),
54
- ...(authz.guardedToolAccess ? { guardedToolAccess: authz.guardedToolAccess } : {}),
55
54
  ...(authz.subjectKey ? { subjectKey: authz.subjectKey } : {}),
56
55
  ...(authz.principalKey ? { principalKey: authz.principalKey } : {}),
57
56
  };
58
57
  }
59
58
  /**
60
- * The registry as this identity may see it. A configured permission naming a
61
- * connector that does not exist refuses the whole view, management rights
62
- * included, rather than quietly granting what remains.
59
+ * The whole registry, for a maintainer only (#837). Everyone else gets the
60
+ * private 404 plain `/mcp` gives them.
63
61
  */
64
62
  export function visibleRegistry({ opts }, authz) {
65
63
  // Operator catalogs describe the default endpoint's view, never a pool view.
66
- // Authentication alone cannot admit a caller that deployment code refuses.
67
- const admission = opts.config.grant
68
- ? Effect.tryPromise(async () => (await opts.config.grant(authz.identity)) === true).pipe(Effect.orElseSucceed(() => false))
69
- : Effect.succeed(true);
70
- return Effect.flatMap(admission, (allowed) => allowed
71
- ? Effect.try({
72
- try: () => {
73
- validateAuthPermissions(authz, opts.registry);
74
- return opts.registry.scoped(scopeFor(authz));
75
- },
76
- catch: (error) => new Answer(privateJson({ error: msg(error) }, { status: 403 })),
77
- })
78
- : refuse("not found", 404));
64
+ if (!admitsMaintainer(opts, authz))
65
+ return refuse("not found", 404);
66
+ return Effect.succeed(opts.registry.scoped(scopeFor(authz)));
67
+ }
68
+ /**
69
+ * The connectors a connector-auth route may resolve (#837): every connector
70
+ * for a maintainer, the connectors of their pools for a pool member, and the
71
+ * private 404 for anyone else. mayManageConnector still decides each action.
72
+ */
73
+ export function managementRegistry({ opts }, authz) {
74
+ if (isMaintainer(opts.maintainers, authz.identity))
75
+ return Effect.succeed(opts.registry.scoped(scopeFor(authz)));
76
+ const ids = memberConnectors(opts, authz.identity);
77
+ if (ids !== "all" && ids.size === 0)
78
+ return refuse("not found", 404);
79
+ return Effect.succeed(opts.registry.scoped(scopeFor(authz, ids === "all" ? "all" : [...ids])));
79
80
  }
80
81
  /**
81
82
  * Run one route program to its Response.