@zackbart/connecta 0.38.0 → 0.40.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 (180) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/README.md +3 -2
  3. package/bin/connecta.mjs +4 -8
  4. package/bin/version.mjs +1 -1
  5. package/dist/access-tokens.js +6 -4
  6. package/dist/agent-routes.js +231 -0
  7. package/dist/auth/downstream-oauth/consent.js +519 -0
  8. package/dist/auth/downstream-oauth/grant-store.js +439 -0
  9. package/dist/auth/downstream-oauth/refresh-coordinator.js +562 -0
  10. package/dist/auth/downstream-oauth/refresh-records.js +447 -0
  11. package/dist/auth/downstream-oauth/sdk-hooks.js +242 -0
  12. package/dist/auth/downstream-oauth.js +66 -2382
  13. package/dist/auth/native.js +37 -0
  14. package/dist/auth/oauth-grant.js +13 -0
  15. package/dist/auth/static-oauth.js +153 -131
  16. package/dist/auth-elicitation.js +7 -1
  17. package/dist/authorization-handoff.js +11 -5
  18. package/dist/catalog-cache.js +219 -59
  19. package/dist/catalog-policy.js +18 -0
  20. package/dist/catalog-service.js +284 -47
  21. package/dist/catalog.js +18 -2
  22. package/dist/config.d.ts +16 -11
  23. package/dist/config.js +9 -6
  24. package/dist/connector-caller.d.ts +1 -1
  25. package/dist/connector-health.js +59 -0
  26. package/dist/connector-status.js +117 -0
  27. package/dist/connectors/api-connector.d.ts +7 -2
  28. package/dist/connectors/api-connector.js +7 -4
  29. package/dist/connectors/negotiation-cache.js +22 -4
  30. package/dist/connectors/remote-mcp/call.js +164 -0
  31. package/dist/connectors/remote-mcp/catalog.js +278 -0
  32. package/dist/connectors/remote-mcp/config.js +263 -0
  33. package/dist/connectors/remote-mcp/connection.js +682 -0
  34. package/dist/connectors/remote-mcp/errors.js +488 -0
  35. package/dist/connectors/remote-mcp/oauth.js +473 -0
  36. package/dist/connectors/remote-mcp/resources.js +473 -0
  37. package/dist/connectors/remote-mcp/runtime.js +1 -0
  38. package/dist/connectors/remote-mcp/transport.js +555 -0
  39. package/dist/connectors/remote-mcp.js +62 -2863
  40. package/dist/credential-contract.d.ts +23 -0
  41. package/dist/credential-reads.js +17 -0
  42. package/dist/credential-verification.js +53 -0
  43. package/dist/credentials.d.ts +7 -0
  44. package/dist/credentials.js +60 -25
  45. package/dist/describe-config.d.ts +12 -2
  46. package/dist/describe-config.js +2 -0
  47. package/dist/discovery-health.js +199 -0
  48. package/dist/downstream-input.js +2 -0
  49. package/dist/errors.d.ts +55 -17
  50. package/dist/errors.js +95 -41
  51. package/dist/execute.js +152 -114
  52. package/dist/executor-result.js +3 -1
  53. package/dist/guest-functions.js +83 -0
  54. package/dist/guest-runtime.js +60 -19
  55. package/dist/guest-types.d.ts +19 -5
  56. package/dist/http/argument-problems.js +35 -0
  57. package/dist/http/errors.js +5 -4
  58. package/dist/http/fields.js +192 -13
  59. package/dist/http/hatch-response.js +73 -0
  60. package/dist/http/hatch.js +88 -23
  61. package/dist/http/oauth.js +3 -2
  62. package/dist/http/provider.js +32 -4
  63. package/dist/http/safety.js +8 -0
  64. package/dist/http/tools.js +44 -23
  65. package/dist/index.d.ts +1 -1
  66. package/dist/index.js +9 -2
  67. package/dist/invocation.js +26 -8
  68. package/dist/membership.d.ts +13 -5
  69. package/dist/membership.js +34 -2
  70. package/dist/meta-tools.js +54 -31
  71. package/dist/native-recovery.js +4 -16
  72. package/dist/native-tools.js +5 -2
  73. package/dist/oauth-sealing.js +10 -0
  74. package/dist/operation-vocabulary.js +135 -13
  75. package/dist/operator-record.d.ts +2 -2
  76. package/dist/operator-record.js +1 -0
  77. package/dist/operator-ui/contract.d.ts +10 -0
  78. package/dist/operator-ui/generated.js +2 -2
  79. package/dist/operator-ui/model.d.ts +22 -1
  80. package/dist/provider-call-facts.js +6 -2
  81. package/dist/providers/_shared/rest/operation-index.js +48 -50
  82. package/dist/providers/_shared/rest/value-safety.js +80 -0
  83. package/dist/providers/breeze/api.js +7 -1
  84. package/dist/providers/breeze/skill.generated.js +1 -1
  85. package/dist/providers/breeze/tools.js +4 -3
  86. package/dist/providers/ccb/api.js +2 -1
  87. package/dist/providers/ccb/index.js +5 -0
  88. package/dist/providers/ccb/skill.generated.js +1 -1
  89. package/dist/providers/cloudflare/api.js +8 -1
  90. package/dist/providers/cloudflare/skill.generated.js +1 -1
  91. package/dist/providers/docs/lib/schemas.js +24 -22
  92. package/dist/providers/docs/safety.js +2 -0
  93. package/dist/providers/docs/skill.generated.js +1 -1
  94. package/dist/providers/docs/tools.js +2 -4
  95. package/dist/providers/drive/api.js +1 -0
  96. package/dist/providers/drive/lib/contracts.js +8 -5
  97. package/dist/providers/drive/skill.generated.js +1 -1
  98. package/dist/providers/drive/tools.js +11 -16
  99. package/dist/providers/forms/api.js +1 -0
  100. package/dist/providers/forms/skill.generated.js +1 -1
  101. package/dist/providers/forms/tools.js +10 -7
  102. package/dist/providers/github/api.js +1 -0
  103. package/dist/providers/github/lib/discovery.js +1 -1
  104. package/dist/providers/github/skill.generated.js +1 -1
  105. package/dist/providers/github/tools.js +61 -17
  106. package/dist/providers/gmail/lib/schemas.js +60 -50
  107. package/dist/providers/gmail/skill.generated.js +1 -1
  108. package/dist/providers/gmail/tools.js +7 -5
  109. package/dist/providers/granola/index.js +2 -0
  110. package/dist/providers/granola/skill.generated.js +1 -1
  111. package/dist/providers/overflow/api.js +7 -1
  112. package/dist/providers/overflow/skill.generated.js +1 -1
  113. package/dist/providers/planning-center/api.js +3 -1
  114. package/dist/providers/planning-center/lib/constants.d.ts +1 -1
  115. package/dist/providers/planning-center/skill.generated.js +1 -1
  116. package/dist/providers/quickbooks/safety.js +80 -39
  117. package/dist/providers/quickbooks/skill.generated.js +1 -1
  118. package/dist/providers/sheets/api.js +1 -0
  119. package/dist/providers/sheets/skill.generated.js +1 -1
  120. package/dist/providers/sheets/tools.js +13 -14
  121. package/dist/providers/slides/skill.generated.js +1 -1
  122. package/dist/providers/slides/tools.js +15 -13
  123. package/dist/providers/stripe/skill.generated.js +1 -1
  124. package/dist/providers/tithely/api.js +3 -1
  125. package/dist/providers/tithely/skill.generated.js +1 -1
  126. package/dist/providers/vercel/api.js +1 -1
  127. package/dist/providers/vercel/skill.generated.js +1 -1
  128. package/dist/providers/vercel/tools.js +4 -3
  129. package/dist/registry.d.ts +10 -4
  130. package/dist/registry.js +41 -13
  131. package/dist/routes/credentials.js +129 -60
  132. package/dist/routes/dispatch.js +5 -1
  133. package/dist/routes/mcp.js +30 -14
  134. package/dist/routes/operator.js +2 -2
  135. package/dist/routes/shared.d.ts +6 -1
  136. package/dist/routes/shared.js +7 -1
  137. package/dist/routes/ui-config.js +2 -2
  138. package/dist/routes/ui-data.js +126 -20
  139. package/dist/routes/ui.js +16 -0
  140. package/dist/runtime/call.js +7 -2
  141. package/dist/runtime/native-storage.js +12 -0
  142. package/dist/runtime/request-reads.js +207 -0
  143. package/dist/runtime/request-scope.js +16 -0
  144. package/dist/runtime/storage.js +12 -0
  145. package/dist/skills.js +37 -20
  146. package/dist/storage/keys.js +53 -3
  147. package/dist/storage/memory.js +8 -2
  148. package/dist/storage/sql-native.js +53 -1
  149. package/dist/storage/sql.js +12 -2
  150. package/dist/storage-digest.js +32 -0
  151. package/dist/timeout.js +8 -0
  152. package/dist/types.d.ts +33 -0
  153. package/dist/usage-guide.js +16 -35
  154. package/dist/validate.d.ts +3 -0
  155. package/dist/validate.js +220 -158
  156. package/dist/version.d.ts +1 -1
  157. package/dist/version.js +1 -1
  158. package/dist/write-outcome.js +0 -3
  159. package/documentation/README.md +13 -13
  160. package/documentation/architecture.md +385 -20
  161. package/documentation/auth.md +233 -64
  162. package/documentation/code-mode.md +122 -39
  163. package/documentation/deploying.md +6 -3
  164. package/documentation/integrating.md +16 -2
  165. package/documentation/meta-tools.md +240 -49
  166. package/documentation/migration-0.33.md +5 -1
  167. package/documentation/migration-0.34.md +3 -1
  168. package/documentation/operator-tests.md +1 -1
  169. package/documentation/operator-ui.md +105 -12
  170. package/documentation/pool-membership-upgrade.md +44 -0
  171. package/documentation/providers.md +105 -0
  172. package/examples/worker/AGENTS.md +10 -10
  173. package/examples/worker/README.md +4 -3
  174. package/examples/worker/src/connecta.config.ts +1 -0
  175. package/guides/connecting-agents.md +2 -1
  176. package/guides/how-it-works.md +4 -2
  177. package/package.json +1 -1
  178. package/templates/node/AGENTS.md +13 -12
  179. package/templates/node/package.json +1 -1
  180. package/templates/node/src/connecta.config.ts +1 -0
