@zackbart/connecta 0.32.0 → 0.34.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 (158) hide show
  1. package/CHANGELOG.md +98 -0
  2. package/README.md +95 -116
  3. package/SECURITY.md +1 -1
  4. package/bin/connecta.mjs +24 -6
  5. package/bin/version.mjs +1 -1
  6. package/dist/access-tokens.d.ts +167 -23
  7. package/dist/access-tokens.js +676 -140
  8. package/dist/activity.d.ts +3 -0
  9. package/dist/agent-identity.js +47 -0
  10. package/dist/auth/cloudflare-access.js +13 -5
  11. package/dist/branding.d.ts +7 -0
  12. package/dist/branding.js +76 -9
  13. package/dist/call-admission-context.js +15 -0
  14. package/dist/call-admission.js +30 -43
  15. package/dist/call-recovery.js +6 -2
  16. package/dist/catalog-service.js +15 -1
  17. package/dist/config-value-sources.js +16 -9
  18. package/dist/config.d.ts +48 -75
  19. package/dist/config.js +144 -36
  20. package/dist/connector-access.d.ts +4 -34
  21. package/dist/connector-access.js +15 -99
  22. package/dist/connector-caller.d.ts +7 -1
  23. package/dist/connector-caller.js +4 -2
  24. package/dist/connectors/api-connector.d.ts +27 -4
  25. package/dist/connectors/api-connector.js +2 -0
  26. package/dist/connectors/remote-mcp.js +2 -0
  27. package/dist/describe-config.d.ts +31 -7
  28. package/dist/describe-config.js +8 -17
  29. package/dist/execute.js +1 -11
  30. package/dist/fix-prompt.js +2 -2
  31. package/dist/index.d.ts +4 -3
  32. package/dist/index.js +30 -10
  33. package/dist/invocation.js +5 -3
  34. package/dist/membership.d.ts +53 -0
  35. package/dist/membership.js +121 -0
  36. package/dist/module-contracts.d.ts +23 -6
  37. package/dist/oauth-callback-outcome.js +2 -2
  38. package/dist/operator-record.d.ts +2 -2
  39. package/dist/operator-record.js +2 -4
  40. package/dist/operator-ui/agents.d.ts +74 -0
  41. package/dist/operator-ui/agents.js +104 -0
  42. package/dist/operator-ui/config-summary.js +562 -0
  43. package/dist/operator-ui/connection-map.js +375 -0
  44. package/dist/operator-ui/contract.d.ts +149 -28
  45. package/dist/operator-ui/endpoints.js +221 -0
  46. package/dist/operator-ui/fix-prompts.js +5 -5
  47. package/dist/operator-ui/generated.js +5 -4
  48. package/dist/operator-ui/member-page.js +97 -0
  49. package/dist/operator-ui/model.d.ts +14 -6
  50. package/dist/operator-ui/overview.js +134 -0
  51. package/dist/operator-ui/view.d.ts +107 -37
  52. package/dist/operator-ui/view.js +163 -86
  53. package/dist/page-styles.js +5 -3
  54. package/dist/provider.d.ts +61 -8
  55. package/dist/provider.js +46 -7
  56. package/dist/providers/_shared/google/workspace.d.ts +56 -17
  57. package/dist/providers/_shared/google/workspace.js +34 -8
  58. package/dist/providers/_shared/rest/tools.js +2 -2
  59. package/dist/providers/_shared/rest/value-safety.d.ts +50 -0
  60. package/dist/providers/_shared/rest/value-safety.js +699 -7
  61. package/dist/providers/ccb/index.js +2 -2
  62. package/dist/providers/cloudflare/index.js +3 -3
  63. package/dist/providers/cloudflare/rest.js +25 -3
  64. package/dist/providers/cloudflare/skill.generated.js +1 -1
  65. package/dist/providers/cloudflare/value-safety.js +32 -0
  66. package/dist/providers/docs/index.js +4 -4
  67. package/dist/providers/docs/skill.generated.js +2 -2
  68. package/dist/providers/drive/index.js +4 -4
  69. package/dist/providers/drive/skill.generated.js +2 -2
  70. package/dist/providers/forms/index.js +4 -4
  71. package/dist/providers/forms/skill.generated.js +2 -2
  72. package/dist/providers/gmail/index.js +11 -9
  73. package/dist/providers/gmail/skill.generated.js +2 -2
  74. package/dist/providers/mixpanel/index.js +1 -1
  75. package/dist/providers/mixpanel/skill.generated.js +3 -3
  76. package/dist/providers/notion/index.d.ts +7 -0
  77. package/dist/providers/notion/index.js +100 -11
  78. package/dist/providers/notion/rest.js +45 -5
  79. package/dist/providers/notion/skill.generated.js +2 -2
  80. package/dist/providers/notion/value-safety.js +30 -5
  81. package/dist/providers/overflow/index.js +2 -2
  82. package/dist/providers/overflow/skill.generated.js +2 -2
  83. package/dist/providers/planning-center/index.js +2 -2
  84. package/dist/providers/planning-center/skill.generated.js +4 -4
  85. package/dist/providers/revenuecat/skill.generated.js +2 -2
  86. package/dist/providers/sheets/index.d.ts +3 -0
  87. package/dist/providers/sheets/index.js +7 -4
  88. package/dist/providers/sheets/skill.generated.js +2 -2
  89. package/dist/providers/slides/index.js +4 -4
  90. package/dist/providers/slides/skill.generated.js +2 -2
  91. package/dist/providers/stripe/rest.js +9 -2
  92. package/dist/providers/stripe/skill.generated.js +1 -1
  93. package/dist/providers/stripe/value-safety.js +22 -0
  94. package/dist/providers/tithely/index.js +1 -1
  95. package/dist/providers/vercel/index.js +1 -1
  96. package/dist/providers/vercel/rest.js +20 -2
  97. package/dist/providers/vercel/skill.generated.js +2 -2
  98. package/dist/providers/vercel/value-safety.js +22 -0
  99. package/dist/registry.d.ts +2 -3
  100. package/dist/registry.js +23 -9
  101. package/dist/routes/access-tokens.js +82 -75
  102. package/dist/routes/activity-disclosure.js +17 -18
  103. package/dist/routes/activity.js +34 -54
  104. package/dist/routes/actor-labels.js +33 -4
  105. package/dist/routes/agent-path.js +43 -0
  106. package/dist/routes/connect.js +16 -12
  107. package/dist/routes/credentials.js +16 -40
  108. package/dist/routes/mcp.js +161 -104
  109. package/dist/routes/oauth-management.js +11 -5
  110. package/dist/routes/oauth.js +5 -11
  111. package/dist/routes/operator.js +24 -23
  112. package/dist/routes/shared.d.ts +60 -20
  113. package/dist/routes/shared.js +187 -96
  114. package/dist/routes/ui-config.js +437 -97
  115. package/dist/routes/ui-data.js +2 -2
  116. package/dist/routes/ui.js +9 -16
  117. package/dist/runtime/call-admission.js +9 -0
  118. package/dist/server.js +5 -0
  119. package/dist/skills.js +10 -0
  120. package/dist/storage/keys.js +9 -1
  121. package/dist/storage/sql.js +5 -1
  122. package/dist/types.d.ts +29 -7
  123. package/dist/ui.d.ts +1 -1
  124. package/dist/ui.js +23 -16
  125. package/dist/usage-guide.js +1 -1
  126. package/dist/version.d.ts +1 -1
  127. package/dist/version.js +1 -1
  128. package/dist/workspace-address.js +9 -0
  129. package/dist/workspace-subject.js +23 -0
  130. package/documentation/README.md +47 -23
  131. package/documentation/architecture.md +371 -61
  132. package/documentation/auth.md +897 -249
  133. package/documentation/code-mode.md +37 -16
  134. package/documentation/deploying.md +126 -25
  135. package/documentation/integrating.md +97 -23
  136. package/documentation/meta-tools.md +50 -31
  137. package/documentation/migration-0.33.md +352 -0
  138. package/documentation/migration-0.34.md +67 -0
  139. package/documentation/operating.md +3 -1
  140. package/documentation/operator-tests.md +147 -34
  141. package/documentation/operator-ui.md +519 -64
  142. package/documentation/provider-auth-migration.md +37 -23
  143. package/documentation/provider-migration-0.29.md +2 -2
  144. package/examples/worker/AGENTS.md +37 -9
  145. package/examples/worker/README.md +127 -61
  146. package/examples/worker/src/connecta.config.ts +40 -23
  147. package/examples/worker/wrangler.jsonc +4 -1
  148. package/package.json +19 -18
  149. package/templates/node/.env.example +3 -0
  150. package/templates/node/AGENTS.md +33 -6
  151. package/templates/node/Dockerfile +2 -2
  152. package/templates/node/README.md +80 -52
  153. package/templates/node/docker-compose.yml +3 -2
  154. package/templates/node/package.json +4 -3
  155. package/templates/node/src/adopt-token.ts +23 -0
  156. package/templates/node/src/connecta.config.ts +28 -12
  157. package/templates/node/src/rotate-token.ts +17 -0
  158. 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
