@zackbart/connecta 0.31.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 (169) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/README.md +30 -20
  3. package/bin/connecta.mjs +25 -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/auth-elicitation.js +1 -1
  10. package/dist/call-admission-context.js +15 -0
  11. package/dist/call-admission.js +30 -43
  12. package/dist/call-recovery.js +6 -2
  13. package/dist/catalog-service.js +7 -2
  14. package/dist/config-value-sources.js +14 -9
  15. package/dist/config.d.ts +58 -78
  16. package/dist/config.js +143 -47
  17. package/dist/connector-access.d.ts +4 -33
  18. package/dist/connector-access.js +15 -99
  19. package/dist/connector-caller.d.ts +7 -1
  20. package/dist/connector-caller.js +3 -2
  21. package/dist/connectors/api-connector.d.ts +27 -4
  22. package/dist/connectors/api-connector.js +2 -0
  23. package/dist/connectors/guarded-fetch.d.ts +9 -0
  24. package/dist/connectors/guarded-fetch.js +7 -0
  25. package/dist/connectors/remote-mcp.js +2 -0
  26. package/dist/describe-config.d.ts +32 -7
  27. package/dist/describe-config.js +9 -18
  28. package/dist/errors.d.ts +1 -1
  29. package/dist/errors.js +1 -0
  30. package/dist/execute.js +16 -14
  31. package/dist/index.d.ts +4 -3
  32. package/dist/index.js +30 -10
  33. package/dist/invocation.js +12 -7
  34. package/dist/membership.d.ts +51 -0
  35. package/dist/membership.js +121 -0
  36. package/dist/meta-tools.js +17 -19
  37. package/dist/module-contracts.d.ts +23 -6
  38. package/dist/operator-record.d.ts +2 -2
  39. package/dist/operator-record.js +2 -2
  40. package/dist/operator-ui/agents.d.ts +74 -0
  41. package/dist/operator-ui/agents.js +104 -0
  42. package/dist/operator-ui/contract.d.ts +151 -29
  43. package/dist/operator-ui/endpoints.js +192 -0
  44. package/dist/operator-ui/fix-prompts.js +4 -4
  45. package/dist/operator-ui/generated.js +5 -4
  46. package/dist/operator-ui/member-page.js +112 -0
  47. package/dist/operator-ui/model.d.ts +15 -7
  48. package/dist/operator-ui/view.d.ts +84 -19
  49. package/dist/operator-ui/view.js +83 -21
  50. package/dist/page-styles.js +1 -1
  51. package/dist/program-writes.js +2 -2
  52. package/dist/provider.d.ts +63 -11
  53. package/dist/provider.js +46 -7
  54. package/dist/providers/_shared/google/workspace.d.ts +53 -16
  55. package/dist/providers/_shared/google/workspace.js +31 -9
  56. package/dist/providers/_shared/rest/tools.js +2 -2
  57. package/dist/providers/_shared/rest/value-safety.d.ts +50 -0
  58. package/dist/providers/_shared/rest/value-safety.js +699 -7
  59. package/dist/providers/breeze/skill.generated.js +2 -2
  60. package/dist/providers/ccb/index.js +1 -1
  61. package/dist/providers/cloudflare/index.js +3 -3
  62. package/dist/providers/cloudflare/rest.js +25 -3
  63. package/dist/providers/cloudflare/skill.generated.js +2 -2
  64. package/dist/providers/cloudflare/value-safety.js +32 -0
  65. package/dist/providers/docs/index.d.ts +1 -1
  66. package/dist/providers/docs/index.js +5 -5
  67. package/dist/providers/docs/skill.generated.js +2 -2
  68. package/dist/providers/drive/index.d.ts +1 -1
  69. package/dist/providers/drive/index.js +4 -4
  70. package/dist/providers/drive/skill.generated.js +2 -2
  71. package/dist/providers/forms/index.d.ts +1 -1
  72. package/dist/providers/forms/index.js +5 -5
  73. package/dist/providers/forms/skill.generated.js +2 -2
  74. package/dist/providers/github/skill.generated.js +2 -2
  75. package/dist/providers/gmail/index.d.ts +1 -1
  76. package/dist/providers/gmail/index.js +279 -26
  77. package/dist/providers/gmail/skill.generated.js +2 -2
  78. package/dist/providers/linear/index.js +1 -1
  79. package/dist/providers/mixpanel/index.d.ts +27 -12
  80. package/dist/providers/mixpanel/index.js +58 -6
  81. package/dist/providers/mixpanel/operations.js +20 -0
  82. package/dist/providers/mixpanel/rest.js +603 -0
  83. package/dist/providers/mixpanel/skill.generated.js +3 -2
  84. package/dist/providers/mixpanel/value-safety.js +16 -0
  85. package/dist/providers/notion/index.d.ts +7 -0
  86. package/dist/providers/notion/index.js +116 -11
  87. package/dist/providers/notion/rest.js +183 -24
  88. package/dist/providers/notion/skill.generated.js +4 -4
  89. package/dist/providers/notion/value-safety.js +30 -5
  90. package/dist/providers/overflow/index.js +1 -1
  91. package/dist/providers/planning-center/index.d.ts +27 -0
  92. package/dist/providers/planning-center/index.js +287 -25
  93. package/dist/providers/planning-center/skill.generated.js +24 -4
  94. package/dist/providers/sheets/index.d.ts +4 -1
  95. package/dist/providers/sheets/index.js +9 -6
  96. package/dist/providers/sheets/skill.generated.js +2 -2
  97. package/dist/providers/slides/index.d.ts +1 -1
  98. package/dist/providers/slides/index.js +6 -6
  99. package/dist/providers/slides/skill.generated.js +2 -2
  100. package/dist/providers/stripe/rest.js +9 -2
  101. package/dist/providers/stripe/skill.generated.js +2 -2
  102. package/dist/providers/stripe/value-safety.js +22 -0
  103. package/dist/providers/vercel/index.js +2 -2
  104. package/dist/providers/vercel/rest.js +20 -2
  105. package/dist/providers/vercel/skill.generated.js +2 -2
  106. package/dist/providers/vercel/value-safety.js +22 -0
  107. package/dist/providers/wispr-flow/index.d.ts +14 -0
  108. package/dist/providers/wispr-flow/index.js +117 -0
  109. package/dist/providers/wispr-flow/skill.generated.js +8 -0
  110. package/dist/registry.d.ts +2 -3
  111. package/dist/registry.js +8 -9
  112. package/dist/routes/access-tokens.js +82 -75
  113. package/dist/routes/activity-disclosure.js +17 -18
  114. package/dist/routes/activity.js +34 -54
  115. package/dist/routes/actor-labels.js +33 -4
  116. package/dist/routes/agent-path.js +43 -0
  117. package/dist/routes/connect.js +16 -12
  118. package/dist/routes/credentials.js +16 -40
  119. package/dist/routes/mcp.js +155 -85
  120. package/dist/routes/oauth-management.js +11 -5
  121. package/dist/routes/oauth.js +3 -9
  122. package/dist/routes/operator.js +25 -17
  123. package/dist/routes/shared.d.ts +60 -20
  124. package/dist/routes/shared.js +187 -96
  125. package/dist/routes/ui-config.js +437 -97
  126. package/dist/routes/ui-data.js +2 -2
  127. package/dist/routes/ui.js +9 -16
  128. package/dist/runtime/call-admission.js +9 -0
  129. package/dist/server.js +8 -0
  130. package/dist/skills.js +5 -4
  131. package/dist/storage/keys.js +9 -1
  132. package/dist/tool-safety.js +13 -5
  133. package/dist/types.d.ts +12 -1
  134. package/dist/ui.d.ts +2 -2
  135. package/dist/ui.js +17 -15
  136. package/dist/usage-guide.js +3 -3
  137. package/dist/version.d.ts +1 -1
  138. package/dist/version.js +1 -1
  139. package/dist/workspace-address.js +9 -0
  140. package/dist/workspace-subject.js +23 -0
  141. package/documentation/README.md +46 -22
  142. package/documentation/architecture.md +378 -58
  143. package/documentation/auth.md +806 -215
  144. package/documentation/code-mode.md +93 -81
  145. package/documentation/deploying.md +128 -26
  146. package/documentation/integrating.md +145 -22
  147. package/documentation/meta-tools.md +58 -46
  148. package/documentation/migration-0.33.md +344 -0
  149. package/documentation/operating.md +14 -9
  150. package/documentation/operator-tests.md +117 -31
  151. package/documentation/operator-ui.md +326 -55
  152. package/documentation/provider-auth-migration.md +42 -27
  153. package/documentation/provider-migration-0.29.md +3 -3
  154. package/examples/worker/AGENTS.md +35 -8
  155. package/examples/worker/README.md +92 -53
  156. package/examples/worker/src/connecta.config.ts +41 -20
  157. package/examples/worker/wrangler.jsonc +3 -0
  158. package/package.json +7 -2
  159. package/templates/node/.env.example +3 -0
  160. package/templates/node/AGENTS.md +33 -10
  161. package/templates/node/Dockerfile +2 -2
  162. package/templates/node/README.md +71 -44
  163. package/templates/node/docker-compose.yml +3 -2
  164. package/templates/node/package.json +4 -3
  165. package/templates/node/src/adopt-token.ts +23 -0
  166. package/templates/node/src/connecta.config.ts +33 -12
  167. package/templates/node/src/rotate-token.ts +17 -0
  168. package/dist/tool-safety.d.ts +0 -9
  169. package/templates/node/src/provision-token.ts +0 -17
