@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 +41 -0
- package/dist/auth/clerk.d.ts +0 -5
- package/dist/auth/clerk.js +21 -8
- package/dist/connector-access.d.ts +32 -0
- package/dist/connector-access.js +79 -0
- package/dist/execute.d.ts +6 -0
- package/dist/execute.js +17 -7
- package/dist/index.d.ts +36 -2
- package/dist/index.js +69 -0
- package/dist/invocation.js +17 -1
- package/dist/registry.d.ts +16 -0
- package/dist/registry.js +36 -3
- package/dist/routes/credentials.js +1 -0
- package/dist/routes/mcp.js +38 -3
- package/dist/routes/oauth-management.js +1 -0
- package/dist/routes/shared.d.ts +10 -1
- package/dist/routes/shared.js +8 -9
- package/dist/routes/ui.js +2 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/documentation/architecture.md +10 -5
- package/documentation/auth.md +77 -6
- package/documentation/code-mode.md +2 -2
- package/documentation/operations.md +4 -1
- package/documentation/upgrading.md +11 -5
- package/ethos.md +1 -1
- package/package.json +2 -2
- package/templates/node/package.json +1 -1
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
|
package/dist/auth/clerk.d.ts
CHANGED
|
@@ -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 {};
|
package/dist/auth/clerk.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
145
|
-
function
|
|
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(
|
|
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: ${
|
|
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:
|
|
607
|
-
maxBlocks:
|
|
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
|
|
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
|
-
/**
|
|
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,
|
package/dist/invocation.js
CHANGED
|
@@ -184,7 +184,23 @@ export class InvocationService {
|
|
|
184
184
|
};
|
|
185
185
|
const failed = (error) => {
|
|
186
186
|
const diagnostics = timing();
|
|
187
|
-
const
|
|
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"
|
package/dist/registry.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
});
|
package/dist/routes/mcp.js
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
});
|
package/dist/routes/shared.d.ts
CHANGED
|
@@ -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;
|
package/dist/routes/shared.js
CHANGED
|
@@ -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
|
|
37
|
+
let access;
|
|
37
38
|
try {
|
|
38
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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 [
|
|
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
|
-
|
|
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
package/dist/version.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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. **
|
|
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
|
-
|
|
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
|
|
package/documentation/auth.md
CHANGED
|
@@ -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
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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"
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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 |
|
|
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.
|
|
3
|
+
"version": "0.24.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
|
-
"description": "One MCP to rule them all
|
|
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"
|