- import { CONNECTA_INSTRUCTIONS } from "../skills.js";
11
+ import { endpointInstructions } 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);
@@ -322,7 +341,10 @@ function serveMcp(request, requestSignal, requestScope, opts, baseUrl, actor, re
322
341
  // A request-local server cannot publish catalog changes. Set this before
323
342
  // tool registration, whose SDK default otherwise advertises listChanged.
324
343
  capabilities: { tools: { listChanged: false }, extensions: { "io.modelcontextprotocol/skills": {} } },
325
- instructions: CONNECTA_INSTRUCTIONS,
344
+ // Instructions differ per endpoint only by the admitted pool's
345
+ // description. Every cache hint below is private, so a client keeps
346
+ // what it learned per endpoint URL and never shares it across them.
347
+ instructions: endpointInstructions(pool !== undefined ? opts.pools.get(pool)?.description : undefined),
326
348
  inputRequired: { legacyShim: false },
327
349
  requestState: { verify: (state, context) => authElicitation.verify(state, context) },
328
350
  cacheHints: {
@@ -456,6 +478,21 @@ function serveMcp(request, requestSignal, requestScope, opts, baseUrl, actor, re
456
478
  return new Response(text, { status: response.status, statusText: response.statusText, headers });
457
479
  })));
458
480
  }
