@zackbart/connecta 0.24.1 → 0.24.2

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,34 @@
2
2
 
3
3
  All notable changes to this package are documented here.
4
4
 
5
+ ## 0.24.2 — 2026-09-16
6
+
7
+ `connectorAccess` can now grant individual tools, and a deployment can declare
8
+ named pools served at `/mcp/<pool>`. Nothing changes for a deployment that
9
+ returns `"all"` or connector ids and declares no pools.
10
+
11
+ ### Added
12
+
13
+ - **Named tool pools at `/mcp/<pool>`.** `ConnectaConfig.pools` declares a
14
+ slice of connector ids and exact `connector.tool` addresses plus a `grant`
15
+ predicate over the authenticated identity, denied by default. The endpoint
16
+ serves the pool intersected with the identity's `connectorAccess`, so it can
17
+ only narrow. An undeclared name, a refusing grant, and a throwing grant are
18
+ one identical 404. Misdeclared pools refuse to boot. Clerk's 401 challenge
19
+ and protected-resource metadata follow the pool path so OAuth discovery
20
+ matches the URL the client used. Ethos records the decision.
21
+
22
+ - **Tool-level grants in `identity.connectorAccess`.** Entries may be a
23
+ connector id (every tool) or an exact `connector.tool` address (that tool
24
+ only); grants are additive. The scoped registry view filters below the
25
+ catalog service, so `search_tools`, `describe_tools`, `call_tool`,
26
+ `call_destructive_tool`, a program's `connecta.search` and `connecta.call`,
27
+ and the connection UI all see the same list, and an ungranted tool fails as
28
+ `unknown_tool` exactly like an absent one. There is no wildcard: a remote
29
+ catalog that drifts cannot widen a grant. An address the catalog lacks is
30
+ unreachable and warned once per isolate. An unparseable entry refuses the
31
+ request with 403 rather than failing open.
32
+
5
33
  ## 0.24.1 — 2026-09-08
6
34
 
7
35
  ### Added
@@ -27,10 +27,5 @@ export interface ClerkAuthOptions {
27
27
  /** Optional hosted Account Portal sign-up URL for operator pages. Absolute https only. */
28
28
  signUpUrl?: string;
29
29
  }
30
- /**
31
- * Clerk inbound auth.
32
- *
33
- * `allowedDomains` and `gate` decide who is admitted; both must pass.
34
- */
35
30
  export declare function clerkAuth(opts: ClerkAuthOptions): InboundAuth;
36
31
  export {};