@@ -1,7 +1,10 @@
1
1
  import { failureRecord, logFailure } from "../operator-record.js";
2
2
  import { Effect } from "effect";
3
- import { claimConnectorScopeCleanup, closeConnectorScope } from "../connector-scope.js";
3
+ import { claimConnectorScopeCleanup } from "../connector-scope.js";
4
4
  import { credentialTestRule, describeCredentialTestMismatch, storedCredentialShape } from "../credential-rules.js";
5
+ import { credentialFailureClass, credentialTestOutcome, writeVerification, } from "../credential-verification.js";
6
+ import { closeScope } from "../runtime/connector-scope.js";
7
+ import { withDeadlineEffect } from "../runtime/run.js";
5
8
  import { authorizedPerson, refuse, serveOperator, managementRegistry } from "./operator.js";
6
9
  import { boundedText, mayManageConnector, isSameOrigin, msg, privateJson } from "./shared.js";
7
10
  /** The longest body, in characters, a credential write accepts. */
@@ -51,9 +54,19 @@ function readCredentialInput(request, config) {
51
54
  // `null` parses, but a credential cannot be read from it.
52
55
  if (body === null)
53
56
  return yield* refuse("invalid JSON body", 400);
54
- return yield* credentialInput(body, config);
57
+ if (body.verify !== undefined && body.verify !== "test" && body.verify !== "skip") {
58
+ return yield* refuse('verify must be "test" or "skip"', 400);
59
+ }
60
+ const input = yield* credentialInput(body, config);
61
+ return { input, verify: body.verify ?? "skip" };
55
62
  });