481
+ function mcpEndpoint(path) {
482
+ if (isAgentPath(path)) {
483
+ const pool = agentEndpointPool(path);
484
+ return pool === undefined ? { kind: "refused" } : { kind: "agent", pool };
485
+ }
486
+ if (path === "/mcp")
487
+ return { kind: "default" };
488
+ if (path.startsWith("/mcp/"))
489
+ return { kind: "pool", pool: path.slice("/mcp/".length) };
490
+ return null;
491
+ }
492
+ /** Whether `path` is an MCP or agent endpoint path, valid or not. */
493
+ function isMcpPath(path) {
494
+ return mcpEndpoint(path) !== null;
495
+ }
459
496
  export function createMcpRoute(opts) {
460
497
  const configuredOrigins = opts.config.allowedOrigins;
461
498
  const isExactOrigin = (value) => {
@@ -490,8 +527,7 @@ export function createMcpRoute(opts) {
490
527
  return hostname === "localhost" || hostname === "[::1]" || /^127\.\d+\.\d+\.\d+$/.test(hostname);
491
528
  };
492
529
  const rejectOrigin = (request) => {
493
- const path = new URL(request.url).pathname;
494
- if (path !== "/mcp" && !path.startsWith("/mcp/"))
530
+ if (!isMcpPath(new URL(request.url).pathname))
495
531
  return null;
496
532
  const origin = request.headers.get("Origin");
497
533
  if (origin === null || allowsOrigin(origin))
@@ -516,25 +552,37 @@ export function createMcpRoute(opts) {
516
552
  suppressedAdmissionWarnings = 0;
517
553
  };
518
554
  function routeMcp(context) {
519
- const { path, request, baseUrl, runtimeContext } = context;
555
+ const { path, request, baseUrl } = context;
520
556
  // Protected-resource metadata stays public, including when a grant is
521
557
  // configured, so a fresh OAuth client can discover how to authenticate.
522
- if (path !== "/mcp" && !path.startsWith("/mcp/"))
558
+ const endpoint = mcpEndpoint(path);
559
+ if (!endpoint)
523
560
  return Effect.succeed(null);
524
561
  // Admission, refusal bodies, SDK serialization and connector work share
525
562
  // one set. Connector contexts still receive an opaque scope, never the
526
563
  // inbound Request or its headers.
527
564
  const requestScope = {};
528
565
  const sentSecrets = sentSecretsForRequest(requestScope, sentSecretsForRequest(request));
529
- const poolName = path === "/mcp" ? undefined : path.slice("/mcp/".length);
566
+ const poolName = endpoint.kind === "pool" || endpoint.kind === "agent" ? endpoint.pool : undefined;
567
+ const agentEndpoint = endpoint.kind === "agent";
568
+ // Access attaches `ctx.access` even to a path it bypasses; an agent
569
+ // endpoint never reads it, so nothing past this point receives it.
570
+ const runtimeContext = agentEndpoint && context.runtimeContext
571
+ ? { waitUntil: context.runtimeContext.waitUntil.bind(context.runtimeContext) }
572
+ : context.runtimeContext;
530
573
  const origin = request.headers.get("Origin");
531
574
  const allowed = origin === null || allowsOrigin(origin);
532
575
  const cors = (response) => withMcpCors(response, request, configuredOrigins === "*" ? "*" : allowed ? origin : null);
576
+ const notFound = () => cors(mcpRefusal(sentSecrets, 404, -33004, "MCP endpoint not found."));
533
577
  // DNS-rebinding refusals cost neither a permit nor an auth lookup. This
534
578
  // local header check also guards OPTIONS before any provider metadata.
535
579
  const refusal = rejectOrigin(request);
536
580
  if (refusal)
537
581
  return Effect.succeed(refusal);
582
+ // Every other spelling in the agent namespace is the pool 404, before any
583
+ // admission or auth: no decoding, case folding, or slash collapsing.
584
+ if (endpoint.kind === "refused")
585
+ return Effect.succeed(notFound());
538
586
  if (request.method === "OPTIONS") {
539
587
  return Effect.succeed(cors(new Response(null, { status: 204 })));
540
588
  }
@@ -555,38 +603,30 @@ export function createMcpRoute(opts) {
555
603
  if (deadlineTimer !== undefined)
556
604
  clearTimeout(deadlineTimer);
557
605
  }));
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.
606
+ // A request naming `subscriptions/listen` defers its permit: nothing of
607
+ // its body is read until auth and endpoint admission pass, and then at
608
+ // most MAX_LISTEN_CLASSIFICATION_BYTES (#857). Only a body-confirmed
609
+ // modern listen goes without a permit; trusting the header alone would
610
+ // let a legacy tools/call bypass the pool, so anything else takes one
611
+ // after classification. Every other request takes its permit here,
612
+ // before auth, and no body is read before auth on any path.
560
613
  const startedAt = Date.now();
561
614
  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
615
+ const listenHeader = request.method === "POST" && request.headers.get("Mcp-Method") === "subscriptions/listen";
616
+ const refuseAdmission = (error) => {
617
+ if (!(error instanceof ExecutorAdmissionError))
618
+ return Effect.fail(error);
619
+ if (error.code === "executor_cancelled")
620
+ return Effect.fail(request.signal.reason ?? error);
621
+ if (error.code === "executor_overloaded")
622
+ warnAdmissionRejected(error);
623
+ return Effect.succeed(cors(requestAdmissionFailure(sentSecrets, error)));
624
+ };
625
+ const admission = listenHeader
575
626
  ? Result.succeed(undefined)
576
627
  : 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
- }
628
+ if (Result.isFailure(admission))
629
+ return yield* refuseAdmission(admission.failure);
590
630
  const remainingMs = admission.success
591
631
  ? admission.success.remainingMs()
592
632
  : maxDurationMs === undefined
@@ -613,8 +653,34 @@ export function createMcpRoute(opts) {
613
653
  // Effect `authorize` would put Effect in the /activity bundle, which
614
654
  // shares it, to save only the lookups an abandoned authorization
615
655
  // finishes on its own.
656
+ // Agent endpoints consult only the `cta_` adapter; human endpoints refuse `cta_`.
657
+ const admit = () => agentEndpoint
658
+ ? authorizeAgent(localRequest, baseUrl, opts.config.auth)
659
+ : authorize(localRequest, baseUrl, opts.config.auth, runtimeContext);
660
+ // Plain /mcp admits maintainers and serves the whole catalog; a pool
661
+ // admits its members, people at /mcp/<pool> and agents at
662
+ // /agent/<pool>, and serves exactly its tools (INV-4).
663
+ const scopeFor = (admitted) => {
664
+ if (poolName === undefined) {
665
+ return admitsMaintainer(opts, admitted)
666
+ ? { access: { connectorIds: "all" }, readOnly: opts.config.readOnly }
667
+ : undefined;
668
+ }
669
+ const pool = agentEndpoint
670
+ ? agentPool(opts, poolName, admitted.identity)
671
+ : memberPool(opts, poolName, admitted.identity);
672
+ // The owner travels with the scope, so both pool endpoints set the
673
+ // same caller facts (#837 P5: agents act as the pool owner).
674
+ return pool
675
+ ? {
676
+ access: pool.access,
677
+ readOnly: pool.readOnly,
678
+ ...(pool.owner !== undefined ? { poolOwner: pool.owner } : {}),
679
+ }
680
+ : undefined;
681
+ };
616
682
  const handled = Effect.gen(function* () {
617
- const authz = yield* Effect.promise(() => authorize(localRequest, baseUrl, opts.config.auth, runtimeContext, opts.config.identity));
683
+ const authz = yield* Effect.promise(admit);
618
684
  if (!authz.ok) {
619
685
  const response = authz.response;
620
686
  // Preserve the auth adapter's challenge and status. The MCP host must
@@ -641,83 +707,80 @@ export function createMcpRoute(opts) {
641
707
  },
642
708
  })), { status: response.status, headers }));