@@ -207,6 +207,11 @@ function emailDomain(email) {
207
207
  *
208
208
  * `allowedDomains` and `gate` decide who is admitted; both must pass.
209
209
  */
210
+ /** `"/<pool>"` for a pool endpoint path, null for `/mcp` and anything else. */
211
+ function mcpPoolSuffix(pathname) {
212
+ const match = /^\/mcp(\/[a-z0-9_-]+)$/.exec(pathname);
213
+ return match ? match[1] : null;
214
+ }
210
215
  export function clerkAuth(opts) {
211
216
  assertNoRetiredToolkitOptions("clerkAuth", opts);
212
217
  // Before the Clerk client, so a malformed key fails as a connecta
@@ -284,9 +289,13 @@ export function clerkAuth(opts) {
284
289
  pendingActivityLabels.set(userId, lookup);
285
290
  return lookup;
286
291
  };
287
- const unauthorized = (baseUrl, tokenPresent) => {
292
+ const unauthorized = (baseUrl, tokenPresent, request) => {
288
293
  const error = tokenPresent ? `error="invalid_token", ` : "";
289
- const meta = `${resolveBase(baseUrl)}/.well-known/oauth-protected-resource`;
294
+ // A pool endpoint is its own protected resource: the challenge names the
295
+ // metadata document whose `resource` matches the URL the client used, or
296
+ // RFC 9728 tells it to reject the mismatch.
297
+ const pool = mcpPoolSuffix(new URL(request.url).pathname);
298
+ const meta = `${resolveBase(baseUrl)}/.well-known/oauth-protected-resource${pool ? `/mcp${pool}` : ""}`;
290
299
  return new Response(JSON.stringify({ error: "unauthorized" }), {
291
300
  status: 401,
292
301
  headers: {
@@ -384,10 +393,14 @@ export function clerkAuth(opts) {
384
393
  return new Response(null, { status: 204, headers: CORS_HEADERS });
385
394
  }
386
395
  const base = resolveBase(baseUrl);
396
+ const pool = pathname.startsWith("/.well-known/oauth-protected-resource/mcp/")
397
+ ? mcpPoolSuffix(pathname.slice("/.well-known/oauth-protected-resource".length))
398
+ : null;
387
399
  if (pathname === "/.well-known/oauth-protected-resource" ||
388
- pathname === "/.well-known/oauth-protected-resource/mcp") {
400
+ pathname === "/.well-known/oauth-protected-resource/mcp" ||
401
+ pool) {
389
402
  return Response.json({
390
- resource: `${base}/mcp`,
403
+ resource: `${base}/mcp${pool ?? ""}`,
391
404
  authorization_servers: [frontendApiUrl],
392
405
  bearer_methods_supported: ["header"],
393
406
  scopes_supported: scopes,
@@ -430,7 +443,7 @@ export function clerkAuth(opts) {
430
443
  ` tokenShape=${tokenShape(request)}`);
431
444
  return {
432
445
  ok: false,
433
- response: unauthorized(baseUrl, tokenPresent),
446
+ response: unauthorized(baseUrl, tokenPresent, request),
434
447
  };
435
448
  }
436
449
  // Session JWTs carry `azp` (the origin they were minted for); pin it
@@ -444,7 +457,7 @@ export function clerkAuth(opts) {
444
457
  console.warn(`[connecta] session token azp mismatch: azp=${azp} expected=${origin}`);
445
458
  return {
446
459
  ok: false,
447
- response: unauthorized(baseUrl, tokenPresent),
460
+ response: unauthorized(baseUrl, tokenPresent, request),
448
461
  };
449
462
  }
450
463
  }
@@ -452,10 +465,10 @@ export function clerkAuth(opts) {
452
465
  }
453
466
  catch (error) {
454
467
  console.warn(`[connecta] clerk authenticateRequest threw: ${error instanceof Error ? error.message : String(error)} tokenShape=${tokenShape(request)}`);
455
- return { ok: false, response: unauthorized(baseUrl, true) };
468
+ return { ok: false, response: unauthorized(baseUrl, true, request) };
456
469
  }
457
470
  if (!userId) {
458
- return { ok: false, response: unauthorized(baseUrl, true) };
471
+ return { ok: false, response: unauthorized(baseUrl, true, request) };
459
472
  }
460
473
  if (!(await checkGate(userId))) {
461
474
  return { ok: false, response: forbidden() };
@@ -0,0 +1,32 @@
1
+ import type { ToolAccess } from "./registry.js";
2
+ import type { AuthenticatedIdentity } from "./types.js";
3
+ /** A declared pool after construction-time validation. */
4
+ export interface ResolvedPool {
5
+ access: ConnectorAccess;
6
+ grant(identity: Readonly<AuthenticatedIdentity>): boolean | Promise<boolean>;
7
+ }
8
+ export declare const POOL_NAME_RE: RegExp;
9
+ /**
10
+ * One derived view: which connectors, and for connectors granted by address
11
+ * only, which tools. A connector absent from `toolAccess` is visible whole.
12
+ */
13
+ export interface ConnectorAccess {
14
+ connectorIds: "all" | readonly string[];
15
+ toolAccess?: ToolAccess;
16
+ }
17
+ /**
18
+ * Normalize a grant list. A bare connector id grants every tool on that
19
+ * connector; a `connector.tool` address grants one tool. Grants are additive,
20
+ * so a bare id beside addresses for the same connector means the whole
21
+ * connector. Anything else — an unknown shape, an empty tool name, a
22
+ * non-string — throws, and the caller decides whether that is a construction
23
+ * failure or a 403: a grant that cannot be parsed must never fail open.
24
+ */
25
+ export declare function parseConnectorAccess(value: unknown): ConnectorAccess;
26
+ /**
27
+ * The view a pool endpoint serves: the pool's grants, never wider than the
28
+ * identity's own. A connector or tool outside either side is gone; a
29
+ * connector whose tool intersection is empty is gone too, so the pool can
30
+ * only narrow what the identity resolver already allowed.
31
+ */
32
+ export declare function intersectAccess(ceiling: ConnectorAccess, pool: ConnectorAccess): ConnectorAccess;
@@ -0,0 +1,79 @@
1
+ export const POOL_NAME_RE = /^[a-z0-9_-]+$/;
2
+ const CONNECTOR_ID_RE = /^[a-z0-9_-]+$/;
3
+ // MCP does not restrict tool names, and remote servers ship spaced and
4
+ // non-ASCII ones. Only control characters are refused, so a grant for a
5
+ // legitimately named tool cannot 403 the whole identity at request time.
6
+ const TOOL_ADDRESS_RE = /^[a-z0-9_-]+\..{1,256}$/su;
7
+ const hasControlCharacter = (value) => [...value].some((ch) => {
8
+ const code = ch.codePointAt(0);
9
+ return code < 0x20 || code === 0x7f;
10
+ });
11
+ /**
12
+ * Normalize a grant list. A bare connector id grants every tool on that
13
+ * connector; a `connector.tool` address grants one tool. Grants are additive,
14
+ * so a bare id beside addresses for the same connector means the whole
15
+ * connector. Anything else — an unknown shape, an empty tool name, a
16
+ * non-string — throws, and the caller decides whether that is a construction
17
+ * failure or a 403: a grant that cannot be parsed must never fail open.
18
+ */
19
+ export function parseConnectorAccess(value) {
20
+ if (value === "all")
21
+ return { connectorIds: "all" };
22
+ if (!Array.isArray(value))
23
+ throw new Error("invalid connector permission");
24
+ const whole = new Set();
25
+ const partial = new Map();
26
+ for (const entry of value) {
27
+ if (typeof entry !== "string")
28
+ throw new Error("invalid connector permission");
29
+ if (CONNECTOR_ID_RE.test(entry)) {
30
+ whole.add(entry);
31
+ continue;
32
+ }
33
+ if (!TOOL_ADDRESS_RE.test(entry) || hasControlCharacter(entry))
34
+ throw new Error("invalid connector permission");
35
+ const dot = entry.indexOf(".");
36
+ const connectorId = entry.slice(0, dot);
37
+ const tools = partial.get(connectorId) ?? new Set();
38
+ tools.add(entry.slice(dot + 1));
39
+ partial.set(connectorId, tools);
40
+ }
41
+ for (const id of whole)
42
+ partial.delete(id);
43
+ const connectorIds = [...new Set([...whole, ...partial.keys()])];
44
+ return partial.size > 0
45
+ ? { connectorIds, toolAccess: partial }
46
+ : { connectorIds };
47
+ }
48
+ /**
49
+ * The view a pool endpoint serves: the pool's grants, never wider than the
50
+ * identity's own. A connector or tool outside either side is gone; a
51
+ * connector whose tool intersection is empty is gone too, so the pool can
52
+ * only narrow what the identity resolver already allowed.
53
+ */
54
+ export function intersectAccess(ceiling, pool) {
55
+ if (pool.connectorIds === "all")
56
+ return ceiling;
57
+ const allowedIds = ceiling.connectorIds === "all"
58
+ ? null
59
+ : new Set(ceiling.connectorIds);
60
+ const connectorIds = [];
61
+ const toolAccess = new Map();
62
+ for (const id of pool.connectorIds) {
63
+ if (allowedIds && !allowedIds.has(id))
64
+ continue;
65
+ const fromPool = pool.toolAccess?.get(id);
66
+ const fromCeiling = ceiling.toolAccess?.get(id);
67
+ if (fromPool && fromCeiling) {
68
+ const both = new Set([...fromPool].filter((name) => fromCeiling.has(name)));
69
+ if (both.size === 0)
70
+ continue;
71
+ toolAccess.set(id, both);
72
+ }
73
+ else if (fromPool ?? fromCeiling) {
74
+ toolAccess.set(id, (fromPool ?? fromCeiling));
75
+ }
76
+ connectorIds.push(id);
77
+ }
78
+ return toolAccess.size > 0 ? { connectorIds, toolAccess } : { connectorIds };
79
+ }
package/dist/index.d.ts CHANGED
@@ -105,7 +105,12 @@ export interface ConnectaAdmissionConfig {
105
105
  /** Config-owned identity rules for one deployment and tenant. */
106
106
  export type ConnectorPermission = "all" | "none" | readonly string[];
107
107
  export interface ConnectaIdentityConfig {
108
- /** Connector ids this admitted identity may discover and call. */
108
+ /**
109
+ * What this admitted identity may discover and call: `"all"`, or a list
110
+ * whose entries are connector ids (the whole connector) and `connector.tool`
111
+ * addresses (that tool only). Grants are additive. An address naming a tool
112
+ * the catalog lacks is unreachable and warned once, never widened.
113
+ */
109
114
  connectorAccess?(identity: Readonly<AuthenticatedIdentity>): "all" | readonly string[] | Promise<"all" | readonly string[]>;
110
115
  /** Global payload-free activity reads. Defaults to interactive humans. */
111
116
  activityAccess?(principal: Readonly<IdentityReference>): boolean | Promise<boolean>;
@@ -114,12 +119,29 @@ export interface ConnectaIdentityConfig {
114
119
  /** Connecting or changing the caller's personal account. Defaults to none. */
115
120
  personalConnection?(identity: Readonly<AuthenticatedIdentity>): ConnectorPermission | Promise<ConnectorPermission>;
116
121
  }
122
+ /**
123
+ * A named tool pool served at `/mcp/<name>`. The pool is the slice a client
124
+ * pointed at that endpoint may see; the identity's own `connectorAccess`
125
+ * remains its ceiling and the pool can only narrow it.
126
+ */
127
+ export interface ConnectaPoolConfig {
128
+ /** Connector ids and exact `connector.tool` addresses in this pool. */
129
+ tools: readonly string[];
130
+ /**
131
+ * Whether this admitted identity may open the pool. Denied by default:
132
+ * a pool with no grant serves nobody. A false return, a throw, and an
133
+ * undeclared pool name are the same 404.
134
+ */
135
+ grant?(identity: Readonly<AuthenticatedIdentity>): boolean | Promise<boolean>;
136
+ }
117
137
  export interface ConnectaConfig {
118
138
  connectors: Connector[];
119
139
  /** Inbound auth adapters. Includes bearerToken(...); omit for open (dev). */
120
140
  auth?: InboundAuth | InboundAuth[];
121
141
  /** Code-derived connection visibility and independent management permissions. */
122
142
  identity?: ConnectaIdentityConfig;
143
+ /** Named tool pools, each served at `/mcp/<name>` to identities its grant admits. */
144
+ pools?: Record<string, ConnectaPoolConfig>;
123
145
  /** KVStorage impl. Defaults to memoryStorage(). */
124
146
  storage?: KVStorage;
125
147
  /**
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { credentialTestRule, describeCredentialTestMismatch, } from "./credential-rules.js";
2
2
  import { Registry } from "./registry.js";
3
+ import { parseConnectorAccess, POOL_NAME_RE } from "./connector-access.js";
3
4
  import { createFetchHandler } from "./server.js";
4
5
  import { droppedBrandingUrls, droppedUiAuthUrls } from "./branding.js";
5
6
  import { memoryStorage } from "./storage/memory.js";
@@ -56,6 +57,7 @@ const CONFIG_SCHEMA = {
56
57
  credentialAdministration: null,
57
58
  personalConnection: null,
58
59
  },
60
+ pools: null,
59
61
  storage: null,
60
62
  publicUrl: null,
61
63
  activity: null,
@@ -145,6 +147,67 @@ function assertKnownConfig(config) {
145
147
  throw new Error("ConnectaConfig.activity must be created with activityHistory(...)");
146
148
  }
147
149
  }
150
+ /**
151
+ * Validate declared pools against the connector set. Everything checkable at
152
+ * construction throws here: a malformed name, an unparseable grant, an
153
+ * unknown connector id, a tool address on an `api()` connector whose static
154
+ * catalog lacks it. Remote catalogs load lazily, so their addresses are
155
+ * checked at catalog load instead and stay unreachable until they match.
156
+ */
157
+ function resolvePools(pools, registry) {
158
+ const resolved = new Map();
159
+ if (!pools)
160
+ return resolved;
161
+ if (typeof pools !== "object" || Array.isArray(pools)) {
162
+ throw new Error("ConnectaConfig.pools must be an object keyed by pool name");
163
+ }
164
+ for (const [name, pool] of Object.entries(pools)) {
165
+ if (!POOL_NAME_RE.test(name)) {
166
+ throw new Error(`ConnectaConfig.pools: pool name "${name}" must match [a-z0-9_-]+`);
167
+ }
168
+ if (!pool || typeof pool !== "object" || !Array.isArray(pool.tools)) {
169
+ throw new Error(`ConnectaConfig.pools.${name}: tools must be an array of connector ids or connector.tool addresses`);
170
+ }
171
+ if (pool.grant !== undefined && typeof pool.grant !== "function") {
172
+ throw new Error(`ConnectaConfig.pools.${name}: grant must be a function`);
173
+ }
174
+ // A misspelled `grant` would otherwise boot as a deny-all pool with only a
175
+ // per-request log line to say so; that is fail-closed, but the rule here
176
+ // is that structural mistakes refuse to boot.
177
+ for (const key of Object.keys(pool)) {
178
+ if (key !== "tools" && key !== "grant") {
179
+ throw new Error(`ConnectaConfig.pools.${name}: unknown option "${key}"`);
180
+ }
181
+ }
182
+ let access;
183
+ try {
184
+ access = parseConnectorAccess(pool.tools);
185
+ }
186
+ catch {
187
+ throw new Error(`ConnectaConfig.pools.${name}: tools must be connector ids or connector.tool addresses`);
188
+ }
189
+ if (access.connectorIds === "all" || access.connectorIds.length === 0) {
190
+ throw new Error(`ConnectaConfig.pools.${name}: a pool must name at least one connector or tool`);
191
+ }
192
+ for (const id of access.connectorIds) {
193
+ const connector = registry.getConnector(id);
194
+ if (!connector) {
195
+ throw new Error(`ConnectaConfig.pools.${name}: unknown connector "${id}"`);
196
+ }
197
+ const granted = access.toolAccess?.get(id);
198
+ if (!granted || !connector.staticTools)
199
+ continue;
200
+ const known = new Set(connector.staticTools.map((tool) => tool.name));
201
+ for (const tool of granted) {
202
+ if (!known.has(tool)) {
203
+ throw new Error(`ConnectaConfig.pools.${name}: connector "${id}" has no tool "${tool}"`);
204
+ }
205
+ }
206
+ }
207
+ resolved.set(name, { access, grant: pool.grant ?? (() => false) });
208
+ }
209
+ return resolved;
210
+ }
148
211
  /**
149
212
  * One-time construction warnings for deployment shapes that run fine but are
150
213
  * usually unintended. Warning-only — never throws and never changes behavior;
@@ -269,6 +332,7 @@ export function createConnecta(config) {
269
332
  maxResultBytes: config.calls?.maxResultBytes,
270
333
  });
271
334
  const inboundAuth = configuredAuth;
335
+ const pools = resolvePools(config.pools, registry);
272
336
  warnInsecureConfig(config, inboundAuth, logger);
273
337
  const requestAdmission = admissionController(config.admission?.requests, REQUEST_ADMISSION_DEFAULTS);
274
338
  const configuredCodeAdmission = admissionController(config.admission?.code, CODE_ADMISSION_DEFAULTS);
@@ -290,6 +354,7 @@ export function createConnecta(config) {
290
354
  registry,
291
355
  auth: inboundAuth,
292
356
  identity: config.identity,
357
+ pools,
293
358
  publicUrl: config.publicUrl,
294
359
  serverInfo,
295
360
  logger,
@@ -114,8 +114,15 @@ export interface RegistryView {
114
114
  /** Bind returned OAuth state to this view's personal storage partition. */
115
115
  bindOAuthHandoff(id: string, authorizationUrl: string): Promise<void>;
116
116
  }
117
+ /**
118
+ * Connector id → the only tool names this view may see on it. A connector
119
+ * absent from the map is visible whole. Derived from `connectorAccess`
120
+ * addresses at the auth gate; never from caller input.
121
+ */
122
+ export type ToolAccess = ReadonlyMap<string, ReadonlySet<string>>;
117
123
  export interface RegistryScope {
118
124
  connectorIds: "all" | readonly string[];
125
+ toolAccess?: ToolAccess;
119
126
  subjectKey?: string;
120
127
  principalKey?: string;
121
128
  }
@@ -151,11 +158,20 @@ export declare class Registry implements RegistryView {
151
158
  readonly maxResultBytes: number;
152
159
  private readonly configuredConnectors;
153
160
  private readonly personalRegistries;
161
+ /** `connector.tool` grants that matched nothing, warned once per isolate. */
162
+ private readonly warnedAbsentGrants;
154
163
  constructor(connectors: Connector[], opts: RegistryOptions);
155
164
  personalRegistry(principalKey: string): Registry;
156
165
  /** Build the only connector view an authenticated request receives. */
157
166
  scoped(scope: RegistryScope): RegistryView;
158
167
  scopedStorage(subjectKey: string): KVStorage;
168
+ /**
169
+ * A granted `connector.tool` address the live catalog does not contain is
170
+ * unreachable, which is the fail-closed outcome; this only makes the
171
+ * misconfiguration visible. Remote catalogs load lazily, so construction
172
+ * cannot check it, and a catalog that drifts later cannot widen a grant.
173
+ */
174
+ noteAbsentGrant(connectorId: string, toolName: string): void;
159
175
  private oauthHandoffKey;
160
176
  storeOAuthHandoff(connectorId: string, state: string, principalKey: string): Promise<void>;
161
177
  oauthCallbackView(connectorId: string, state: string | null): Promise<{
package/dist/registry.js CHANGED
@@ -119,6 +119,8 @@ export class Registry {
119
119
  maxResultBytes;
120
120
  configuredConnectors;
121
121
  personalRegistries = new Map();
122
+ /** `connector.tool` grants that matched nothing, warned once per isolate. */
123
+ warnedAbsentGrants = new Set();
122
124
  constructor(connectors, opts) {
123
125
  this.opts = opts;
124
126
  this.configuredConnectors = [...connectors];
@@ -201,6 +203,22 @@ export class Registry {
201
203
  scopedStorage(subjectKey) {
202
204
  return namespaced(this.opts.storage, `subject:${subjectKey}:`);
203
205
  }
206
+ /**
207
+ * A granted `connector.tool` address the live catalog does not contain is
208
+ * unreachable, which is the fail-closed outcome; this only makes the
209
+ * misconfiguration visible. Remote catalogs load lazily, so construction
210
+ * cannot check it, and a catalog that drifts later cannot widen a grant.
211
+ */
212
+ noteAbsentGrant(connectorId, toolName) {
213
+ const key = `${connectorId}.${toolName}`;
214
+ if (this.warnedAbsentGrants.has(key))
215
+ return;
216
+ this.warnedAbsentGrants.add(key);
217
+ // Grant names are operator data but may carry any non-control character;
218
+ // quote them so a line terminator a log reader honours cannot forge a line.
219
+ const quoted = JSON.stringify(key).replace(/[\u2028\u2029]/g, (ch) => `\\u${ch.charCodeAt(0).toString(16)}`);
220
+ this.opts.logger.warn(`connectorAccess grants ${quoted} but connector "${connectorId}" lists no such tool; the grant is unreachable`);
221
+ }
204
222
  oauthHandoffKey(connectorId, stateHash) {
205
223
  return `oauth-handoff:v1:${connectorId}:${stateHash}`;
206
224
  }
@@ -1078,12 +1096,27 @@ class ScopedRegistryView {
1078
1096
  const connector = this.getConnector(parsed.connectorId);
1079
1097
  return connector ? { connector, toolName: parsed.toolName } : null;
1080
1098
  }
1081
- getTools(...args) {
1099
+ async getTools(...args) {
1082
1100
  const registry = this.registryFor(args[0]);
1083
1101
  if (!registry) {
1084
- return Promise.reject(new Error(`Unknown connector "${args[0]}"`));
1102
+ throw new Error(`Unknown connector "${args[0]}"`);
1103
+ }
1104
+ const tools = await registry.getTools(...args);
1105
+ const granted = this.scope.toolAccess?.get(args[0]);
1106
+ if (!granted)
1107
+ return tools;
1108
+ // Every consumer — search, describe, call_tool, and a program's
1109
+ // connecta.call — resolves through this list, so an ungranted tool is
1110
+ // indistinguishable from one the connector never had.
1111
+ const visible = tools.filter((tool) => granted.has(tool.name));
1112
+ if (visible.length < granted.size) {
1113
+ const present = new Set(visible.map((tool) => tool.name));
1114
+ for (const name of granted) {
1115
+ if (!present.has(name))
1116
+ this.root.noteAbsentGrant(args[0], name);
1117
+ }
1085
1118
  }
1086
- return registry.getTools(...args);
1119
+ return visible;
1087
1120
  }
1088
1121
  contextFor(...args) {
1089
1122
  const registry = this.registryFor(args[0]);
@@ -81,6 +81,7 @@ async function handleCredentialRequest(context, connectorId, action) {
81
81
  validateAuthPermissions(authz, opts.registry);
82
82
  registry = opts.registry.scoped({
83
83
  connectorIds: authz.connectorIds,
84
+ ...(authz.toolAccess ? { toolAccess: authz.toolAccess } : {}),
84
85
  ...(authz.subjectKey ? { subjectKey: authz.subjectKey } : {}),
85
86
  ...(authz.principalKey ? { principalKey: authz.principalKey } : {}),
86
87
  });
@@ -2,9 +2,10 @@ import { createMcpHandler, isLegacyRequest, McpServer, WebStandardStreamableHTTP
2
2
  import { registerExecuteTool } from "../execute.js";
3
3
  import { ExecutorAdmissionError, } from "../executor-admission.js";
4
4
  import { registerMetaTools } from "../meta-tools.js";
5
+ import { intersectAccess } from "../connector-access.js";
5
6
  import { instructionsFor } from "../skills.js";
6
7
  import { msg } from "../errors.js";
7
- import { authorize, mayManageConnector, validateAuthPermissions, } from "./shared.js";
8
+ import { authorize, loggableValue, mayManageConnector, validateAuthPermissions, } from "./shared.js";
8
9
  export const MCP_CORS_HEADERS = {
9
10
  "Access-Control-Allow-Origin": "*",
10
11
  "Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
@@ -262,8 +263,10 @@ export function createMcpRoute(opts) {
262
263
  };
263
264
  return async function routeMcp(context) {
264
265
  const { path, request, baseUrl, runtimeContext, } = context;
265
- if (path !== "/mcp")
266
+ const poolPath = /^\/mcp\/([a-z0-9_-]+)$/.exec(path);
267
+ if (path !== "/mcp" && !poolPath)
266
268
  return null;
269
+ const poolName = poolPath?.[1];
267
270
  let admission;
268
271
  try {
269
272
  admission = await opts.requestAdmission.acquire({
@@ -295,11 +298,37 @@ export function createMcpRoute(opts) {
295
298
  if (!authz.ok) {
296
299
  return releaseAdmissionWithResponse(withMcpCors(authz.response), admission, request.signal);
297
300
  }
301
+ // A pool endpoint narrows the identity's own view and nothing else. An
302
+ // undeclared name, a grant that refuses, and a grant that throws are
303
+ // one identical 404 so a credential never enumerates the other pools;
304
+ // the operator log is where the reason lives.
305
+ let access = authz;
306
+ if (poolName !== undefined) {
307
+ const pool = opts.pools?.get(poolName);
308
+ let granted = false;
309
+ let reason = "undeclared";
310
+ if (pool) {
311
+ try {
312
+ granted = (await pool.grant(authz.identity)) === true;
313
+ reason = granted ? "granted" : "refused";
314
+ }
315
+ catch {
316
+ reason = "grant threw";
317
+ }
318
+ }
319
+ if (!pool || !granted) {
320
+ opts.logger.warn(`[connecta] refused /mcp/${poolName} with 404: pool ${reason}` +
321
+ (authz.actor.id ? ` for ${loggableValue(authz.actor.id)}` : ""));
322
+ return releaseAdmissionWithResponse(withMcpCors(new Response("Not Found", { status: 404 })), admission, request.signal);
323
+ }
324
+ access = intersectAccess(authz, pool.access);
325
+ }
298
326
  let scopedRegistry;
299
327
  try {
300
328
  validateAuthPermissions(authz, opts.registry);
301
329
  scopedRegistry = opts.registry.scoped({
302
- connectorIds: authz.connectorIds,
330
+ connectorIds: access.connectorIds,
331
+ ...(access.toolAccess ? { toolAccess: access.toolAccess } : {}),
303
332
  ...(authz.subjectKey ? { subjectKey: authz.subjectKey } : {}),
304
333
  ...(authz.principalKey ? { principalKey: authz.principalKey } : {}),
305
334
  });
@@ -14,6 +14,7 @@ async function handleOAuthManagementRequest(context, connectorId) {
14
14
  validateAuthPermissions(authz, opts.registry);
15
15
  registry = opts.registry.scoped({
16
16
  connectorIds: authz.connectorIds,
17
+ ...(authz.toolAccess ? { toolAccess: authz.toolAccess } : {}),
17
18
  ...(authz.subjectKey ? { subjectKey: authz.subjectKey } : {}),
18
19
  ...(authz.principalKey ? { principalKey: authz.principalKey } : {}),
19
20
  });
@@ -4,7 +4,8 @@ import type { ActivityActor, ActivityReadGate, ActivityStore } from "../activity
4
4
  import type { CredentialVault } from "../credential-contract.js";
5
5
  import type { DeferredWork } from "../connector-scope.js";
6
6
  import type { AdmissionController } from "../executor-admission.js";
7
- import type { Registry } from "../registry.js";
7
+ import type { Registry, ToolAccess } from "../registry.js";
8
+ import type { ResolvedPool } from "../connector-access.js";
8
9
  import type { AuthenticatedIdentity, ConnectaBranding, Executor, InboundAuth, InboundAuthRuntimeContext, Logger } from "../types.js";
9
10
  import type { ConnectorPermission, ConnectaIdentityConfig } from "../index.js";
10
11
  export { msg } from "../errors.js";
@@ -12,6 +13,8 @@ export interface ServerOptions {
12
13
  registry: Registry;
13
14
  auth: InboundAuth[];
14
15
  identity?: ConnectaIdentityConfig | undefined;
16
+ /** Validated named pools served at `/mcp/<name>`; empty when none declared. */
17
+ pools?: ReadonlyMap<string, ResolvedPool> | undefined;
15
18
  publicUrl?: string | undefined;
16
19
  serverInfo: Implementation;
17
20
  logger: Logger;
@@ -78,6 +81,8 @@ export declare function authorize(request: Request, baseUrl: string, auth: Inbou
78
81
  subjectKey?: string;
79
82
  principalKey?: string;
80
83
  connectorIds: "all" | readonly string[];
84
+ /** Per-connector tool allowlist for connectors granted by address only. */
85
+ toolAccess?: ToolAccess;
81
86
  operator: boolean;
82
87
  credentialAdministration: ConnectorPermission;
83
88
  personalConnection: ConnectorPermission;
@@ -1,3 +1,4 @@
1
+ import { parseConnectorAccess } from "../connector-access.js";
1
2
  import { identityStorageKey, validIdentityReference } from "../identity.js";
2
3
  export { msg } from "../errors.js";
3
4
  export function privateJson(body, init = {}) {
@@ -33,11 +34,9 @@ export async function authorize(request, baseUrl, auth, runtimeContext, identity
33
34
  if (auth.length === 0) {
34
35
  const actor = { kind: "anonymous" };
35
36
  const identity = { actor, interactive: false };
36
- let connectorIds = "all";
37
+ let access;
37
38
  try {
38
- connectorIds = identityConfig?.connectorAccess ? await identityConfig.connectorAccess(identity) : "all";
39
- if (connectorIds !== "all" && (!Array.isArray(connectorIds) || !connectorIds.every(id => typeof id === "string" && /^[a-z0-9_-]+$/.test(id))))
40
- throw new Error("invalid connector permission");
39
+ access = parseConnectorAccess(identityConfig?.connectorAccess ? await identityConfig.connectorAccess(identity) : "all");
41
40
  }
42
41
  catch {
43
42
  return {
@@ -45,7 +44,7 @@ export async function authorize(request, baseUrl, auth, runtimeContext, identity
45
44
  response: privateJson({ error: "identity access resolution failed" }, { status: 403 }),
46
45
  };
47
46
  }
48
- return { ok: true, actor, identity, connectorIds, operator: false, credentialAdministration: "none", personalConnection: "none" };
47
+ return { ok: true, actor, identity, ...access, operator: false, credentialAdministration: "none", personalConnection: "none" };
49
48
  }
50
49
  let lastResponse = null;
51
50
  for (const provider of auth) {
@@ -77,21 +76,21 @@ export async function authorize(request, baseUrl, auth, runtimeContext, identity
77
76
  let operator = interactive;
78
77
  let credentialAdministration = "none";
79
78
  let personalConnection = "none";
80
- let connectorIds = "all";
79
+ let access;
81
80
  try {
82
81
  if (identityConfig?.activityAccess) {
83
82
  operator = interactive && principal
84
83
  ? await identityConfig.activityAccess(principal)
85
84
  : false;
86
85
  }
87
- connectorIds = identityConfig?.connectorAccess ? await identityConfig.connectorAccess(identity) : "all";
86
+ access = parseConnectorAccess(identityConfig?.connectorAccess ? await identityConfig.connectorAccess(identity) : "all");
88
87
  if (interactive) {
89
88
  credentialAdministration = identityConfig?.credentialAdministration ? await identityConfig.credentialAdministration(identity) : "none";
90
89
  personalConnection = principal && identityConfig?.personalConnection ? await identityConfig.personalConnection(identity) : "none";
91
90
  }
92
91
  if (typeof operator !== "boolean")
93
92
  throw new Error("invalid activity permission");
94
- for (const permission of [connectorIds, credentialAdministration, personalConnection]) {
93
+ for (const permission of [credentialAdministration, personalConnection]) {
95
94
  if (permission !== "all" && permission !== "none" && (!Array.isArray(permission) || !permission.every(id => typeof id === "string" && /^[a-z0-9_-]+$/.test(id))))
96
95
  throw new Error("invalid identity permission");
97
96
  }
@@ -112,7 +111,7 @@ export async function authorize(request, baseUrl, auth, runtimeContext, identity
112
111
  ...(principal && partitionIdentity
113
112
  ? { principalKey: await identityStorageKey(principal) }
114
113
  : {}),
115
- connectorIds,
114
+ ...access,
116
115
  credentialAdministration,
117
116
  personalConnection,
118
117
  operator,
package/dist/routes/ui.js CHANGED
@@ -111,6 +111,7 @@ export async function routeUi(context) {
111
111
  validateAuthPermissions(authz, opts.registry);
112
112
  registry = opts.registry.scoped({
113
113
  connectorIds: authz.connectorIds,
114
+ ...(authz.toolAccess ? { toolAccess: authz.toolAccess } : {}),
114
115
  ...(authz.subjectKey ? { subjectKey: authz.subjectKey } : {}),
115
116
  ...(authz.principalKey ? { principalKey: authz.principalKey } : {}),
116
117
  });
@@ -133,7 +134,7 @@ export async function routeUi(context) {
133
134
  const connector = registry.getConnector(detail[1]);
134
135
  if (!connector)
135
136
  return privateJson({ error: "unknown connector" }, { status: 404 });
136
- const one = opts.registry.scoped({ connectorIds: [connector.id], ...(authz.subjectKey ? { subjectKey: authz.subjectKey } : {}), ...(authz.principalKey ? { principalKey: authz.principalKey } : {}) });
137
+ const one = opts.registry.scoped({ connectorIds: [connector.id], ...(authz.toolAccess ? { toolAccess: authz.toolAccess } : {}), ...(authz.subjectKey ? { subjectKey: authz.subjectKey } : {}), ...(authz.principalKey ? { principalKey: authz.principalKey } : {}) });
137
138
  const data = await buildUiData(one, baseUrl, opts.serverInfo, opts.credentialVault, activityEnabled, credentialManagement, defer, false, 1, authz.principalKey, { mayManage, timeoutMs: opts.probeTimeoutMs ?? 30_000, signal: request.signal });
138
139
  return privateJson({ ...data.connectors[0], permissions: permissions(connector) });
139
140
  }
package/dist/version.d.ts CHANGED
@@ -4,4 +4,4 @@
4
4
  * a bump that forgets this file fails the build rather than shipping a stale
5
5
  * version to `/health` and to downstream MCP handshakes.
6
6
  */
7
- export declare const CONNECTA_VERSION = "0.24.1";
7
+ export declare const CONNECTA_VERSION = "0.24.2";
package/dist/version.js CHANGED
@@ -4,4 +4,4 @@
4
4
  * a bump that forgets this file fails the build rather than shipping a stale
5
5
  * version to `/health` and to downstream MCP handshakes.
6
6
  */
7
- export const CONNECTA_VERSION = "0.24.1";
7
+ export const CONNECTA_VERSION = "0.24.2";
@@ -71,7 +71,7 @@ read top to bottom.
71
71
  | 3 | `/.well-known/*` | Auth metadata, or 404. |
72
72
  | 4 | `/health` | Open payload-free health, executor, admission, and deployment metadata; reserved routes reflect installed modules. |
73
73
  | 5 | `/oauth/callback/<connectorId>` | Core downstream OAuth completion, state verification and personal ownership checks; independent of UI. |
74
- | 6 | `/mcp` | Admission before auth, then a request-local MCP server. |
74
+ | 6 | `/mcp`, `/mcp/<pool>` | Admission before auth, then a request-local MCP server. A pool path serves the declared pool intersected with the identity's own view; an undeclared name, a refusing grant, and a throwing grant are one identical 404. |
75
75
  | 7 | Other paths | 404. Custom HTTP routes belong to the deployment. |
76
76
 
77
77
 
@@ -80,7 +80,7 @@ policy, HSTS on HTTPS, while the UI module adds a nonce-based script CSP and fra
80
80
  and the exact refusal bodies; it exists because the ordering is invisible in
81
81
  any one file and a reordering reads like a harmless refactor.
82
82
 
83
- `/mcp` itself is five steps, in this order and for these reasons:
83
+ `/mcp` itself is six steps, in this order and for these reasons:
84
84
 
85
85
  1. **Admit.** One permit from the deployment-wide FIFO pool, taken before auth
86
86
  so an unauthenticated flood costs a permit rather than a Clerk lookup
@@ -92,13 +92,18 @@ any one file and a reordering reads like a harmless refactor.
92
92
  only, and it warns at construction.
93
93
  3. **Derive the registry view.** Auth supplies a namespaced subject and, for a
94
94
  human, a principal. `identity.connectorAccess` selects declared connector
95
- ids. Personal connectors use the principal partition; result paging uses
95
+ ids and, for a narrower slice, exact `connector.tool` addresses; the
96
+ scoped view filters every catalog read through them. Personal connectors use the principal partition; result paging uses
96
97
  the subject partition. No caller parameter selects either.
97
- 4. **Refuse `?toolkit=`.** Caller-selected toolkits were removed ([#178](https://github.com/zackbart/connecta/issues/178))
98
+ 4. **Narrow to the pool.** On `/mcp/<pool>`, look the name up in the
99
+ declared pools and run its grant against the authenticated identity. The
100
+ view becomes the pool intersected with the identity's `connectorAccess`;
101
+ a pool can never widen it. Anything else is a 404 that names no pool.
102
+ 5. **Refuse `?toolkit=`.** Caller-selected toolkits were removed ([#178](https://github.com/zackbart/connecta/issues/178))
98
103
  but the URLs naming them were handed out, so the parameter is a 404 rather
99
104
  than silently serving the full registry. Retiring a scoping boundary into
100
105
  fail-open is the one outcome worse than the 404.
101
- 5. **Serve.** A fresh `McpServer` per request, the seven meta-tools registered
106
+ 6. **Serve.** A fresh `McpServer` per request, the seven meta-tools registered
102
107
  against the registry and the response
103
108
  handed back.
104
109
 
@@ -13,10 +13,77 @@ such as `get_result` pages. The principal is the human owner of personal
13
13
  connector auth. An interactive Clerk or Access user supplies all three. A
14
14
  Cloudflare service identity has an actor and subject but no principal.
15
15
 
16
- `identity.connectorAccess` returns `"all"` or declared connector ids. It governs
17
- discovery and use, and defaults to all connectors. Visibility alone grants no
18
- authentication-management permission. Two independent resolvers return
19
- `"all"`, `"none"`, or declared connector ids:
16
+ `identity.connectorAccess` returns `"all"` or a list of grants. A grant is a
17
+ declared connector id, which opens every tool on it, or a `connector.tool`
18
+ address, which opens that tool alone. Grants are additive, so a bare id beside
19
+ addresses for the same connector means the whole connector. It governs
20
+ discovery and use, and defaults to all connectors.
21
+
22
+ Tool grants are enforced in the scoped registry view, below the catalog
23
+ service, so `search_tools`, `describe_tools`, both call tools, a program's
24
+ `connecta.search` and `connecta.call`, and the connection UI all read the same
25
+ filtered list. An ungranted tool fails exactly like one the connector never
26
+ had: `unknown_tool`, with no hint that it exists. That is the whole security
27
+ claim, and it lives in one place on purpose. There is no separate endpoint per
28
+ tool set; an identity that should see a narrower slice is a branch in this
29
+ resolver, and a bot that needs its own slice is its own bearer subject.
30
+
31
+ ## Pools
32
+
33
+ A pool is a named slice of the deployment served at its own endpoint,
34
+ `/mcp/<pool>`, for the case where one identity needs different capability
35
+ sets on different clients: a support agent that sees three Notion tools and
36
+ Linear, a calendar bot that sees one tool, both over the same credentials and
37
+ catalog cache.
38
+
39
+ ```ts
40
+ createConnecta({
41
+ pools: {
42
+ support: {
43
+ tools: ["linear", "notion.search_pages", "notion.fetch_page"],
44
+ grant: ({ principal }) => supportTeam.has(principal?.id ?? ""),
45
+ },
46
+ calendar_bot: {
47
+ tools: ["calendar.create_event"],
48
+ grant: ({ actor }) => actor.id === "calendar-bot",
49
+ },
50
+ },
51
+ identity: { connectorAccess },
52
+ connectors,
53
+ executor,
54
+ });
55
+ ```
56
+
57
+ The rules, each of which is a test:
58
+
59
+ - **A pool narrows; it never widens.** The view on `/mcp/<pool>` is the pool
60
+ intersected with the identity's own `connectorAccess`. Plain `/mcp` is
61
+ unchanged. The security boundary is still the resolver; the pool decides
62
+ which part of it a given client sees.
63
+ - **Grant defaults to deny.** A pool with no `grant` serves nobody. Only a
64
+ literal `true` admits; any other return, a throw, and an undeclared pool
65
+ name produce one 404 identical in status, body, and headers, so a
66
+ credential does not enumerate the other pools by response. Keep grants
67
+ pure and fast: a grant that does I/O is the one thing that could make a
68
+ declared pool distinguishable from an undeclared one by timing. The
69
+ operator log carries the reason.
70
+ - **Structural mistakes throw at construction.** A malformed name, an
71
+ unknown connector, an empty pool, and a `connector.tool` address an
72
+ `api()` connector's static catalog lacks all refuse to boot. Remote
73
+ catalogs load lazily, so their addresses are checked at load and stay
74
+ unreachable until they match.
75
+ - **OAuth discovery follows the path.** On Clerk, the 401 challenge for
76
+ `/mcp/<pool>` names `/.well-known/oauth-protected-resource/mcp/<pool>`,
77
+ whose `resource` is the pool URL, so RFC 9728 clients see a match.
78
+ Cloudflare Managed OAuth is application-level and needs nothing.
79
+
80
+ A `connector.tool` address the live catalog does not contain is unreachable
81
+ and warned once per isolate. Remote catalogs load lazily, so construction
82
+ cannot check it, and a catalog that drifts later can never widen a grant
83
+ because there is no wildcard: every tool grant is an exact name.
84
+
85
+ Visibility alone grants no authentication-management permission. Two
86
+ independent resolvers return `"all"`, `"none"`, or declared connector ids:
20
87
 
21
88
  - `credentialAdministration` allows an interactive human to manage shared
22
89
  credentials and shared OAuth grants.
@@ -39,8 +106,12 @@ administrator role or token-management authority.
39
106
  createConnecta({
40
107
  auth: cloudflareAccessAuth(),
41
108
  identity: {
42
- connectorAccess: ({ principal }) =>
43
- principal?.id === "owner-id" ? "all" : ["shared_docs", "personal_linear"],
109
+ connectorAccess: ({ principal, actor }) =>
110
+ principal?.id === "owner-id"
111
+ ? "all"
112
+ : actor.id === "calendar-bot"
113
+ ? ["calendar.create_event"]
114
+ : ["shared_docs", "personal_linear", "notion.search_pages"],
44
115
  credentialAdministration: ({ principal }) =>
45
116
  principal?.id === "owner-id" ? "all" : "none",
46
117
  personalConnection: () => ["personal_linear"],
@@ -84,6 +84,7 @@ optional.
84
84
  | `executor` | — (required) | the sandbox `execute_code` runs in ([code mode](./code-mode.md#what-an-executor-must-implement)) |
85
85
  | `auth?` | none ⇒ open (dev only) | one `InboundAuth` or an array; bearer providers are checked before interactive providers ([inbound auth](./auth.md)) |
86
86
  | `identity?` | all visible; auth management denied; interactive activity reads | `{ connectorAccess?, credentialAdministration?, personalConnection?, activityAccess? }` derives separate use and management permissions ([identity](./auth.md#principals-visibility-and-operators)) |
87
+ | `pools?` | none | `{ <name>: { tools, grant? } }` named slices served at `/mcp/<name>`, each intersected with the identity view and denied unless `grant` admits ([pools](./auth.md#pools)) |
87
88
  | `storage?` | `memoryStorage()` | connector state, catalogs, and result paging; pass storage explicitly to the optional vault ([storage](./storage-and-credentials.md)) |
88
89
  | `publicUrl?` | per-request origin | public base URL; an HTTPS value also redirects inbound HTTP |
89
90
  | `logger?` | `console`, prefixed `[connecta]` | `{ debug, info, warn, error }`, or `"silent"` to suppress diagnostic output; independent of activity history |
@@ -251,7 +252,7 @@ in.
251
252
  | `executor-admission.test.ts` | the portable bounded FIFO both pools use: active and queue ceilings, stable retryable overload, queue timeout, cancellation removal, idempotent release, shutdown |
252
253
  | `guarded-fetch.test.ts` | the guarded transport — construction, request building, destination confinement, and response handling |
253
254
  | `guest-api-contract.test.ts` | the shared guest contract on the Dynamic Worker, including caught call, typed inline describe recovery, discovery, utility, parallel-call, and budget failure codes; plus the real authority boundary — local `data:` fetch, denied egress, unresolved DNS, empty environment paths, unavailable filesystem/HTTP builtins, and present runtime globals |
254
- | `identity-scope.test.ts` | identity-derived connector visibility, personal credential isolation, separate shared-auth and personal-auth management permissions, and personal OAuth callback ownership |
255
+ | `identity-scope.test.ts` | identity-derived connector visibility, named pools at `/mcp/<pool>` (grant-gated, intersected with the identity ceiling, identical 404 for undeclared, refused, and throwing grants, construction-time refusals), exact `connector.tool` grants enforced identically across discovery, direct calls, the program host bridge, and the connection UI, fail-closed grant parsing, the once-per-isolate absent-grant warning, personal credential isolation, separate shared-auth and personal-auth management permissions, and personal OAuth callback ownership |
255
256
  | `linear-provider.test.ts` | the Linear proxy's construction, guide, plan-aware catalog superset, and current workspace, template, and issue-sharing classifications |
256
257
  | `meta-tools-call.test.ts` | registry-backed calls: structured errors, truncation and `get_result`, per-connector result bounds, JSON representation failures, MCP content bounds, and offset alignment |
257
258
  | `meta-tools-search.test.ts` | registry-backed discovery: bounded search with page and address maxima, compact and JSON schemas with constraints, typed describe recovery and suggestions, and structured-result compatibility |
@@ -117,7 +117,7 @@ exist so far:
117
117
  | --- | --- | --- |
118
118
  | **pre-template** | before 0.10.2 | no `connecta init` existed; hand-written, or copied from the retired `examples/node` |
119
119
  | **A** | 0.10.2 – 0.15.1 | `.env.example`, `.gitignore`, `AGENTS.md`, `CLAUDE.md`, `README.md`, `package.json`, `src/index.ts`, `tsconfig.json` |
120
- | **B** | 0.16.0 – 0.24.1 | adds `.dockerignore`, `Dockerfile`, `docker-compose.yml`, and `src/file-activity.ts`; `src/index.ts` grows the four commented operator blocks; `.env.example` ships `CONNECTA_TOKEN=` empty |
120
+ | **B** | 0.16.0 – 0.24.2 | adds `.dockerignore`, `Dockerfile`, `docker-compose.yml`, and `src/file-activity.ts`; `src/index.ts` grows the four commented operator blocks; `.env.example` ships `CONNECTA_TOKEN=` empty |
121
121
 
122
122
  Generation A is a decade in template years and identifying it precisely does
123
123
  not matter, because you are about to reconstruct it exactly rather than guess
@@ -190,7 +190,7 @@ Generate the *current* template beside the base you already made, into the same
190
190
  `$SCRATCH`:
191
191
 
192
192
  ```sh
193
- (cd "$SCRATCH" && npx @zackbart/connecta@0.24.1 init current)
193
+ (cd "$SCRATCH" && npx @zackbart/connecta@0.24.2 init current)
194
194
  ```
195
195
 
196
196
  You now have a three-way merge with a real base: `$SCRATCH/base` is what this
@@ -246,7 +246,7 @@ A deployment older than 0.10.2 has no base to diff against. Do not try to
246
246
  manufacture one. Instead:
247
247
 
248
248
  1. `SCRATCH=$(mktemp -d)`, then
249
- `(cd "$SCRATCH" && npx @zackbart/connecta@0.24.1 init current)` — there is no
249
+ `(cd "$SCRATCH" && npx @zackbart/connecta@0.24.2 init current)` — there is no
250
250
  `base` leg here, only the current template to read from.
251
251
  2. Copy `$SCRATCH/current` into the deployment file by file, **skipping
252
252
  `src/index.ts`**.
@@ -267,7 +267,7 @@ first, so cross them bottom-up: start at the oldest one still above this
267
267
  deployment's pin and work back up the page, because each boundary assumes the
268
268
  older ones are already done.
269
269
 
270
- ### 0.23.0 → 0.24.1
270
+ ### 0.23.0 → 0.24.2
271
271
 
272
272
  Use the [optional-module migration](./optional-modules-upgrade.md) to select
273
273
  modules, grant auth-management permissions, and migrate issued-token clients.
@@ -275,7 +275,9 @@ Preserve storage, encryption keys, and identity namespaces. 0.24.1 adds two
275
275
  optional settings, `execute.maxHostCalls` and `execute.hostCallTimeoutMs`, for
276
276
  deployments whose providers legitimately run past the 20-call and 15-second
277
277
  `execute_code` defaults, and one bounded `warn` log line per failed connector
278
- call; neither needs migration.
278
+ call; neither needs migration. 0.24.2 adds tool-level
279
+ `connectorAccess` grants and optional named pools at `/mcp/<pool>`; a
280
+ deployment that declares neither is unchanged. See [pools](./auth.md#pools).
279
281
 
280
282
  ### 0.22.3 → 0.23.0
281
283
 
package/ethos.md CHANGED
@@ -60,7 +60,7 @@ subsystem guides and the CHANGELOG.
60
60
  | `connecta.batch` | removed | JavaScript promises suffice |
61
61
  | Automatic direct-call retries | removed | callers own retry timing |
62
62
  | Connector HTTP routes | removed | deployments own custom routes |
63
- | Caller-selected toolkits | removed | only config may derive an identity's connector view ([#178](https://github.com/zackbart/connecta/issues/178)) |
63
+ | Caller-selected toolkits | removed | config derives every view; config-declared, grant-gated pools at `/mcp/<pool>` are not caller-selected ([#178](https://github.com/zackbart/connecta/issues/178), [#531](https://github.com/zackbart/connecta/issues/531)) |
64
64
  | Proactive credential liveness | removed | fail-at-use is enough ([#179](https://github.com/zackbart/connecta/issues/179)) |
65
65
  | Classic (executor-free) surface | removed | an executor is mandatory ([#273](https://github.com/zackbart/connecta/issues/273)) |
66
66
  | Per-result lexical query coverage | removed | did not earn its response bytes in a precommitted gate ([#323](https://github.com/zackbart/connecta/issues/323)) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zackbart/connecta",
3
- "version": "0.24.1",
3
+ "version": "0.24.2",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "description": "One MCP to rule them all — a single MCP endpoint aggregating many downstream connectors behind a code-first surface of seven meta-tools.",
@@ -15,7 +15,7 @@
15
15
  "typecheck": "tsc --noEmit"
16
16
  },
17
17
  "dependencies": {
18
- "@zackbart/connecta": "0.24.1",
18
+ "@zackbart/connecta": "0.24.2",
19
19
  "quickjs-emscripten": "0.32.0"
20
20
  },
21
21
  "devDependencies": {