56
63
  }
64
+ /** The test hook's refusal when the declared shape picks none, in the page's words. */
65
+ function untestable(mismatch) {
66
+ return refuse(mismatch
67
+ ? "this connector cannot test its credential: " + describeCredentialTestMismatch(mismatch)
68
+ : "this connector does not support credential testing", 400);
69
+ }
57
70
  function credentialRequest(context, connectorId, action) {
58
71
  const { request, baseUrl, opts } = context;
59
72
  return Effect.gen(function* () {
@@ -79,6 +92,64 @@ function credentialRequest(context, connectorId, action) {
79
92
  const updatedBy = authz.identity.principal
80
93
  ? `${authz.identity.principal.namespace}:${authz.identity.principal.id}`
81
94
  : (authz.actor.id ?? authz.actor.kind);
95
+ /**
96
+ * Run the connector's test hook on `values` — the stored credential or an
97
+ * unsaved candidate, which stays in this request's memory — under the
98
+ * deployment's probe deadline. Settles to an outcome class and never
99
+ * fails: whatever goes wrong is the test's result, not the route's. Each
100
+ * run gets its own context and request scope, so concurrent tests share
101
+ * no connection.
102
+ *
103
+ * Only the class leaves. A hook's message, and anything it throws, can
104
+ * quote the downstream's reply, and that reply can quote the credential
105
+ * it just rejected — so neither the page nor the log repeats it (INV-6):
106
+ * the page says fixed copy for the class, and the log records a
107
+ * failure's class.
108
+ */
109
+ const runTest = (mode, values) => {
110
+ const timeoutError = new Error("credential test timed out");
111
+ return withDeadlineEffect((signal) => {
112
+ const ctx = registry.contextFor(connectorId, baseUrl, {}, { signal });
113
+ const releaseCleanup = claimConnectorScopeCleanup(ctx, connectorId);
114
+ return Effect.tryPromise({
115
+ try: async () => {
116
+ const result = mode === "multiple"
117
+ ? await connector.testCredentials(values, ctx)
118
+ : // The single-value shape guarantees this key.
119
+ await connector.testCredential(values.value, ctx);
120
+ // A hook that turned the deadline's abort into a result still ran out of time.
121
+ return signal.reason === timeoutError ? "timeout" : credentialTestOutcome(result);
122
+ },
123
+ catch: (error) => (signal.reason === timeoutError ? timeoutError : error),
124
+ }).pipe(
125
+ // The scope closes here, once, however the test ends: a hook that
126
+ // never settles is interrupted by the deadline, and a cleanup in
127
+ // its Promise would never run (INV-7).
128
+ Effect.ensuring(Effect.suspend(() => {
129
+ releaseCleanup();
130
+ return closeScope(connector, ctx, context.defer);
131
+ })));
132
+ }, { timeoutMs: opts.config.discovery.probeTimeoutMs, timeoutError }).pipe(Effect.catch((error) => {
133
+ if (error === timeoutError)
134
+ return Effect.succeed("timeout");
135
+ logFailure(opts.config.logger, "credential test threw", failureRecord({ connector: connectorId }, error));
136
+ return Effect.succeed(credentialFailureClass(error));
137
+ }), Effect.map((outcome) => {
138
+ if (outcome !== "ok")
139
+ logFailure(opts.config.logger, "credential test failed", failureRecord({ connector: connectorId }));
140
+ return outcome;
141
+ }));
142
+ };
143
+ /**
144
+ * Record what was observed about the save with `revision`, or (`null`)
145
+ * forget it, in the connector's own owner-partitioned storage. A save
146
+ * without a revision (a vault that keeps none) is never recorded. Best
147
+ * effort: a lost record only leaves status saying the credential is not
148
+ * verified, so it never fails the request.
149
+ */
150
+ const record = (outcome, revision) => Effect.promise(async () => outcome && !revision
151
+ ? undefined
152
+ : writeVerification(registry.contextFor(connectorId, baseUrl).storage, outcome, revision).catch((error) => void logFailure(opts.config.logger, "credential verification record failed", failureRecord({ connector: connectorId }, error))));
82
153
  if (action === "test") {
83
154
  if (request.method !== "POST")
84
155
  return yield* refuse("method not allowed", 405);
@@ -86,74 +157,69 @@ function credentialRequest(context, connectorId, action) {
86
157
  // Credentials page asks for its Test affordance, so a shown button reaches
87
158
  // that reads the shape the credential was stored in.
88
159
  const { mode, mismatch } = credentialTestRule(connector);
89
- if (!mode) {
90
- return yield* refuse(mismatch
91
- ? "this connector cannot test its credential: " + describeCredentialTestMismatch(mismatch)
92
- : "this connector does not support credential testing", 400);
93
- }
94
- // Whatever goes wrong past this point is the test's result, not the
95
- // route's: the page shows it beside the Test button. Only `ok` leaves
96
- // the host. A hook's message, and anything it throws, can quote the
97
- // downstream's reply, and that reply can quote the credential it just
98
- // rejected — so neither the page nor the log repeats it (INV-6): the
99
- // page says a fixed sentence for the outcome, and the log records a
100
- // failure's class.
101
- return yield* Effect.tryPromise({
102
- try: async () => {
103
- const values = await vault.getAll(connectorId, owner);
104
- const shape = storedCredentialShape(credential, values);
105
- // Both refusals are the route's own words, and each carries the
106
- // problem kind the page keys its copy and fix prompt off.
107
- if (shape.state === "missing") {
108
- return privateJson({
109
- error: mode === "multiple"
110
- ? "configure the credentials before testing them"
111
- : "configure the credential before testing it",
112
- problem: "credential_required",
113
- }, { status: 409 });
114
- }
115
- if (shape.state === "mismatch") {
116
- return privateJson({ error: shape.message, problem: "credential_mismatch" }, { status: 409 });
117
- }
118
- const storedValues = values;
119
- const ctx = registry.contextFor(connectorId, baseUrl);
120
- const releaseCleanup = claimConnectorScopeCleanup(ctx, connectorId);
121
- try {
122
- const result = mode === "multiple"
123
- ? await connector.testCredentials(storedValues, ctx)
124
- : await connector.testCredential(
125
- // The single-value shape check above guarantees this key.
126
- storedValues.value, ctx);
127
- const ok = result?.ok === true;
128
- if (!ok)
129
- logFailure(opts.config.logger, "credential test failed", failureRecord({ connector: connectorId }, undefined));
130
- return privateJson({ ok });
131
- }
132
- finally {
133
- releaseCleanup();
134
- await closeConnectorScope(connector, ctx, context.defer);
135
- }
136
- },
160
+ if (!mode)
161
+ return yield* untestable(mismatch);
162
+ // One envelope read gives both the values tested and the revision of
163
+ // the save they came from, so the record can never vouch for another
164
+ // save. A vault without snapshots is tested but never recorded.
165
+ const stored = yield* Effect.tryPromise({
166
+ try: async () => vault.snapshot
167
+ ? ((await vault.snapshot(connectorId, owner)) ?? { values: null })
168
+ : { values: await vault.getAll(connectorId, owner) },
137
169
  catch: (error) => error,
138
170
  }).pipe(Effect.catch((error) => {
139
171
  logFailure(opts.config.logger, "credential test threw", failureRecord({ connector: connectorId }, error));
140
- return Effect.succeed(privateJson({ ok: false }));
172
+ return Effect.succeed(undefined);
141
173
  }));
174
+ if (!stored)
175
+ return privateJson({ ok: false, failure: "failed" });
176
+ const shape = storedCredentialShape(credential, stored.values);
177
+ // Both refusals are the route's own words, and each carries the
178
+ // problem kind the page keys its copy and fix prompt off.
179
+ if (shape.state === "missing") {
180
+ return privateJson({
181
+ error: mode === "multiple"
182
+ ? "configure the credentials before testing them"
183
+ : "configure the credential before testing it",
184
+ problem: "credential_required",
185
+ }, { status: 409 });
186
+ }
187
+ if (shape.state === "mismatch") {
188
+ return privateJson({ error: shape.message, problem: "credential_mismatch" }, { status: 409 });
189
+ }
190
+ const outcome = yield* runTest(mode, stored.values);
191
+ if ("metadata" in stored)
192
+ yield* record(outcome, stored.metadata.revision);
193
+ return privateJson(outcome === "ok" ? { ok: true } : { ok: false, failure: outcome });
142
194
  }
143
195
  if (action)
144
196
  return yield* refuse("not found", 404);
145
197
  if (request.method === "PUT") {
146
- const input = yield* readCredentialInput(request, credential);
147
- return yield* Effect.tryPromise({
148
- try: async () => {
149
- const metadata = input.kind === "single"
150
- ? await vault.set(connectorId, input.value, updatedBy, owner)
151
- : await vault.setAll(connectorId, input.values, updatedBy, owner);
152
- await registry.invalidateStored(connectorId);
153
- return privateJson({ credential: metadata });
154
- },
198
+ const { input, verify } = yield* readCredentialInput(request, credential);
199
+ const values = input.kind === "single" ? { value: input.value } : input.values;
200
+ if (verify === "test") {
201
+ // Test then save: the candidate is tested in memory and stored only
202
+ // if the test passes. A failure stores nothing and names its class;
203
+ // the operator may still save it unverified with `verify: "skip"`,
204
+ // so an outage at the vendor never blocks a replacement.
205
+ const { mode, mismatch } = credentialTestRule(connector);
206
+ if (!mode)
207
+ return yield* untestable(mismatch);
208
+ const outcome = yield* runTest(mode, values);
209
+ if (outcome !== "ok")
210
+ return privateJson({ saved: false, failure: outcome });
211
+ }
212
+ const metadata = yield* Effect.tryPromise({
213
+ try: () => input.kind === "single"
214
+ ? vault.set(connectorId, input.value, updatedBy, owner)
215
+ : vault.setAll(connectorId, input.values, updatedBy, owner),
155
216
  catch: (error) => error,
156
217
  }).pipe(Effect.catch((error) => refuse(msg(error), 400)));
218
+ // The write's own revision: a concurrent save cannot lend it one.
219
+ const { revision, ...saved } = metadata;
220
+ const verification = yield* record(verify === "test" ? "ok" : "unverified", revision);
221
+ yield* Effect.promise(() => registry.invalidateStored(connectorId));
222
+ return privateJson({ saved: true, credential: saved, ...(verification ? { verification } : {}) });
157
223
  }
158
224
  if (request.method === "DELETE") {
159
225
  // Clearing nothing is refused, as testing nothing is, so the route
@@ -168,6 +234,9 @@ function credentialRequest(context, connectorId, action) {
168
234
  return privateJson({ error: "no credential is stored to clear", problem: "credential_required" }, { status: 409 });
169
235
  }
170
236
  yield* Effect.promise(() => vault.delete(connectorId, owner));
237
+ // A record about a value no longer stored would match nothing anyway;
238
+ // removing it keeps the partition clean.
239
+ yield* record(null);
171
240
  yield* Effect.promise(() => registry.invalidateStored(connectorId));
172
241
  return new Response(null, {
173
242
  status: 204,
@@ -28,7 +28,11 @@ export function createRouteDispatcher(mcp, metadata, health) {
28
28
  });
29
29
  const afterUi = (program) => (context) => Effect.gen(function* () {
30
30
  const ui = context.opts.config.ui;
31
- const response = ui ? yield* Effect.promise(() => ui.handle(context)) : null;
31
+ // The operator surface is a Promise edge that owns the request's
32
+ // signal: when the caller leaves, its routes stop on their own. Wait
33
+ // for it to settle rather than abandoning it, so writes it has
34
+ // already started end inside this request (INV-7).
35
+ const response = ui ? yield* Effect.uninterruptible(Effect.promise(() => ui.handle(context))) : null;
32
36
  if (response?.status === 401) {
33
37
  const authorizationHeader = authorizationCredential(context.request).kind;
34
38
  logFailure(context.opts.config.logger, "operator_refused", failureRecord({
@@ -1,4 +1,6 @@
1
+ import { routeNames } from "../agent-routes.js";
1
2
  import { upwardStep } from "../upward.js";
3
+ import { bindDiscoveryEndpoint } from "../discovery-health.js";
2
4
  import { authCall, authInvoke, authFlow, authMethod } from "../auth/native.js";
3
5
  import { registryProgram } from "../runtime/registry.js";
4
6
  import { requestOwner, RequestScope } from "../runtime/request-scope.js";
@@ -20,7 +22,7 @@ import { failureRecord, logFailure } from "../operator-record.js";
20
22
  import { redactAgentOutput, sentSecretsForRequest } from "../sent-secrets.js";
21
23
  import { detach } from "../runtime/run.js";
22
24
  import { agentEndpointPool, isAgentPath } from "./agent-path.js";
23
- import { admitsMaintainer, agentPool, authorize, authorizeAgent, mayManageConnector, memberPool, } from "./shared.js";
25
+ import { admitsMaintainer, agentPool, authorize, authorizeAgent, mayManageConnector, memberPool, personPools, } from "./shared.js";
24
26
  /**
25
27
  * Core checks admission.requests.maxBodyBytes after authentication and endpoint
26
28
  * admission, before any SDK/body-hint reader. The SDK's separate 4 MiB default
@@ -396,8 +398,11 @@ function serveMcp(request, requestSignal, requestScope, opts, baseUrl, actor, re
396
398
  ...(runtimeContext?.waitUntil ? { defer: runtimeContext.waitUntil.bind(runtimeContext) } : {}),
397
399
  });
398
400
  const diagnostics = native ? { ...native, catalogErrors: [] } : undefined;
401
+ // A native pool's text uses the native contract's wording.
402
+ const routes = native ? routeNames(native.executeCode ? "native-code" : "native") : opts.routes;
399
403
  const nativeRoute = registerMetaTools(server, registry, {
400
404
  native: diagnostics,
405
+ routes,
401
406
  authElicitation,
402
407
  client,
403
408
  baseUrl,
@@ -430,6 +435,7 @@ function serveMcp(request, requestSignal, requestScope, opts, baseUrl, actor, re
430
435
  ...executeLimits(opts.config),
431
436
  readOnly,
432
437
  native: native !== undefined,
438
+ routes,
433
439
  });
434
440
  }
435
441
  if (native && nativeRoute && hint) {
@@ -597,6 +603,9 @@ export function createMcpRoute(opts) {
597
603
  const requestScope = {};
598
604
  const sentSecrets = sentSecretsForRequest(requestScope, sentSecretsForRequest(request));
599
605
  const poolName = endpoint.kind === "pool" || endpoint.kind === "agent" ? endpoint.pool : undefined;
606
+ // Discovery health is kept per admitted endpoint: the same person and
607
+ // pool at `/mcp` and `/mcp/<pool>` keep separate records.
608
+ bindDiscoveryEndpoint(requestScope, JSON.stringify([endpoint.kind, poolName ?? null]));
600
609
  const agentEndpoint = endpoint.kind === "agent";
601
610
  // Access attaches `ctx.access` even to a path it bypasses; an agent
602
611
  // endpoint never reads it, so nothing past this point receives it.
@@ -719,13 +728,17 @@ export function createMcpRoute(opts) {
719
728
  false,
720
729
  opts.config.logger,
721
730
  ]));
722
- // Plain /mcp admits maintainers and serves the whole catalog; a pool
731
+ // Plain /mcp serves maintainers the whole catalog and other people their unique pool; a pool
723
732
  // admits its members, people at /mcp/<pool> and agents at
724
733
  // /agent/<pool>, and serves exactly its tools (INV-4).
725
734
  const scopeFor = (admitted) => {
726
735
  if (poolName === undefined) {
727
- return admitsMaintainer(opts, admitted)
728
- ? { access: { connectorIds: "all" }, readOnly: opts.config.readOnly }
736
+ if (admitsMaintainer(opts, admitted))
737
+ return { access: { connectorIds: "all" }, readOnly: opts.config.readOnly };
738
+ // Exactly one membership routes; none or several fail closed.
739
+ const [match, ...more] = personPools(opts, admitted.identity);
740
+ return match && !more.length
741
+ ? { pool: match[0], access: match[1].access, readOnly: match[1].readOnly }
729
742
  : undefined;
730
743
  }
731
744
  const pool = agentEndpoint
@@ -735,6 +748,7 @@ export function createMcpRoute(opts) {
735
748
  // same caller facts (#837 P5: agents act as the pool owner).
736
749
  return pool
737
750
  ? {
751
+ pool: poolName,
738
752
  access: pool.access,
739
753
  readOnly: pool.readOnly,
740
754
  ...(pool.owner !== undefined ? { poolOwner: pool.owner } : {}),
@@ -780,14 +794,16 @@ export function createMcpRoute(opts) {
780
794
  ? "MCP agent endpoint request denied"
781
795
  : "MCP pool request denied", failureRecord({
782
796
  reason: poolName === undefined
783
- ? "not_maintainer"
797
+ ? personPools(opts, authz.identity).length > 1
798
+ ? "ambiguous_pool"
799
+ : "not_maintainer"
784
800
  : opts.pools.has(poolName)
785
801
  ? "not_pool_member"
786
802
  : "pool_not_declared",
787
803
  }));
788
804
  return notFound();
789
805
  }
790
- const { access, readOnly, poolOwner } = scope;
806
+ const { access, readOnly } = scope;
791
807
  // Admission, not completed-call activity. This fact carries no live
792
808
  // identity or credential and never changes the admission verdict.
793
809
  if (agentEndpoint && authz.identity.agent && opts.config.agents.includes(authz.identity.agent)) {
@@ -812,15 +828,15 @@ export function createMcpRoute(opts) {
812
828
  }
813
829
  // The caller rides on the view, and the paging recheck builds the
814
830
  // same one: an agent's Workspace visibility depends on the owner.
815
- const callerFor = (identity, owner) => ({
831
+ const callerFor = (identity, admittedScope) => ({
816
832
  identity,
817
833
  // `authorize` admits an open deployment's every request as the
818
834
  // anonymous actor; only a provider's `ok` is an authentication.
819
835
  authenticated: opts.config.auth.length > 0,
820
836
  // Both pool endpoints build the caller here from the same
821
837
  // admitted scope, so an agent at /agent/<pool> carries the owner.
822
- ...(poolName !== undefined ? { pool: poolName } : {}),
823
- ...(owner !== undefined ? { poolOwner: owner } : {}),
838
+ ...(admittedScope.pool !== undefined ? { pool: admittedScope.pool } : {}),
839
+ ...(admittedScope.poolOwner !== undefined ? { poolOwner: admittedScope.poolOwner } : {}),
824
840
  });
825
841
  let scopedRegistry;
826
842
  try {
@@ -844,14 +860,14 @@ export function createMcpRoute(opts) {
844
860
  return false;
845
861
  // Paging re-admits the caller at this endpoint, as a new request would.
846
862
  const currentScope = scopeFor(current);
847
- if (!currentScope)
863
+ if (!currentScope || currentScope.pool !== scope.pool)
848
864
  return false;
849
- const { access: currentAccess, readOnly: currentReadOnly, poolOwner: currentOwner } = currentScope;
865
+ const { access: currentAccess, readOnly: currentReadOnly } = currentScope;
850
866
  const view = opts.registry.scoped({
851
867
  ...currentAccess,
852
868
  ...(current.subjectKey ? { subjectKey: current.subjectKey } : {}),
853
869
  ...(current.principalKey ? { principalKey: current.principalKey } : {}),
854
- caller: callerFor(current.identity, currentOwner),
870
+ caller: callerFor(current.identity, currentScope),
855
871
  });
856
872
  if (classification === "write" && currentReadOnly)
857
873
  return false;
@@ -898,7 +914,7 @@ export function createMcpRoute(opts) {
898
914
  }
899
915
  }
900
916
  })),
901
- caller: callerFor(authz.identity, poolOwner),
917
+ caller: callerFor(authz.identity, scope),
902
918
  });
903
919
  }
904
920
  catch {
@@ -910,7 +926,7 @@ export function createMcpRoute(opts) {
910
926
  return cors(yield* serveMcp(localRequest, localAbort.signal, requestScope, opts, baseUrl, authz.actor, scopedRegistry, (id) => {
911
927
  const connector = scopedRegistry.getConnector(id);
912
928
  return Boolean(connector && mayManageConnector(opts, authz, connector));
913
- }, authz.principalKey, runtimeContext, readOnly, poolName, authz.identity.principal
929
+ }, authz.principalKey, runtimeContext, readOnly, scope.pool, authz.identity.principal
914
930
  ? {
915
931
  kind: authz.actor.kind,
916
932
  id: authz.identity.principal.id,
@@ -93,12 +93,12 @@ export function operatorViewerKey(context, authz) {
93
93
  }).pipe(Effect.catch(() => refuse("operator identity could not be read", 503)));
94
94
  }
95
95
  /** The partition an identity carries into a registry view of the given connectors. */
96
- export function scopeFor(authz, authenticated, connectorIds = "all") {
96
+ export function scopeFor(authz, authenticated, connectorIds = "all", pool) {
97
97
  return {
98
98
  connectorIds,
99
99
  ...(authz.subjectKey ? { subjectKey: authz.subjectKey } : {}),
100
100
  ...(authz.principalKey ? { principalKey: authz.principalKey } : {}),
101
- operatorCaller: { identity: authz.identity, authenticated },
101
+ operatorCaller: { identity: authz.identity, authenticated, ...(pool !== undefined ? { pool } : {}) },
102
102
  };
103
103
  }
104
104
  /**
@@ -1,3 +1,4 @@
1
+ import type { AgentRouteNames } from "../types.js";
1
2
  import type { ActivityActor } from "../activity.js";
2
3
  import type { DeferredWork } from "../connector-scope.js";
3
4
  import type { AdmissionController } from "../executor-admission.js";
@@ -28,6 +29,8 @@ export interface ServerOptions {
28
29
  executorName?: string | undefined;
29
30
  /** Global FIFO boundary for all non-preflight `/mcp` requests. */
30
31
  requestAdmission: AdmissionController;
32
+ /** Route names agent text uses outside native pools: meta, or program form for the eval code arm only. */
33
+ routes: AgentRouteNames;
31
34
  }
32
35
  export interface RuntimeExecutionContext extends InboundAuthRuntimeContext {
33
36
  waitUntil(promise: Promise<unknown>): void;
@@ -93,7 +96,7 @@ type AuthorizedIdentity = Extract<Awaited<ReturnType<typeof authorize>>, {
93
96
  ok: true;
94
97
  }>;
95
98
  /**
96
- * Plain `/mcp` and every operator route admit a maintainer. An open
99
+ * Every operator route admits a maintainer. An open
97
100
  * deployment has no inbound auth and so no maintainers: its one anonymous
98
101
  * caller keeps the whole default endpoint, as the startup warning says.
99
102
  */
@@ -103,6 +106,8 @@ export declare function admitsMaintainer(opts: ServerOptions, authz: AuthorizedI
103
106
  * same 404. An agent is never admitted here, even when listed.
104
107
  */
105
108
  export declare function memberPool(opts: ServerOptions, name: string, identity: Readonly<AuthenticatedIdentity>): ResolvedPool | undefined;
109
+ /** Human memberships, matched only against the resolved namespaced principal. */
110
+ export declare function personPools(opts: ServerOptions, identity: Readonly<AuthenticatedIdentity>): Array<[string, ResolvedPool]>;
106
111
  /** The declared pool `/agent/<name>` serves to this agent, or undefined for the same 404. */
107
112
  export declare function agentPool(opts: ServerOptions, name: string, identity: Readonly<AuthenticatedIdentity>): ResolvedPool | undefined;
108
113
  /**
@@ -392,7 +392,7 @@ export function withSecurityHeaders(response, requestUrl) {
392
392
  });
393
393
  }
394
394
  /**
395
- * Plain `/mcp` and every operator route admit a maintainer. An open
395
+ * Every operator route admits a maintainer. An open
396
396
  * deployment has no inbound auth and so no maintainers: its one anonymous
397
397
  * caller keeps the whole default endpoint, as the startup warning says.
398
398
  */
@@ -409,6 +409,12 @@ export function memberPool(opts, name, identity) {
409
409
  const pool = opts.pools.get(name);
410
410
  return pool?.members.some((member) => isMember(member, identity)) ? pool : undefined;
411
411
  }
412
+ /** Human memberships, matched only against the resolved namespaced principal. */
413
+ export function personPools(opts, identity) {
414
+ if (identity.agent !== undefined)
415
+ return [];
416
+ return [...opts.pools].filter(([, pool]) => pool.members.some((member) => isMember(member, identity)));
417
+ }
412
418
  /** The declared pool `/agent/<name>` serves to this agent, or undefined for the same 404. */
413
419
  export function agentPool(opts, name, identity) {
414
420
  if (identity.agent === undefined)
@@ -553,7 +553,7 @@ function memberRead(context, authz, pools) {
553
553
  return Effect.gen(function* () {
554
554
  const allIds = opts.registry.listConnectors().map((c) => c.id);
555
555
  const ids = new Set(allIds.filter((id) => pools.some(([, pool]) => servesConnector(pool, id))));
556
- const registry = opts.registry.scoped(scopeFor(authz, opts.config.auth.length > 0, [...ids]));
556
+ const registry = opts.registry.scoped(scopeFor(authz, opts.config.auth.length > 0, [...ids], pools.length === 1 ? pools[0][0] : undefined));
557
557
  const selected = context.url.searchParams.get("connector");
558
558
  const visible = registry
559
559
  .listConnectors()
@@ -571,7 +571,7 @@ function memberRead(context, authz, pools) {
571
571
  root: null,
572
572
  pools: pools.map(([name, pool]) => ({
573
573
  name,
574
- humanPath: `/mcp/${name}`,
574
+ humanPath: pools.length === 1 ? "/mcp" : `/mcp/${name}`,
575
575
  agentPath: `/agent/${name}`,
576
576
  readOnly: pool.readOnly,
577
577
  surface: pool.native ? "native" : "meta",