643
709
  }
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;
710
+ // A non-maintainer, a non-member, and an undeclared pool are one
711
+ // identical 404 so a credential never enumerates the other pools;
647
712
  // 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
- }
713
+ const scope = scopeFor(authz);
714
+ if (!scope) {
715
+ logFailure(opts.config.logger, poolName === undefined
716
+ ? "MCP default endpoint request denied"
717
+ : agentEndpoint
718
+ ? "MCP agent endpoint request denied"
719
+ : "MCP pool request denied", failureRecord({
720
+ reason: poolName === undefined
721
+ ? "not_maintainer"
722
+ : opts.pools.has(poolName)
723
+ ? "not_pool_member"
724
+ : "pool_not_declared",
725
+ }));
726
+ return notFound();
665
727
  }
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."));
728
+ const { access, readOnly, poolOwner } = scope;
729
+ if (listenHeader) {
730
+ const verdict = yield* Effect.promise(() => classifyListen(localRequest, localAbort.signal));
731
+ // The deadline timer aborts a stalled read; its body is already cancelled.
732
+ if (verdict === "aborted") {
733
+ return cors(mcpRefusal(sentSecrets, 504, -33003, "MCP request lifetime exceeded."));
734
+ }
735
+ if (verdict === "too_large") {
736
+ return cors(mcpRefusal(sentSecrets, 413, -33006, "MCP request body too large."));
737
+ }
738
+ if (verdict === "other") {
739
+ const late = yield* Effect.result(admitted(opts.requestAdmission, request.signal));
740
+ if (Result.isFailure(late))
741
+ return yield* refuseAdmission(late.failure);
681
742
  }
682
- access = intersectAccess(authz, pool.access);
683
- readOnly = pool.readOnly;
684
743
  }