@@ -2,45 +2,10 @@ import { failureRecord, logFailure } from "../operator-record.js";
2
2
  import { Effect } from "effect";
3
3
  import { claimConnectorScopeCleanup, closeConnectorScope } from "../connector-scope.js";
4
4
  import { credentialTestRule, describeCredentialTestMismatch, storedCredentialShape } from "../credential-rules.js";
5
- import { authorizedPerson, refuse, serveOperator, visibleRegistry } from "./operator.js";
6
- import { mayManageConnector, isSameOrigin, msg, privateJson } from "./shared.js";
5
+ import { authorizedPerson, refuse, serveOperator, managementRegistry } from "./operator.js";
6
+ import { boundedText, mayManageConnector, isSameOrigin, msg, privateJson } from "./shared.js";
7
7
  /** The longest body, in characters, a credential write accepts. */
8
8
  const MAX_BODY_CHARS = 20_000;
9
- /**
10
- * The most UTF-8 a body within MAX_BODY_CHARS can take. No UTF-16 unit costs
11
- * more than three bytes, and a malformed sequence decodes to one U+FFFD per
12
- * at most three bytes, so a body past this many bytes is past the character
13
- * limit too — and reading stops there, instead of buffering whatever a client
14
- * cares to send before measuring it.
15
- */
16
- const MAX_BODY_BYTES = MAX_BODY_CHARS * 3;
17
- /** The body as text, or undefined once it is longer than MAX_BODY_CHARS. */
18
- async function boundedText(request) {
19
- if (!request.body)
20
- return "";
21
- const reader = request.body.getReader();
22
- const chunks = [];
23
- let size = 0;
24
- for (;;) {
25
- const { done, value } = await reader.read();
26
- if (done)
27
- break;
28
- size += value.byteLength;
29
- if (size > MAX_BODY_BYTES) {
30
- reader.cancel().catch(() => { });
31
- return undefined;
32
- }
33
- chunks.push(value);
34
- }
35
- const bytes = new Uint8Array(size);
36
- let offset = 0;
37
- for (const chunk of chunks) {
38
- bytes.set(chunk, offset);
39
- offset += chunk.byteLength;
40
- }
41
- const text = new TextDecoder().decode(bytes);
42
- return text.length > MAX_BODY_CHARS ? undefined : text;
43
- }
44
9
  /** Field-by-field checks, in words the Credentials page shows as written. */
