@zackbart/connecta 0.24.0 → 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,47 @@
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
+
33
+ ## 0.24.1 — 2026-09-08
34
+
35
+ ### Added
36
+
37
+ - `execute.maxHostCalls` and `execute.hostCallTimeoutMs` configure the
38
+ `execute_code` host-call budget and per-call deadline, previously fixed at
39
+ 20 calls and 15 seconds. The tool description advertises the configured
40
+ values. Analytics providers such as Mixpanel routinely need 15 to 35 seconds
41
+ for funnel and experiment queries.
42
+ - A `warn` log line for every failed connector call, carrying the connector,
43
+ tool, source, error code, attempts, duration, and a bounded downstream
44
+ message. Activity rows remain payload-free; the log is where the reason goes.
45
+
5
46
  ## 0.24.0 — 2026-09-07
6
47
 
7
48
  Deployments now select UI, encrypted credentials, activity history, and inbound
@@ -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/execute.d.ts CHANGED
@@ -117,6 +117,8 @@ export declare function createExecuteTool(registry: RegistryView, baseUrl: strin
117
117
  probeTimeoutMs?: number | undefined;
118
118
  maxEmittedBytes?: number | undefined;
119
119
  maxEmittedBlocks?: number | undefined;
120
+ maxHostCalls?: number | undefined;
121
+ hostCallTimeoutMs?: number | undefined;
120
122
  defer?: DeferredWork | undefined;
121
123
  }): ({ code, diagnostics: diagnosticsRequested }: {
122
124
  code: string;
@@ -143,6 +145,10 @@ export declare function registerExecuteTool(server: McpServer, registry: Registr
143
145
  maxEmittedBytes?: number | undefined;
144
146
  /** Block-count budget for connecta.emit. Default 32. */
145
147
  maxEmittedBlocks?: number | undefined;
148
+ /** Host calls one program may make. Default 20. */
149
+ maxHostCalls?: number | undefined;
150
+ /** Deadline per host call in milliseconds. Default 15_000. */
151
+ hostCallTimeoutMs?: number | undefined;
146
152
  defer?: DeferredWork | undefined;
147
153
  }): void;
148
154
  export {};
package/dist/execute.js CHANGED
@@ -141,8 +141,8 @@ export class EmitCollector {
141
141
  this.diagnostics?.recordEmitted(this.blocks.length, this.bytes);
142
142
  }
143
143
  }