744
+ // The caller rides on the view, and the paging recheck builds the
745
+ // same one: an agent's Workspace visibility depends on the owner.
746
+ const callerFor = (identity, owner) => ({
747
+ identity,
748
+ // `authorize` admits an open deployment's every request as the
749
+ // anonymous actor; only a provider's `ok` is an authentication.
750
+ authenticated: opts.config.auth.length > 0,
751
+ // Both pool endpoints build the caller here from the same
752
+ // admitted scope, so an agent at /agent/<pool> carries the owner.
753
+ ...(poolName !== undefined ? { pool: poolName } : {}),
754
+ ...(owner !== undefined ? { poolOwner: owner } : {}),
755
+ });
685
756
  let scopedRegistry;
686
757
  try {
687
- validateAuthPermissions(authz, opts.registry);
688
758
  scopedRegistry = opts.registry.scoped({
689
759
  connectorIds: access.connectorIds,
690
760
  ...(access.toolAccess ? { toolAccess: access.toolAccess } : {}),
691
- ...(access.guardedToolAccess ? { guardedToolAccess: access.guardedToolAccess } : {}),
692
761
  ...(authz.subjectKey ? { subjectKey: authz.subjectKey } : {}),
693
762
  ...(authz.principalKey ? { principalKey: authz.principalKey } : {}),
694
763
  endpoint: new URL(request.url).pathname,
695
764
  origin: request.headers.get("Origin"),
696
765
  currentResultAccess: async (address, classification, signal, access) => {
697
766
  signal?.throwIfAborted();
698
- const current = await authorize(localRequest, baseUrl, opts.config.auth, runtimeContext, opts.config.identity);
767
+ const current = await admit();
699
768
  if (!current.ok) {
700
769
  await current.response.body?.cancel().catch(() => { });
701
770
  return false;
702
771
  }
703
772
  if (current.subjectKey !== authz.subjectKey || current.principalKey !== authz.principalKey)
704
773
  return false;
705
- validateAuthPermissions(current, opts.registry);
706
- if (poolName === undefined && opts.config.grant && (await opts.config.grant(current.identity)) !== true)
774
+ // Paging re-admits the caller at this endpoint, as a new request would.
775
+ const currentScope = scopeFor(current);
776
+ if (!currentScope)
707
777
  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
- }
778
+ const { access: currentAccess, readOnly: currentReadOnly, poolOwner: currentOwner } = currentScope;
717
779
  const view = opts.registry.scoped({
718
780
  ...currentAccess,
719
781
  ...(current.subjectKey ? { subjectKey: current.subjectKey } : {}),
720
782
  ...(current.principalKey ? { principalKey: current.principalKey } : {}),
783
+ caller: callerFor(current.identity, currentOwner),
721
784
  });
722
785
  if (classification === "write" && currentReadOnly)
723
786
  return false;
@@ -742,13 +805,7 @@ export function createMcpRoute(opts) {
742
805
  await closeConnectorScope(resolved.connector, view.contextFor(resolved.connector.id, baseUrl, requestScope), runtimeContext?.waitUntil?.bind(runtimeContext));
743
806
  }
744
807
  },
745
- caller: {
746
- identity: authz.identity,
747
- // `authorize` admits an open deployment's every request as the
748
- // anonymous actor; only a provider's `ok` is an authentication.
749
- authenticated: opts.config.auth.length > 0,
750
- ...(poolName !== undefined ? { pool: poolName } : {}),
751
- },
808
+ caller: callerFor(authz.identity, poolOwner),
752
809
  });