45
10
  function credentialInput(body, config) {
46
11
  if (!config.fields?.length) {
@@ -73,7 +38,7 @@ function readCredentialInput(request, config) {
73
38
  if (!request.headers.get("content-type")?.toLowerCase().startsWith("application/json")) {
74
39
  return yield* refuse("Content-Type must be application/json", 415);
75
40
  }
76
- const raw = yield* Effect.promise(() => boundedText(request));
41
+ const raw = yield* Effect.promise(() => boundedText(request, MAX_BODY_CHARS));
77
42
  if (raw === undefined)
78
43
  return yield* refuse("request body is too large", 413);
79
44
  let body;
@@ -99,12 +64,12 @@ function credentialRequest(context, connectorId, action) {
99
64
  return yield* refuse("same-origin request required", 403);
100
65
  }
101
66
  const authz = yield* authorizedPerson(context, "credential management");
102
- const registry = yield* visibleRegistry(context, authz);
67
+ const registry = yield* managementRegistry(context, authz);
103
68
  const connector = registry.getConnector(connectorId);
104
69
  const credential = connector?.credential;
105
70
  if (!connector || !credential)
106
71
  return yield* refuse("unknown credential slot", 404);
107
- if (!mayManageConnector(authz, connector)) {
72
+ if (!mayManageConnector(opts, authz, connector)) {
108
73
  return yield* refuse("credential management is not permitted", 403);
109
74
  }
110
75
  const personal = connector.authScope === "personal";
@@ -191,6 +156,17 @@ function credentialRequest(context, connectorId, action) {
191
156
  }).pipe(Effect.catch((error) => refuse(msg(error), 400)));
192
157
  }
193
158
  if (request.method === "DELETE") {
159
+ // Clearing nothing is refused, as testing nothing is, so the route
160
+ // agrees with the operator contract, which offers `clear` only once a
161
+ // value is stored in the caller's partition (#837). A value that cannot
162
+ // be read may still be cleared: removing it is how one recovers.
163
+ const stored = yield* Effect.tryPromise({
164
+ try: () => vault.metadata(connectorId, owner),
165
+ catch: (error) => error,
166
+ }).pipe(Effect.orElseSucceed(() => "unreadable"));
167
+ if (stored === null) {
168
+ return privateJson({ error: "no credential is stored to clear", problem: "credential_required" }, { status: 409 });
169
+ }
194
170
  yield* Effect.promise(() => vault.delete(connectorId, owner));
195
171
  yield* Effect.promise(() => registry.invalidateStored(connectorId));
196
172
  return new Response(null, {
@@ -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);
@@ -294,7 +313,7 @@ function toolkitRetired(logger, sentSecrets) {
294
313
  },
295
314
  });
296
315
  }
297
- function serveMcp(request, requestSignal, requestScope, opts, baseUrl, actor, registry, canManageAuth, principalKey, runtimeContext, trust = "read-only", pool, principalActor) {
316
+ function serveMcp(request, requestSignal, requestScope, opts, baseUrl, actor, registry, canManageAuth, principalKey, runtimeContext, readOnly = false, pool, principalActor) {
298
317
  const sentSecrets = sentSecretsForRequest(requestScope);
299
318
  // Every McpServer the request builds is fresh and closes with its scope.
300
319
  // The modern handler tears its own down after the exchange; the legacy
@@ -361,7 +380,7 @@ function serveMcp(request, requestSignal, requestScope, opts, baseUrl, actor, re
361
380
  client,
362
381
  baseUrl,
363
382
  requestScope,
364
- trust,
383
+ readOnly,
365
384
  canManageAuth,
366
385
  oauthConnectUrl: (id, force) => oauthConnectUrl(opts, baseUrl, id, principalKey, force),
367
386
  oauthConnectUnavailable: oauthConnectUnavailable(opts),
@@ -385,7 +404,7 @@ function serveMcp(request, requestSignal, requestScope, opts, baseUrl, actor, re
385
404
  requestSignal,
386
405
  ...(runtimeContext?.waitUntil ? { defer: runtimeContext.waitUntil.bind(runtimeContext) } : {}),
387
406
  ...executeLimits(opts.config),
388
- trust,
407
+ readOnly,
389
408
  });
390
409
  servers.push(server);
391
410
  return server;
@@ -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,23 +549,37 @@ export function createMcpRoute(opts) {
516
549
  suppressedAdmissionWarnings = 0;
517
550
  };
518
551
  function routeMcp(context) {
519
- const { path, request, baseUrl, runtimeContext } = context;
520
- if (path !== "/mcp" && !path.startsWith("/mcp/"))
552
+ const { path, request, baseUrl } = context;
553
+ // Protected-resource metadata stays public, including when a grant is
554
+ // configured, so a fresh OAuth client can discover how to authenticate.
555
+ const endpoint = mcpEndpoint(path);
556
+ if (!endpoint)
521
557
  return Effect.succeed(null);
522
558
  // Admission, refusal bodies, SDK serialization and connector work share
523
559
  // one set. Connector contexts still receive an opaque scope, never the
524
560
  // inbound Request or its headers.
525
561
  const requestScope = {};
526
562
  const sentSecrets = sentSecretsForRequest(requestScope, sentSecretsForRequest(request));
527
- 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;
528
570
  const origin = request.headers.get("Origin");
529
571
  const allowed = origin === null || allowsOrigin(origin);
530
572
  const cors = (response) => withMcpCors(response, request, configuredOrigins === "*" ? "*" : allowed ? origin : null);
573
+ const notFound = () => cors(mcpRefusal(sentSecrets, 404, -33004, "MCP endpoint not found."));
531
574
  // DNS-rebinding refusals cost neither a permit nor an auth lookup. This
532
575
  // local header check also guards OPTIONS before any provider metadata.
533
576
  const refusal = rejectOrigin(request);
534
577
  if (refusal)
535
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());
536
583
  if (request.method === "OPTIONS") {
537
584
  return Effect.succeed(cors(new Response(null, { status: 204 })));
538
585
  }
@@ -553,38 +600,30 @@ export function createMcpRoute(opts) {
553
600
  if (deadlineTimer !== undefined)
554
601
  clearTimeout(deadlineTimer);
555
602
  }));
556
- // Only a body-confirmed modern listen may skip admission. Trusting the
557
- // 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.
558
610
  const startedAt = Date.now();
559
611
  const maxDurationMs = opts.requestAdmission.maxDurationMs;
560
- let listen = false;
561
- if (request.method === "POST" && request.headers.get("Mcp-Method") === "subscriptions/listen") {
562
- const classify = Effect.promise(() => isModernListen(request, localAbort.signal));
563
- const prepared = maxDurationMs === undefined
564
- ? Option.some(yield* classify)
565
- : yield* classify.pipe(Effect.timeoutOption(Duration.millis(maxDurationMs)));
566
- if (Option.isNone(prepared)) {
567
- localAbort.abort(new Error("MCP request lifetime exceeded."));
568
- return cors(mcpRefusal(sentSecrets, 504, -33003, "MCP request lifetime exceeded."));
569
- }
570
- listen = prepared.value;
571
- }
572
- 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
573
623
  ? Result.succeed(undefined)
574
624
  : yield* Effect.result(admitted(opts.requestAdmission, request.signal));
575
- if (Result.isFailure(admission)) {
576
- const error = admission.failure;
577
- if (!(error instanceof ExecutorAdmissionError)) {
578
- return yield* Effect.fail(error);
579
- }
580
- if (error.code === "executor_cancelled") {
581
- return yield* Effect.fail(request.signal.reason ?? error);
582
- }
583
- if (error.code === "executor_overloaded") {
584
- warnAdmissionRejected(error);
585
- }
586
- return cors(requestAdmissionFailure(sentSecrets, error));
587
- }
625
+ if (Result.isFailure(admission))
626
+ return yield* refuseAdmission(admission.failure);
588
627
  const remainingMs = admission.success
589
628
  ? admission.success.remainingMs()
590
629
  : maxDurationMs === undefined
@@ -611,8 +650,34 @@ export function createMcpRoute(opts) {
611
650
  // Effect `authorize` would put Effect in the /activity bundle, which
612
651
  // shares it, to save only the lookups an abandoned authorization
613
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
+ };
614
679
  const handled = Effect.gen(function* () {
615
- const authz = yield* Effect.promise(() => authorize(localRequest, baseUrl, opts.config.auth, runtimeContext, opts.config.identity));
680
+ const authz = yield* Effect.promise(admit);
616
681
  if (!authz.ok) {
617
682
  const response = authz.response;
618
683
  // Preserve the auth adapter's challenge and status. The MCP host must
@@ -639,67 +704,69 @@ export function createMcpRoute(opts) {
639
704
  },
640
705
  })), { status: response.status, headers }));