144
- /** A configured emit budget must be a finite number >= 1; anything else falls back. */
145
- function resolveEmitBudget(value, fallback) {
144
+ /** A positive whole-number budget, or the default when the value is unusable. */
145
+ function resolveBudget(value, fallback) {
146
146
  return typeof value === "number" && Number.isFinite(value) && value >= 1
147
147
  ? Math.trunc(value)
148
148
  : fallback;
@@ -349,7 +349,7 @@ export function createExecuteTool(registry, baseUrl, executor, logger, activity,
349
349
  let lease;
350
350
  let outcome;
351
351
  const diagnostics = diagnosticsRequested ? new ExecuteDiagnostics() : undefined;
352
- const emitted = new EmitCollector(resolveEmitBudget(config.maxEmittedBytes, EXECUTE_MAX_EMITTED_BYTES), resolveEmitBudget(config.maxEmittedBlocks, EXECUTE_MAX_EMITTED_BLOCKS), diagnostics);
352
+ const emitted = new EmitCollector(resolveBudget(config.maxEmittedBytes, EXECUTE_MAX_EMITTED_BYTES), resolveBudget(config.maxEmittedBlocks, EXECUTE_MAX_EMITTED_BLOCKS), diagnostics);
353
353
  const invocationFailures = [];
354
354
  try {
355
355
  // Admission comes before provider construction: queued calls retain no
@@ -382,6 +382,8 @@ export function createExecuteTool(registry, baseUrl, executor, logger, activity,
382
382
  ...(diagnostics ? { diagnostics } : {}),
383
383
  discoveryConcurrency: config.discoveryConcurrency,
384
384
  probeTimeoutMs: config.probeTimeoutMs,
385
+ maxHostCalls: config.maxHostCalls,
386
+ hostCallTimeoutMs: config.hostCallTimeoutMs,
385
387
  defer: config.defer,
386
388
  });
387
389
  }
@@ -585,7 +587,7 @@ function connectorInventory(connectors) {
585
587
  return `${prefix}${shown.join(", ")}.`;
586
588
  return `${prefix}${shown.join(", ")}${shown.length > 0 ? "; " : ""}+${omitted} more.`;
587
589
  }
588
- const executeDescription = (emitBudgets, connectorGuides, connectors) => `Use the configured services below to answer the task. A known address uses call_tool. Unknown-address and wider read-only work uses one execute_code program for discovery, calls, and reduction. Do not return catalog matches alone. Only readOnlyHint: true tools are available. Limits: ${EXECUTE_MAX_HOST_CALLS} host calls, ${EXECUTE_HOST_CALL_TIMEOUT_MS / 1_000}s/host call.
590
+ const executeDescription = (emitBudgets, hostLimits, connectorGuides, connectors) => `Use the configured services below to answer the task. A known address uses call_tool. Unknown-address and wider read-only work uses one execute_code program for discovery, calls, and reduction. Do not return catalog matches alone. Only readOnlyHint: true tools are available. Limits: ${hostLimits.maxHostCalls} host calls, ${hostLimits.hostCallTimeoutMs / 1_000}s/host call.
589
591
 
590
592
  ${connectorInventory(connectors)}
591
593
 
@@ -603,8 +605,14 @@ export function registerExecuteTool(server, registry, ctx) {
603
605
  // Resolved once so the description and the collector cannot disagree about
604
606
  // the budgets this deployment actually enforces.
605
607
  const emitBudgets = {
606
- maxBytes: resolveEmitBudget(ctx.maxEmittedBytes, EXECUTE_MAX_EMITTED_BYTES),
607
- maxBlocks: resolveEmitBudget(ctx.maxEmittedBlocks, EXECUTE_MAX_EMITTED_BLOCKS),
608
+ maxBytes: resolveBudget(ctx.maxEmittedBytes, EXECUTE_MAX_EMITTED_BYTES),
609
+ maxBlocks: resolveBudget(ctx.maxEmittedBlocks, EXECUTE_MAX_EMITTED_BLOCKS),
610
+ };
611
+ // Same rule for host-call limits: the description advertises exactly what
612
+ // the sandbox enforces, so a raised deadline is visible to the model.
613
+ const hostLimits = {
614
+ maxHostCalls: resolveBudget(ctx.maxHostCalls, EXECUTE_MAX_HOST_CALLS),
615
+ hostCallTimeoutMs: resolveBudget(ctx.hostCallTimeoutMs, EXECUTE_HOST_CALL_TIMEOUT_MS),
608
616
  };
609
617
  const connectors = registry.listConnectors();
610
618
  const handler = createExecuteTool(registry, ctx.baseUrl, ctx.executor, ctx.logger, ctx.activity, {
@@ -612,10 +620,12 @@ export function registerExecuteTool(server, registry, ctx) {
612
620
  probeTimeoutMs: ctx.probeTimeoutMs,
613
621
  maxEmittedBytes: emitBudgets.maxBytes,
614
622
  maxEmittedBlocks: emitBudgets.maxBlocks,
623
+ maxHostCalls: hostLimits.maxHostCalls,
624
+ hostCallTimeoutMs: hostLimits.hostCallTimeoutMs,
615
625
  defer: ctx.defer,
616
626
  });
617
627
  server.registerTool("execute_code", {
618
- description: executeDescription(emitBudgets, hasConnectorGuides(connectors), connectors),
628
+ description: executeDescription(emitBudgets, hostLimits, hasConnectorGuides(connectors), connectors),
619
629
  inputSchema: z.object({
620
630
  code: z
621
631
  .string()
package/dist/index.d.ts CHANGED
@@ -51,7 +51,7 @@ export interface ConnectaCallsConfig {
51
51
  */
52
52
  maxResultBytes?: number;
53
53
  }
54
- /** Budgets for rich output emitted by execute_code programs (`connecta.emit`). */
54
+ /** Budgets for execute_code programs: host calls and rich output (`connecta.emit`). */
55
55
  export interface ConnectaExecuteConfig {
56
56
  /**
57
57
  * Aggregate serialized bytes `connecta.emit` accepts per run. Default
@@ -62,6 +62,18 @@ export interface ConnectaExecuteConfig {
62
62
  maxEmittedBytes?: number;
63
63
  /** Content blocks `connecta.emit` accepts per run. Default 32. */
64
64
  maxEmittedBlocks?: number;
65
+ /**
66
+ * Host calls one program may make. Default 20. Invalid values fall back to
67
+ * the default.
68
+ */
69
+ maxHostCalls?: number;
70
+ /**
71
+ * Deadline for each host call a program makes, in milliseconds. Default
72
+ * 15_000. Raise it for providers whose legitimate calls run longer, such as
73
+ * analytics queries; `call_tool`'s own `timeoutMs` is unaffected. Invalid
74
+ * values fall back to the default.
75
+ */
76
+ hostCallTimeoutMs?: number;
65
77
  }
66
78
  export interface AdmissionPoolConfig {
67
79
  /** Simultaneous work admitted to this pool. */
@@ -93,7 +105,12 @@ export interface ConnectaAdmissionConfig {
93
105
  /** Config-owned identity rules for one deployment and tenant. */
94
106
  export type ConnectorPermission = "all" | "none" | readonly string[];
95
107
  export interface ConnectaIdentityConfig {
96
- /** 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
+ */
97
114
  connectorAccess?(identity: Readonly<AuthenticatedIdentity>): "all" | readonly string[] | Promise<"all" | readonly string[]>;
98
115
  /** Global payload-free activity reads. Defaults to interactive humans. */
99
116
  activityAccess?(principal: Readonly<IdentityReference>): boolean | Promise<boolean>;
@@ -102,12 +119,29 @@ export interface ConnectaIdentityConfig {
102
119
  /** Connecting or changing the caller's personal account. Defaults to none. */
103
120
  personalConnection?(identity: Readonly<AuthenticatedIdentity>): ConnectorPermission | Promise<ConnectorPermission>;
104
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
+ }
105
137
  export interface ConnectaConfig {
106
138
  connectors: Connector[];
107
139
  /** Inbound auth adapters. Includes bearerToken(...); omit for open (dev). */
108
140
  auth?: InboundAuth | InboundAuth[];
109
141
  /** Code-derived connection visibility and independent management permissions. */
110
142
  identity?: ConnectaIdentityConfig;
143
+ /** Named tool pools, each served at `/mcp/<name>` to identities its grant admits. */
144
+ pools?: Record<string, ConnectaPoolConfig>;
111
145
  /** KVStorage impl. Defaults to memoryStorage(). */
112
146
  storage?: KVStorage;
113
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,
@@ -75,6 +77,8 @@ const CONFIG_SCHEMA = {
75
77
  execute: {
76
78
  maxEmittedBytes: null,
77
79
  maxEmittedBlocks: null,
80
+ maxHostCalls: null,
81
+ hostCallTimeoutMs: null,
78
82
  },
79
83
  admission: {
80
84
  requests: admissionPoolSchema,
@@ -143,6 +147,67 @@ function assertKnownConfig(config) {
143
147
  throw new Error("ConnectaConfig.activity must be created with activityHistory(...)");
144
148
  }
145
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
+ }
146
211
  /**
147
212
  * One-time construction warnings for deployment shapes that run fine but are
148
213
  * usually unintended. Warning-only — never throws and never changes behavior;
@@ -267,6 +332,7 @@ export function createConnecta(config) {
267
332
  maxResultBytes: config.calls?.maxResultBytes,
268
333
  });
269
334
  const inboundAuth = configuredAuth;
335
+ const pools = resolvePools(config.pools, registry);
270
336
  warnInsecureConfig(config, inboundAuth, logger);
271
337
  const requestAdmission = admissionController(config.admission?.requests, REQUEST_ADMISSION_DEFAULTS);
272
338
  const configuredCodeAdmission = admissionController(config.admission?.code, CODE_ADMISSION_DEFAULTS);
@@ -288,6 +354,7 @@ export function createConnecta(config) {
288
354
  registry,
289
355
  auth: inboundAuth,
290
356
  identity: config.identity,
357
+ pools,
291
358
  publicUrl: config.publicUrl,
292
359
  serverInfo,
293
360
  logger,
@@ -303,6 +370,8 @@ export function createConnecta(config) {
303
370
  discoveryConcurrency: config.discovery?.concurrency,
304
371
  maxEmittedBytes: config.execute?.maxEmittedBytes,
305
372
  maxEmittedBlocks: config.execute?.maxEmittedBlocks,
373
+ maxHostCalls: config.execute?.maxHostCalls,
374
+ hostCallTimeoutMs: config.execute?.hostCallTimeoutMs,
306
375
  credentialVault,
307
376
  ui: config.ui,
308
377
  deploymentInfo: config.deploymentInfo,
@@ -184,7 +184,23 @@ export class InvocationService {
184
184
  };
185
185
  const failed = (error) => {
186
186
  const diagnostics = timing();
187
- const details = enrich(error, resolved ?? activityTarget);
187
+ const target = resolved ?? activityTarget;
188
+ const details = enrich(error, target);
189
+ // Activity rows stay payload-free by construction; the operator's log is
190
+ // where the downstream reason goes, bounded and without arguments.
191
+ if (target && details.code !== "destructive_tool_requires_approval") {
192
+ this.registry
193
+ .contextFor(target.connector.id, this.catalog.baseUrl, this.catalog.requestScope)
194
+ .logger.warn("[connecta] call failed", {
195
+ connector: target.connector.id,
196
+ tool: target.toolName,
197
+ source: context.source,
198
+ code: details.code,
199
+ attempts,
200
+ durationMs: Date.now() - started,
201
+ message: String(details.message ?? "").slice(0, 300),
202
+ });
203
+ }
188
204
  record(details.code === "timeout"
189
205
  ? "timeout"
190
206
  : details.code === "cancelled"
@@ -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",
@@ -215,6 +216,12 @@ async function serveMcp(request, opts, baseUrl, actor, registry, canManageAuth,
215
216
  ...(opts.maxEmittedBlocks !== undefined
216
217
  ? { maxEmittedBlocks: opts.maxEmittedBlocks }
217
218
  : {}),
219
+ ...(opts.maxHostCalls !== undefined
220
+ ? { maxHostCalls: opts.maxHostCalls }
221
+ : {}),
222
+ ...(opts.hostCallTimeoutMs !== undefined
223
+ ? { hostCallTimeoutMs: opts.hostCallTimeoutMs }
224
+ : {}),
218
225
  });
219
226
  return server;
220
227
  };
@@ -256,8 +263,10 @@ export function createMcpRoute(opts) {
256
263
  };
257
264
  return async function routeMcp(context) {
258
265
  const { path, request, baseUrl, runtimeContext, } = context;
259
- if (path !== "/mcp")
266
+ const poolPath = /^\/mcp\/([a-z0-9_-]+)$/.exec(path);
267
+ if (path !== "/mcp" && !poolPath)
260
268
  return null;
269
+ const poolName = poolPath?.[1];
261
270
  let admission;
262
271
  try {
263
272
  admission = await opts.requestAdmission.acquire({
@@ -289,11 +298,37 @@ export function createMcpRoute(opts) {
289
298
  if (!authz.ok) {
290
299
  return releaseAdmissionWithResponse(withMcpCors(authz.response), admission, request.signal);
291
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
+ }
292
326
  let scopedRegistry;
293
327
  try {
294
328
  validateAuthPermissions(authz, opts.registry);
295
329
  scopedRegistry = opts.registry.scoped({
296
- connectorIds: authz.connectorIds,
330
+ connectorIds: access.connectorIds,
331
+ ...(access.toolAccess ? { toolAccess: access.toolAccess } : {}),
297
332
  ...(authz.subjectKey ? { subjectKey: authz.subjectKey } : {}),
298
333
  ...(authz.principalKey ? { principalKey: authz.principalKey } : {}),
299
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;
@@ -29,6 +32,10 @@ export interface ServerOptions {
29
32
  maxEmittedBytes?: number | undefined;
30
33
  /** Block-count budget for connecta.emit per run. Default 32. */
31
34
  maxEmittedBlocks?: number | undefined;
35
+ /** Host calls one execute_code program may make. Default 20. */
36
+ maxHostCalls?: number | undefined;
37
+ /** Deadline per execute_code host call. Default 15_000. */
38
+ hostCallTimeoutMs?: number | undefined;
32
39
  /** Required sandbox backing the execute_code meta-tool. */
33
40
  executor: Executor;
34
41
  /** Sanitized identity of the configured sandbox, when it has one. */
@@ -74,6 +81,8 @@ export declare function authorize(request: Request, baseUrl: string, auth: Inbou
74
81
  subjectKey?: string;
75
82
  principalKey?: string;
76
83
  connectorIds: "all" | readonly string[];
84
+ /** Per-connector tool allowlist for connectors granted by address only. */
85
+ toolAccess?: ToolAccess;
77
86
  operator: boolean;
78
87
  credentialAdministration: ConnectorPermission;
79
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.0";
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.0";
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"],
@@ -302,7 +302,7 @@ Program-authored errors stay untyped, and code must never parse error prose.
302
302
  | `input_required_unsupported` | a downstream asked for mid-call input | false |
303
303
  | `rate_limited` | the downstream reported a rate limit | true |
304
304
  | `unavailable` | the downstream is down or unreachable | true |
305
- | `timeout` | the per-call 15-second deadline expired | true |
305
+ | `timeout` | the per-call deadline (`execute.hostCallTimeoutMs`, default 15 s) expired | true |
306
306
  | `cancelled` | the run ended while this call was in flight (`E5`) | false |
307
307
  | `connector_call_failed` | anything else the connector threw | per message |
308
308
  | `catalog_lookup_failed` | the connector's catalog could not be loaded | per cause |
@@ -492,7 +492,7 @@ because connecta enforces them above the sandbox:
492
492
  | Bound | Value |
493
493
  | --- | --- |
494
494
  | Host calls per execution | 20 |
495
- | Deadline per host call | 15 s |
495
+ | Deadline per host call | 15 s, `execute.hostCallTimeoutMs` |
496
496
  | Discovery page | ≤ 100 tools, ≤ 256,000 serialized bytes |
497
497
  | `describe` addresses | ≤ 100 |
498
498
  | `describe` nearby suggestions | ≤ 3 canonical addresses per failed entry |
@@ -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 |
@@ -101,6 +102,8 @@ optional.
101
102
  | `calls.maxResultBytes?` | 50_000 | inline result cap before truncation and `get_result` paging; a connector may override it. Invalid values warn and fall back |
102
103
  | `execute.maxEmittedBytes?` | 4_000_000 | aggregate `connecta.emit` bytes per run — a transport bound, not a context bound |
103
104
  | `execute.maxEmittedBlocks?` | 32 | content blocks `connecta.emit` accepts per run |
105
+ | `execute.maxHostCalls?` | 20 | connector calls one `execute_code` program may make |
106
+ | `execute.hostCallTimeoutMs?` | 15_000 | deadline per `execute_code` host call; raise it for providers whose legitimate calls run longer. `call_tool`'s `timeoutMs` is separate |
104
107
  | `admission.requests?` | 16 active / 32 queued / 5 s / 1 s | global FIFO `/mcp` capacity, taken before auth ([request admission](./request-admission.md)) |
105
108
  | `admission.code?` | 2 active / 8 queued / 5 s / 1 s | fallback pool for an executor that owns no `acquire()`; ignored with a warning when it does |
106
109
 
@@ -249,7 +252,7 @@ in.
249
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 |
250
253
  | `guarded-fetch.test.ts` | the guarded transport — construction, request building, destination confinement, and response handling |
251
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 |
252
- | `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 |
253
256
  | `linear-provider.test.ts` | the Linear proxy's construction, guide, plan-aware catalog superset, and current workspace, template, and issue-sharing classifications |
254
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 |
255
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.0 | 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.0 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.0 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,11 +267,17 @@ 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.0
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.
274
- Preserve storage, encryption keys, and identity namespaces.
274
+ Preserve storage, encryption keys, and identity namespaces. 0.24.1 adds two
275
+ optional settings, `execute.maxHostCalls` and `execute.hostCallTimeoutMs`, for
276
+ deployments whose providers legitimately run past the 20-call and 15-second
277
+ `execute_code` defaults, and one bounded `warn` log line per failed connector
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).
275
281
 
276
282
  ### 0.22.3 → 0.23.0
277
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,9 +1,9 @@
1
1
  {
2
2
  "name": "@zackbart/connecta",
3
- "version": "0.24.0",
3
+ "version": "0.24.2",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
- "description": "One MCP to rule them all \u2014 a single MCP endpoint aggregating many downstream connectors behind a code-first surface of seven meta-tools.",
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.",
7
7
  "license": "MIT",
8
8
  "engines": {
9
9
  "node": ">=22.0.0"
@@ -15,7 +15,7 @@
15
15
  "typecheck": "tsc --noEmit"
16
16
  },
17
17
  "dependencies": {
18
- "@zackbart/connecta": "0.24.0",
18
+ "@zackbart/connecta": "0.24.2",
19
19
  "quickjs-emscripten": "0.32.0"
20
20
  },
21
21
  "devDependencies": {