753
810
  }
754
811
  catch {
@@ -759,7 +816,7 @@ export function createMcpRoute(opts) {
759
816
  }
760
817
  return cors(yield* serveMcp(localRequest, localAbort.signal, requestScope, opts, baseUrl, authz.actor, scopedRegistry, (id) => {
761
818
  const connector = scopedRegistry.getConnector(id);
762
- return Boolean(connector && mayManageConnector(authz, connector));
819
+ return Boolean(connector && mayManageConnector(opts, authz, connector));
763
820
  }, authz.principalKey, runtimeContext, readOnly, poolName, authz.identity.principal
764
821
  ? {
765
822
  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" },
@@ -38,7 +38,7 @@ function html(reason, opts, connector) {
38
38
  const details = outcome.fixPrompt
39
39
  ? `<details class="status-details">
40
40
  <summary>Details for the operator</summary>
41
- <p>If this keeps happening, send this to whoever runs ${escapeHtml(brand.productName)}. It is written for a coding agent working on the deployment and carries nothing from the provider's response.</p>
41
+ <p>If this keeps happening, send this to whoever runs ${escapeHtml(brand.productName)}. It is for a coding agent and holds nothing from the provider.</p>
42
42
  <pre>${escapeHtml(outcome.fixPrompt)}</pre>
43
43
  </details>`
44
44
  : "";
@@ -55,7 +55,7 @@ function html(reason, opts, connector) {
55
55
  </section>
56
56
  </main>`;
57
57
  return new Response(renderPage(opts.config.ui?.branding, {
58
- title: `${outcome.heading} — ${brand.pageTitle}`,
58
+ title: `${outcome.heading} · ${brand.pageTitle}`,
59
59
  uiMounted,
60
60
  body,
61
61
  }), { status: outcome.status, headers: htmlSecurityHeaders({ "Content-Type": "text/html; charset=utf-8" }) });
@@ -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.