641
706
  }
642
- // A pool endpoint narrows the identity's own view and nothing else. An
643
- // undeclared name, a grant that refuses, and a grant that throws are
644
- // 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;
645
709
  // the operator log is where the reason lives.
646
- let access = authz;
647
- let trust = opts.config.trust;
648
- if (poolName !== undefined) {
649
- const pool = opts.pools?.get(poolName);
650
- const denialReason = !pool
651
- ? "pool_not_declared"
652
- : yield* Effect.promise(async () => {
653
- try {
654
- return (await pool.grant(authz.identity)) === true ? undefined : "pool_grant_denied";
655
- }
656
- catch {
657
- return "pool_grant_threw";
658
- }
659
- });
660
- if (!pool || denialReason !== undefined) {
661
- logFailure(opts.config.logger, "MCP pool request denied", failureRecord({ reason: denialReason ?? "pool_not_declared" }));
662
- return cors(mcpRefusal(sentSecrets, 404, -33004, "MCP endpoint not found."));
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();
724
+ }
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);
663
739
  }
664
- access = intersectAccess(authz, pool.access);
665
- trust = pool.trust;
666
740
  }
667
741
  let scopedRegistry;
668
742
  try {
669
- validateAuthPermissions(authz, opts.registry);
670
743
  scopedRegistry = opts.registry.scoped({
671
744
  connectorIds: access.connectorIds,
672
745
  ...(access.toolAccess ? { toolAccess: access.toolAccess } : {}),
673
- ...(access.guardedToolAccess ? { guardedToolAccess: access.guardedToolAccess } : {}),
674
746
  ...(authz.subjectKey ? { subjectKey: authz.subjectKey } : {}),
675
747
  ...(authz.principalKey ? { principalKey: authz.principalKey } : {}),
676
748
  endpoint: new URL(request.url).pathname,
677
749
  origin: request.headers.get("Origin"),
678
750
  currentResultAccess: async (address, classification, signal, access) => {
679
751
  signal?.throwIfAborted();
680
- const current = await authorize(localRequest, baseUrl, opts.config.auth, runtimeContext, opts.config.identity);
752
+ const current = await admit();
681
753
  if (!current.ok) {
682
754
  await current.response.body?.cancel().catch(() => { });
683
755
  return false;
684
756
  }
685
757
  if (current.subjectKey !== authz.subjectKey || current.principalKey !== authz.principalKey)
686
758
  return false;
687
- validateAuthPermissions(current, opts.registry);
688
- let currentAccess = current;
689
- let currentTrust = opts.config.trust;
690
- if (poolName !== undefined) {
691
- const pool = opts.pools.get(poolName);
692
- if (!pool || (await pool.grant(current.identity)) !== true)
693
- return false;
694
- currentAccess = intersectAccess(current, pool.access);
695
- currentTrust = pool.trust;
696
- }
759
+ // Paging re-admits the caller at this endpoint, as a new request would.
760
+ const currentScope = scopeFor(current);
761
+ if (!currentScope)
762
+ return false;
763
+ const { access: currentAccess, readOnly: currentReadOnly } = currentScope;
697
764
  const view = opts.registry.scoped({
698
765
  ...currentAccess,
699
766
  ...(current.subjectKey ? { subjectKey: current.subjectKey } : {}),
700
767
  ...(current.principalKey ? { principalKey: current.principalKey } : {}),
701
768
  });
702
- if (classification === "write" && currentTrust !== "trusted")
769
+ if (classification === "write" && currentReadOnly)
703
770
  return false;
704
771
  if (address === undefined)
705
772
  return true;
@@ -716,7 +783,7 @@ export function createMcpRoute(opts) {
716
783
  return Boolean(tool &&
717
784
  (access === "catalog" ||
718
785
  (classification === "read" && tool.classification === "read") ||
719
- currentTrust === "trusted"));
786
+ !currentReadOnly));
720
787
  }
721
788
  finally {
722
789
  await closeConnectorScope(resolved.connector, view.contextFor(resolved.connector.id, baseUrl, requestScope), runtimeContext?.waitUntil?.bind(runtimeContext));
@@ -727,7 +794,10 @@ export function createMcpRoute(opts) {
727
794
  // `authorize` admits an open deployment's every request as the
728
795
  // anonymous actor; only a provider's `ok` is an authentication.
729
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.
730
799
  ...(poolName !== undefined ? { pool: poolName } : {}),
800
+ ...(poolOwner !== undefined ? { poolOwner } : {}),
731
801
  },
732
802
  });
733
803
  }
@@ -739,8 +809,8 @@ export function createMcpRoute(opts) {
739
809
  }
740
810
  return cors(yield* serveMcp(localRequest, localAbort.signal, requestScope, opts, baseUrl, authz.actor, scopedRegistry, (id) => {
741
811
  const connector = scopedRegistry.getConnector(id);
742
- return Boolean(connector && mayManageConnector(authz, connector));
743
- }, authz.principalKey, runtimeContext, trust, poolName, authz.identity.principal
812
+ return Boolean(connector && mayManageConnector(opts, authz, connector));
813
+ }, authz.principalKey, runtimeContext, readOnly, poolName, authz.identity.principal
744
814
  ? {
745
815
  kind: authz.actor.kind,
746
816
  id: authz.identity.principal.id,
@@ -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,35 +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
- return Effect.try({
66
- try: () => {
67
- validateAuthPermissions(authz, opts.registry);
68
- return opts.registry.scoped(scopeFor(authz));
69
- },
70
- catch: (error) => new Answer(privateJson({ error: msg(error) }, { status: 403 })),
71
- });
63
+ // Operator catalogs describe the default endpoint's view, never a pool view.
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])));
72
80
  }
73
81
  /**
74
82
  * Run one route program to its Response.