@zackbart/connecta 0.24.1 → 0.24.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -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/index.d.ts +23 -1
- package/dist/index.js +65 -0
- 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 +32 -3
- package/dist/routes/oauth-management.js +1 -0
- package/dist/routes/shared.d.ts +6 -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/operations.md +2 -1
- package/documentation/upgrading.md +7 -5
- package/ethos.md +1 -1
- package/package.json +1 -1
- package/templates/node/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,34 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this package are documented here.
|
|
4
4
|
|
|
5
|
+
## 0.24.2 — 2026-09-16
|
|
6
|
+
|
|
7
|
+
`connectorAccess` can now grant individual tools, and a deployment can declare
|
|
8
|
+
named pools served at `/mcp/<pool>`. Nothing changes for a deployment that
|
|
9
|
+
returns `"all"` or connector ids and declares no pools.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **Named tool pools at `/mcp/<pool>`.** `ConnectaConfig.pools` declares a
|
|
14
|
+
slice of connector ids and exact `connector.tool` addresses plus a `grant`
|
|
15
|
+
predicate over the authenticated identity, denied by default. The endpoint
|
|
16
|
+
serves the pool intersected with the identity's `connectorAccess`, so it can
|
|
17
|
+
only narrow. An undeclared name, a refusing grant, and a throwing grant are
|
|
18
|
+
one identical 404. Misdeclared pools refuse to boot. Clerk's 401 challenge
|
|
19
|
+
and protected-resource metadata follow the pool path so OAuth discovery
|
|
20
|
+
matches the URL the client used. Ethos records the decision.
|
|
21
|
+
|
|
22
|
+
- **Tool-level grants in `identity.connectorAccess`.** Entries may be a
|
|
23
|
+
connector id (every tool) or an exact `connector.tool` address (that tool
|
|
24
|
+
only); grants are additive. The scoped registry view filters below the
|
|
25
|
+
catalog service, so `search_tools`, `describe_tools`, `call_tool`,
|
|
26
|
+
`call_destructive_tool`, a program's `connecta.search` and `connecta.call`,
|
|
27
|
+
and the connection UI all see the same list, and an ungranted tool fails as
|
|
28
|
+
`unknown_tool` exactly like an absent one. There is no wildcard: a remote
|
|
29
|
+
catalog that drifts cannot widen a grant. An address the catalog lacks is
|
|
30
|
+
unreachable and warned once per isolate. An unparseable entry refuses the
|
|
31
|
+
request with 403 rather than failing open.
|
|
32
|
+
|
|
5
33
|
## 0.24.1 — 2026-09-08
|
|
6
34
|
|
|
7
35
|
### Added
|
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/index.d.ts
CHANGED
|
@@ -105,7 +105,12 @@ export interface ConnectaAdmissionConfig {
|
|
|
105
105
|
/** Config-owned identity rules for one deployment and tenant. */
|
|
106
106
|
export type ConnectorPermission = "all" | "none" | readonly string[];
|
|
107
107
|
export interface ConnectaIdentityConfig {
|
|
108
|
-
/**
|
|
108
|
+
/**
|
|
109
|
+
* What this admitted identity may discover and call: `"all"`, or a list
|
|
110
|
+
* whose entries are connector ids (the whole connector) and `connector.tool`
|
|
111
|
+
* addresses (that tool only). Grants are additive. An address naming a tool
|
|
112
|
+
* the catalog lacks is unreachable and warned once, never widened.
|
|
113
|
+
*/
|
|
109
114
|
connectorAccess?(identity: Readonly<AuthenticatedIdentity>): "all" | readonly string[] | Promise<"all" | readonly string[]>;
|
|
110
115
|
/** Global payload-free activity reads. Defaults to interactive humans. */
|
|
111
116
|
activityAccess?(principal: Readonly<IdentityReference>): boolean | Promise<boolean>;
|
|
@@ -114,12 +119,29 @@ export interface ConnectaIdentityConfig {
|
|
|
114
119
|
/** Connecting or changing the caller's personal account. Defaults to none. */
|
|
115
120
|
personalConnection?(identity: Readonly<AuthenticatedIdentity>): ConnectorPermission | Promise<ConnectorPermission>;
|
|
116
121
|
}
|
|
122
|
+
/**
|
|
123
|
+
* A named tool pool served at `/mcp/<name>`. The pool is the slice a client
|
|
124
|
+
* pointed at that endpoint may see; the identity's own `connectorAccess`
|
|
125
|
+
* remains its ceiling and the pool can only narrow it.
|
|
126
|
+
*/
|
|
127
|
+
export interface ConnectaPoolConfig {
|
|
128
|
+
/** Connector ids and exact `connector.tool` addresses in this pool. */
|
|
129
|
+
tools: readonly string[];
|
|
130
|
+
/**
|
|
131
|
+
* Whether this admitted identity may open the pool. Denied by default:
|
|
132
|
+
* a pool with no grant serves nobody. A false return, a throw, and an
|
|
133
|
+
* undeclared pool name are the same 404.
|
|
134
|
+
*/
|
|
135
|
+
grant?(identity: Readonly<AuthenticatedIdentity>): boolean | Promise<boolean>;
|
|
136
|
+
}
|
|
117
137
|
export interface ConnectaConfig {
|
|
118
138
|
connectors: Connector[];
|
|
119
139
|
/** Inbound auth adapters. Includes bearerToken(...); omit for open (dev). */
|
|
120
140
|
auth?: InboundAuth | InboundAuth[];
|
|
121
141
|
/** Code-derived connection visibility and independent management permissions. */
|
|
122
142
|
identity?: ConnectaIdentityConfig;
|
|
143
|
+
/** Named tool pools, each served at `/mcp/<name>` to identities its grant admits. */
|
|
144
|
+
pools?: Record<string, ConnectaPoolConfig>;
|
|
123
145
|
/** KVStorage impl. Defaults to memoryStorage(). */
|
|
124
146
|
storage?: KVStorage;
|
|
125
147
|
/**
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { credentialTestRule, describeCredentialTestMismatch, } from "./credential-rules.js";
|
|
2
2
|
import { Registry } from "./registry.js";
|
|
3
|
+
import { parseConnectorAccess, POOL_NAME_RE } from "./connector-access.js";
|
|
3
4
|
import { createFetchHandler } from "./server.js";
|
|
4
5
|
import { droppedBrandingUrls, droppedUiAuthUrls } from "./branding.js";
|
|
5
6
|
import { memoryStorage } from "./storage/memory.js";
|
|
@@ -56,6 +57,7 @@ const CONFIG_SCHEMA = {
|
|
|
56
57
|
credentialAdministration: null,
|
|
57
58
|
personalConnection: null,
|
|
58
59
|
},
|
|
60
|
+
pools: null,
|
|
59
61
|
storage: null,
|
|
60
62
|
publicUrl: null,
|
|
61
63
|
activity: null,
|
|
@@ -145,6 +147,67 @@ function assertKnownConfig(config) {
|
|
|
145
147
|
throw new Error("ConnectaConfig.activity must be created with activityHistory(...)");
|
|
146
148
|
}
|
|
147
149
|
}
|
|
150
|
+
/**
|
|
151
|
+
* Validate declared pools against the connector set. Everything checkable at
|
|
152
|
+
* construction throws here: a malformed name, an unparseable grant, an
|
|
153
|
+
* unknown connector id, a tool address on an `api()` connector whose static
|
|
154
|
+
* catalog lacks it. Remote catalogs load lazily, so their addresses are
|
|
155
|
+
* checked at catalog load instead and stay unreachable until they match.
|
|
156
|
+
*/
|
|
157
|
+
function resolvePools(pools, registry) {
|
|
158
|
+
const resolved = new Map();
|
|
159
|
+
if (!pools)
|
|
160
|
+
return resolved;
|
|
161
|
+
if (typeof pools !== "object" || Array.isArray(pools)) {
|
|
162
|
+
throw new Error("ConnectaConfig.pools must be an object keyed by pool name");
|
|
163
|
+
}
|
|
164
|
+
for (const [name, pool] of Object.entries(pools)) {
|
|
165
|
+
if (!POOL_NAME_RE.test(name)) {
|
|
166
|
+
throw new Error(`ConnectaConfig.pools: pool name "${name}" must match [a-z0-9_-]+`);
|
|
167
|
+
}
|
|
168
|
+
if (!pool || typeof pool !== "object" || !Array.isArray(pool.tools)) {
|
|
169
|
+
throw new Error(`ConnectaConfig.pools.${name}: tools must be an array of connector ids or connector.tool addresses`);
|
|
170
|
+
}
|
|
171
|
+
if (pool.grant !== undefined && typeof pool.grant !== "function") {
|
|
172
|
+
throw new Error(`ConnectaConfig.pools.${name}: grant must be a function`);
|
|
173
|
+
}
|
|
174
|
+
// A misspelled `grant` would otherwise boot as a deny-all pool with only a
|
|
175
|
+
// per-request log line to say so; that is fail-closed, but the rule here
|
|
176
|
+
// is that structural mistakes refuse to boot.
|
|
177
|
+
for (const key of Object.keys(pool)) {
|
|
178
|
+
if (key !== "tools" && key !== "grant") {
|
|
179
|
+
throw new Error(`ConnectaConfig.pools.${name}: unknown option "${key}"`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
let access;
|
|
183
|
+
try {
|
|
184
|
+
access = parseConnectorAccess(pool.tools);
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
throw new Error(`ConnectaConfig.pools.${name}: tools must be connector ids or connector.tool addresses`);
|
|
188
|
+
}
|
|
189
|
+
if (access.connectorIds === "all" || access.connectorIds.length === 0) {
|
|
190
|
+
throw new Error(`ConnectaConfig.pools.${name}: a pool must name at least one connector or tool`);
|
|
191
|
+
}
|
|
192
|
+
for (const id of access.connectorIds) {
|
|
193
|
+
const connector = registry.getConnector(id);
|
|
194
|
+
if (!connector) {
|
|
195
|
+
throw new Error(`ConnectaConfig.pools.${name}: unknown connector "${id}"`);
|
|
196
|
+
}
|
|
197
|
+
const granted = access.toolAccess?.get(id);
|
|
198
|
+
if (!granted || !connector.staticTools)
|
|
199
|
+
continue;
|
|
200
|
+
const known = new Set(connector.staticTools.map((tool) => tool.name));
|
|
201
|
+
for (const tool of granted) {
|
|
202
|
+
if (!known.has(tool)) {
|
|
203
|
+
throw new Error(`ConnectaConfig.pools.${name}: connector "${id}" has no tool "${tool}"`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
resolved.set(name, { access, grant: pool.grant ?? (() => false) });
|
|
208
|
+
}
|
|
209
|
+
return resolved;
|
|
210
|
+
}
|
|
148
211
|
/**
|
|
149
212
|
* One-time construction warnings for deployment shapes that run fine but are
|
|
150
213
|
* usually unintended. Warning-only — never throws and never changes behavior;
|
|
@@ -269,6 +332,7 @@ export function createConnecta(config) {
|
|
|
269
332
|
maxResultBytes: config.calls?.maxResultBytes,
|
|
270
333
|
});
|
|
271
334
|
const inboundAuth = configuredAuth;
|
|
335
|
+
const pools = resolvePools(config.pools, registry);
|
|
272
336
|
warnInsecureConfig(config, inboundAuth, logger);
|
|
273
337
|
const requestAdmission = admissionController(config.admission?.requests, REQUEST_ADMISSION_DEFAULTS);
|
|
274
338
|
const configuredCodeAdmission = admissionController(config.admission?.code, CODE_ADMISSION_DEFAULTS);
|
|
@@ -290,6 +354,7 @@ export function createConnecta(config) {
|
|
|
290
354
|
registry,
|
|
291
355
|
auth: inboundAuth,
|
|
292
356
|
identity: config.identity,
|
|
357
|
+
pools,
|
|
293
358
|
publicUrl: config.publicUrl,
|
|
294
359
|
serverInfo,
|
|
295
360
|
logger,
|
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",
|
|
@@ -262,8 +263,10 @@ export function createMcpRoute(opts) {
|
|
|
262
263
|
};
|
|
263
264
|
return async function routeMcp(context) {
|
|
264
265
|
const { path, request, baseUrl, runtimeContext, } = context;
|
|
265
|
-
|
|
266
|
+
const poolPath = /^\/mcp\/([a-z0-9_-]+)$/.exec(path);
|
|
267
|
+
if (path !== "/mcp" && !poolPath)
|
|
266
268
|
return null;
|
|
269
|
+
const poolName = poolPath?.[1];
|
|
267
270
|
let admission;
|
|
268
271
|
try {
|
|
269
272
|
admission = await opts.requestAdmission.acquire({
|
|
@@ -295,11 +298,37 @@ export function createMcpRoute(opts) {
|
|
|
295
298
|
if (!authz.ok) {
|
|
296
299
|
return releaseAdmissionWithResponse(withMcpCors(authz.response), admission, request.signal);
|
|
297
300
|
}
|
|
301
|
+
// A pool endpoint narrows the identity's own view and nothing else. An
|
|
302
|
+
// undeclared name, a grant that refuses, and a grant that throws are
|
|
303
|
+
// one identical 404 so a credential never enumerates the other pools;
|
|
304
|
+
// the operator log is where the reason lives.
|
|
305
|
+
let access = authz;
|
|
306
|
+
if (poolName !== undefined) {
|
|
307
|
+
const pool = opts.pools?.get(poolName);
|
|
308
|
+
let granted = false;
|
|
309
|
+
let reason = "undeclared";
|
|
310
|
+
if (pool) {
|
|
311
|
+
try {
|
|
312
|
+
granted = (await pool.grant(authz.identity)) === true;
|
|
313
|
+
reason = granted ? "granted" : "refused";
|
|
314
|
+
}
|
|
315
|
+
catch {
|
|
316
|
+
reason = "grant threw";
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
if (!pool || !granted) {
|
|
320
|
+
opts.logger.warn(`[connecta] refused /mcp/${poolName} with 404: pool ${reason}` +
|
|
321
|
+
(authz.actor.id ? ` for ${loggableValue(authz.actor.id)}` : ""));
|
|
322
|
+
return releaseAdmissionWithResponse(withMcpCors(new Response("Not Found", { status: 404 })), admission, request.signal);
|
|
323
|
+
}
|
|
324
|
+
access = intersectAccess(authz, pool.access);
|
|
325
|
+
}
|
|
298
326
|
let scopedRegistry;
|
|
299
327
|
try {
|
|
300
328
|
validateAuthPermissions(authz, opts.registry);
|
|
301
329
|
scopedRegistry = opts.registry.scoped({
|
|
302
|
-
connectorIds:
|
|
330
|
+
connectorIds: access.connectorIds,
|
|
331
|
+
...(access.toolAccess ? { toolAccess: access.toolAccess } : {}),
|
|
303
332
|
...(authz.subjectKey ? { subjectKey: authz.subjectKey } : {}),
|
|
304
333
|
...(authz.principalKey ? { principalKey: authz.principalKey } : {}),
|
|
305
334
|
});
|
|
@@ -14,6 +14,7 @@ async function handleOAuthManagementRequest(context, connectorId) {
|
|
|
14
14
|
validateAuthPermissions(authz, opts.registry);
|
|
15
15
|
registry = opts.registry.scoped({
|
|
16
16
|
connectorIds: authz.connectorIds,
|
|
17
|
+
...(authz.toolAccess ? { toolAccess: authz.toolAccess } : {}),
|
|
17
18
|
...(authz.subjectKey ? { subjectKey: authz.subjectKey } : {}),
|
|
18
19
|
...(authz.principalKey ? { principalKey: authz.principalKey } : {}),
|
|
19
20
|
});
|
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;
|
|
@@ -78,6 +81,8 @@ export declare function authorize(request: Request, baseUrl: string, auth: Inbou
|
|
|
78
81
|
subjectKey?: string;
|
|
79
82
|
principalKey?: string;
|
|
80
83
|
connectorIds: "all" | readonly string[];
|
|
84
|
+
/** Per-connector tool allowlist for connectors granted by address only. */
|
|
85
|
+
toolAccess?: ToolAccess;
|
|
81
86
|
operator: boolean;
|
|
82
87
|
credentialAdministration: ConnectorPermission;
|
|
83
88
|
personalConnection: ConnectorPermission;
|
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"],
|
|
@@ -84,6 +84,7 @@ optional.
|
|
|
84
84
|
| `executor` | — (required) | the sandbox `execute_code` runs in ([code mode](./code-mode.md#what-an-executor-must-implement)) |
|
|
85
85
|
| `auth?` | none ⇒ open (dev only) | one `InboundAuth` or an array; bearer providers are checked before interactive providers ([inbound auth](./auth.md)) |
|
|
86
86
|
| `identity?` | all visible; auth management denied; interactive activity reads | `{ connectorAccess?, credentialAdministration?, personalConnection?, activityAccess? }` derives separate use and management permissions ([identity](./auth.md#principals-visibility-and-operators)) |
|
|
87
|
+
| `pools?` | none | `{ <name>: { tools, grant? } }` named slices served at `/mcp/<name>`, each intersected with the identity view and denied unless `grant` admits ([pools](./auth.md#pools)) |
|
|
87
88
|
| `storage?` | `memoryStorage()` | connector state, catalogs, and result paging; pass storage explicitly to the optional vault ([storage](./storage-and-credentials.md)) |
|
|
88
89
|
| `publicUrl?` | per-request origin | public base URL; an HTTPS value also redirects inbound HTTP |
|
|
89
90
|
| `logger?` | `console`, prefixed `[connecta]` | `{ debug, info, warn, error }`, or `"silent"` to suppress diagnostic output; independent of activity history |
|
|
@@ -251,7 +252,7 @@ in.
|
|
|
251
252
|
| `executor-admission.test.ts` | the portable bounded FIFO both pools use: active and queue ceilings, stable retryable overload, queue timeout, cancellation removal, idempotent release, shutdown |
|
|
252
253
|
| `guarded-fetch.test.ts` | the guarded transport — construction, request building, destination confinement, and response handling |
|
|
253
254
|
| `guest-api-contract.test.ts` | the shared guest contract on the Dynamic Worker, including caught call, typed inline describe recovery, discovery, utility, parallel-call, and budget failure codes; plus the real authority boundary — local `data:` fetch, denied egress, unresolved DNS, empty environment paths, unavailable filesystem/HTTP builtins, and present runtime globals |
|
|
254
|
-
| `identity-scope.test.ts` | identity-derived connector visibility, personal credential isolation, separate shared-auth and personal-auth management permissions, and personal OAuth callback ownership |
|
|
255
|
+
| `identity-scope.test.ts` | identity-derived connector visibility, named pools at `/mcp/<pool>` (grant-gated, intersected with the identity ceiling, identical 404 for undeclared, refused, and throwing grants, construction-time refusals), exact `connector.tool` grants enforced identically across discovery, direct calls, the program host bridge, and the connection UI, fail-closed grant parsing, the once-per-isolate absent-grant warning, personal credential isolation, separate shared-auth and personal-auth management permissions, and personal OAuth callback ownership |
|
|
255
256
|
| `linear-provider.test.ts` | the Linear proxy's construction, guide, plan-aware catalog superset, and current workspace, template, and issue-sharing classifications |
|
|
256
257
|
| `meta-tools-call.test.ts` | registry-backed calls: structured errors, truncation and `get_result`, per-connector result bounds, JSON representation failures, MCP content bounds, and offset alignment |
|
|
257
258
|
| `meta-tools-search.test.ts` | registry-backed discovery: bounded search with page and address maxima, compact and JSON schemas with constraints, typed describe recovery and suggestions, and structured-result compatibility |
|
|
@@ -117,7 +117,7 @@ exist so far:
|
|
|
117
117
|
| --- | --- | --- |
|
|
118
118
|
| **pre-template** | before 0.10.2 | no `connecta init` existed; hand-written, or copied from the retired `examples/node` |
|
|
119
119
|
| **A** | 0.10.2 – 0.15.1 | `.env.example`, `.gitignore`, `AGENTS.md`, `CLAUDE.md`, `README.md`, `package.json`, `src/index.ts`, `tsconfig.json` |
|
|
120
|
-
| **B** | 0.16.0 – 0.24.
|
|
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,7 +267,7 @@ first, so cross them bottom-up: start at the oldest one still above this
|
|
|
267
267
|
deployment's pin and work back up the page, because each boundary assumes the
|
|
268
268
|
older ones are already done.
|
|
269
269
|
|
|
270
|
-
### 0.23.0 → 0.24.
|
|
270
|
+
### 0.23.0 → 0.24.2
|
|
271
271
|
|
|
272
272
|
Use the [optional-module migration](./optional-modules-upgrade.md) to select
|
|
273
273
|
modules, grant auth-management permissions, and migrate issued-token clients.
|
|
@@ -275,7 +275,9 @@ Preserve storage, encryption keys, and identity namespaces. 0.24.1 adds two
|
|
|
275
275
|
optional settings, `execute.maxHostCalls` and `execute.hostCallTimeoutMs`, for
|
|
276
276
|
deployments whose providers legitimately run past the 20-call and 15-second
|
|
277
277
|
`execute_code` defaults, and one bounded `warn` log line per failed connector
|
|
278
|
-
call; neither needs migration.
|
|
278
|
+
call; neither needs migration. 0.24.2 adds tool-level
|
|
279
|
+
`connectorAccess` grants and optional named pools at `/mcp/<pool>`; a
|
|
280
|
+
deployment that declares neither is unchanged. See [pools](./auth.md#pools).
|
|
279
281
|
|
|
280
282
|
### 0.22.3 → 0.23.0
|
|
281
283
|
|
package/ethos.md
CHANGED
|
@@ -60,7 +60,7 @@ subsystem guides and the CHANGELOG.
|
|
|
60
60
|
| `connecta.batch` | removed | JavaScript promises suffice |
|
|
61
61
|
| Automatic direct-call retries | removed | callers own retry timing |
|
|
62
62
|
| Connector HTTP routes | removed | deployments own custom routes |
|
|
63
|
-
| Caller-selected toolkits | removed |
|
|
63
|
+
| Caller-selected toolkits | removed | config derives every view; config-declared, grant-gated pools at `/mcp/<pool>` are not caller-selected ([#178](https://github.com/zackbart/connecta/issues/178), [#531](https://github.com/zackbart/connecta/issues/531)) |
|
|
64
64
|
| Proactive credential liveness | removed | fail-at-use is enough ([#179](https://github.com/zackbart/connecta/issues/179)) |
|
|
65
65
|
| Classic (executor-free) surface | removed | an executor is mandatory ([#273](https://github.com/zackbart/connecta/issues/273)) |
|
|
66
66
|
| Per-result lexical query coverage | removed | did not earn its response bytes in a precommitted gate ([#323](https://github.com/zackbart/connecta/issues/323)) |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zackbart/connecta",
|
|
3
|
-
"version": "0.24.
|
|
3
|
+
"version": "0.24.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"description": "One MCP to rule them all — a single MCP endpoint aggregating many downstream connectors behind a code-first surface of seven meta-tools.",
|