@zackbart/connecta 0.32.0 → 0.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +98 -0
- package/README.md +95 -116
- package/SECURITY.md +1 -1
- package/bin/connecta.mjs +24 -6
- package/bin/version.mjs +1 -1
- package/dist/access-tokens.d.ts +167 -23
- package/dist/access-tokens.js +676 -140
- package/dist/activity.d.ts +3 -0
- package/dist/agent-identity.js +47 -0
- package/dist/auth/cloudflare-access.js +13 -5
- package/dist/branding.d.ts +7 -0
- package/dist/branding.js +76 -9
- package/dist/call-admission-context.js +15 -0
- package/dist/call-admission.js +30 -43
- package/dist/call-recovery.js +6 -2
- package/dist/catalog-service.js +15 -1
- package/dist/config-value-sources.js +16 -9
- package/dist/config.d.ts +48 -75
- package/dist/config.js +144 -36
- package/dist/connector-access.d.ts +4 -34
- package/dist/connector-access.js +15 -99
- package/dist/connector-caller.d.ts +7 -1
- package/dist/connector-caller.js +4 -2
- package/dist/connectors/api-connector.d.ts +27 -4
- package/dist/connectors/api-connector.js +2 -0
- package/dist/connectors/remote-mcp.js +2 -0
- package/dist/describe-config.d.ts +31 -7
- package/dist/describe-config.js +8 -17
- package/dist/execute.js +1 -11
- package/dist/fix-prompt.js +2 -2
- package/dist/index.d.ts +4 -3
- package/dist/index.js +30 -10
- package/dist/invocation.js +5 -3
- package/dist/membership.d.ts +53 -0
- package/dist/membership.js +121 -0
- package/dist/module-contracts.d.ts +23 -6
- package/dist/oauth-callback-outcome.js +2 -2
- package/dist/operator-record.d.ts +2 -2
- package/dist/operator-record.js +2 -4
- package/dist/operator-ui/agents.d.ts +74 -0
- package/dist/operator-ui/agents.js +104 -0
- package/dist/operator-ui/config-summary.js +562 -0
- package/dist/operator-ui/connection-map.js +375 -0
- package/dist/operator-ui/contract.d.ts +149 -28
- package/dist/operator-ui/endpoints.js +221 -0
- package/dist/operator-ui/fix-prompts.js +5 -5
- package/dist/operator-ui/generated.js +5 -4
- package/dist/operator-ui/member-page.js +97 -0
- package/dist/operator-ui/model.d.ts +14 -6
- package/dist/operator-ui/overview.js +134 -0
- package/dist/operator-ui/view.d.ts +107 -37
- package/dist/operator-ui/view.js +163 -86
- package/dist/page-styles.js +5 -3
- package/dist/provider.d.ts +61 -8
- package/dist/provider.js +46 -7
- package/dist/providers/_shared/google/workspace.d.ts +56 -17
- package/dist/providers/_shared/google/workspace.js +34 -8
- package/dist/providers/_shared/rest/tools.js +2 -2
- package/dist/providers/_shared/rest/value-safety.d.ts +50 -0
- package/dist/providers/_shared/rest/value-safety.js +699 -7
- package/dist/providers/ccb/index.js +2 -2
- package/dist/providers/cloudflare/index.js +3 -3
- package/dist/providers/cloudflare/rest.js +25 -3
- package/dist/providers/cloudflare/skill.generated.js +1 -1
- package/dist/providers/cloudflare/value-safety.js +32 -0
- package/dist/providers/docs/index.js +4 -4
- package/dist/providers/docs/skill.generated.js +2 -2
- package/dist/providers/drive/index.js +4 -4
- package/dist/providers/drive/skill.generated.js +2 -2
- package/dist/providers/forms/index.js +4 -4
- package/dist/providers/forms/skill.generated.js +2 -2
- package/dist/providers/gmail/index.js +11 -9
- package/dist/providers/gmail/skill.generated.js +2 -2
- package/dist/providers/mixpanel/index.js +1 -1
- package/dist/providers/mixpanel/skill.generated.js +3 -3
- package/dist/providers/notion/index.d.ts +7 -0
- package/dist/providers/notion/index.js +100 -11
- package/dist/providers/notion/rest.js +45 -5
- package/dist/providers/notion/skill.generated.js +2 -2
- package/dist/providers/notion/value-safety.js +30 -5
- package/dist/providers/overflow/index.js +2 -2
- package/dist/providers/overflow/skill.generated.js +2 -2
- package/dist/providers/planning-center/index.js +2 -2
- package/dist/providers/planning-center/skill.generated.js +4 -4
- package/dist/providers/revenuecat/skill.generated.js +2 -2
- package/dist/providers/sheets/index.d.ts +3 -0
- package/dist/providers/sheets/index.js +7 -4
- package/dist/providers/sheets/skill.generated.js +2 -2
- package/dist/providers/slides/index.js +4 -4
- package/dist/providers/slides/skill.generated.js +2 -2
- package/dist/providers/stripe/rest.js +9 -2
- package/dist/providers/stripe/skill.generated.js +1 -1
- package/dist/providers/stripe/value-safety.js +22 -0
- package/dist/providers/tithely/index.js +1 -1
- package/dist/providers/vercel/index.js +1 -1
- package/dist/providers/vercel/rest.js +20 -2
- package/dist/providers/vercel/skill.generated.js +2 -2
- package/dist/providers/vercel/value-safety.js +22 -0
- package/dist/registry.d.ts +2 -3
- package/dist/registry.js +23 -9
- package/dist/routes/access-tokens.js +82 -75
- package/dist/routes/activity-disclosure.js +17 -18
- package/dist/routes/activity.js +34 -54
- package/dist/routes/actor-labels.js +33 -4
- package/dist/routes/agent-path.js +43 -0
- package/dist/routes/connect.js +16 -12
- package/dist/routes/credentials.js +16 -40
- package/dist/routes/mcp.js +161 -104
- package/dist/routes/oauth-management.js +11 -5
- package/dist/routes/oauth.js +5 -11
- package/dist/routes/operator.js +24 -23
- package/dist/routes/shared.d.ts +60 -20
- package/dist/routes/shared.js +187 -96
- package/dist/routes/ui-config.js +437 -97
- package/dist/routes/ui-data.js +2 -2
- package/dist/routes/ui.js +9 -16
- package/dist/runtime/call-admission.js +9 -0
- package/dist/server.js +5 -0
- package/dist/skills.js +10 -0
- package/dist/storage/keys.js +9 -1
- package/dist/storage/sql.js +5 -1
- package/dist/types.d.ts +29 -7
- package/dist/ui.d.ts +1 -1
- package/dist/ui.js +23 -16
- package/dist/usage-guide.js +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/workspace-address.js +9 -0
- package/dist/workspace-subject.js +23 -0
- package/documentation/README.md +47 -23
- package/documentation/architecture.md +371 -61
- package/documentation/auth.md +897 -249
- package/documentation/code-mode.md +37 -16
- package/documentation/deploying.md +126 -25
- package/documentation/integrating.md +97 -23
- package/documentation/meta-tools.md +50 -31
- package/documentation/migration-0.33.md +352 -0
- package/documentation/migration-0.34.md +67 -0
- package/documentation/operating.md +3 -1
- package/documentation/operator-tests.md +147 -34
- package/documentation/operator-ui.md +519 -64
- package/documentation/provider-auth-migration.md +37 -23
- package/documentation/provider-migration-0.29.md +2 -2
- package/examples/worker/AGENTS.md +37 -9
- package/examples/worker/README.md +127 -61
- package/examples/worker/src/connecta.config.ts +40 -23
- package/examples/worker/wrangler.jsonc +4 -1
- package/package.json +19 -18
- package/templates/node/.env.example +3 -0
- package/templates/node/AGENTS.md +33 -6
- package/templates/node/Dockerfile +2 -2
- package/templates/node/README.md +80 -52
- package/templates/node/docker-compose.yml +3 -2
- package/templates/node/package.json +4 -3
- package/templates/node/src/adopt-token.ts +23 -0
- package/templates/node/src/connecta.config.ts +28 -12
- package/templates/node/src/rotate-token.ts +17 -0
- package/templates/node/src/provision-token.ts +0 -17
|
@@ -2,45 +2,10 @@ import { failureRecord, logFailure } from "../operator-record.js";
|
|
|
2
2
|
import { Effect } from "effect";
|
|
3
3
|
import { claimConnectorScopeCleanup, closeConnectorScope } from "../connector-scope.js";
|
|
4
4
|
import { credentialTestRule, describeCredentialTestMismatch, storedCredentialShape } from "../credential-rules.js";
|
|
5
|
-
import { authorizedPerson, refuse, serveOperator,
|
|
6
|
-
import { mayManageConnector, isSameOrigin, msg, privateJson } from "./shared.js";
|
|
5
|
+
import { authorizedPerson, refuse, serveOperator, managementRegistry } from "./operator.js";
|
|
6
|
+
import { boundedText, mayManageConnector, isSameOrigin, msg, privateJson } from "./shared.js";
|
|
7
7
|
/** The longest body, in characters, a credential write accepts. */
|
|
8
8
|
const MAX_BODY_CHARS = 20_000;
|
|
9
|
-
/**
|
|
10
|
-
* The most UTF-8 a body within MAX_BODY_CHARS can take. No UTF-16 unit costs
|
|
11
|
-
* more than three bytes, and a malformed sequence decodes to one U+FFFD per
|
|
12
|
-
* at most three bytes, so a body past this many bytes is past the character
|
|
13
|
-
* limit too — and reading stops there, instead of buffering whatever a client
|
|
14
|
-
* cares to send before measuring it.
|
|
15
|
-
*/
|
|
16
|
-
const MAX_BODY_BYTES = MAX_BODY_CHARS * 3;
|
|
17
|
-
/** The body as text, or undefined once it is longer than MAX_BODY_CHARS. */
|
|
18
|
-
async function boundedText(request) {
|
|
19
|
-
if (!request.body)
|
|
20
|
-
return "";
|
|
21
|
-
const reader = request.body.getReader();
|
|
22
|
-
const chunks = [];
|
|
23
|
-
let size = 0;
|
|
24
|
-
for (;;) {
|
|
25
|
-
const { done, value } = await reader.read();
|
|
26
|
-
if (done)
|
|
27
|
-
break;
|
|
28
|
-
size += value.byteLength;
|
|
29
|
-
if (size > MAX_BODY_BYTES) {
|
|
30
|
-
reader.cancel().catch(() => { });
|
|
31
|
-
return undefined;
|
|
32
|
-
}
|
|
33
|
-
chunks.push(value);
|
|
34
|
-
}
|
|
35
|
-
const bytes = new Uint8Array(size);
|
|
36
|
-
let offset = 0;
|
|
37
|
-
for (const chunk of chunks) {
|
|
38
|
-
bytes.set(chunk, offset);
|
|
39
|
-
offset += chunk.byteLength;
|
|
40
|
-
}
|
|
41
|
-
const text = new TextDecoder().decode(bytes);
|
|
42
|
-
return text.length > MAX_BODY_CHARS ? undefined : text;
|
|
43
|
-
}
|
|
44
9
|
/** Field-by-field checks, in words the Credentials page shows as written. */
|
|
45
10
|
function credentialInput(body, config) {
|
|
46
11
|
if (!config.fields?.length) {
|
|
@@ -73,7 +38,7 @@ function readCredentialInput(request, config) {
|
|
|
73
38
|
if (!request.headers.get("content-type")?.toLowerCase().startsWith("application/json")) {
|
|
74
39
|
return yield* refuse("Content-Type must be application/json", 415);
|
|
75
40
|
}
|
|
76
|
-
const raw = yield* Effect.promise(() => boundedText(request));
|
|
41
|
+
const raw = yield* Effect.promise(() => boundedText(request, MAX_BODY_CHARS));
|
|
77
42
|
if (raw === undefined)
|
|
78
43
|
return yield* refuse("request body is too large", 413);
|
|
79
44
|
let body;
|
|
@@ -99,12 +64,12 @@ function credentialRequest(context, connectorId, action) {
|
|
|
99
64
|
return yield* refuse("same-origin request required", 403);
|
|
100
65
|
}
|
|
101
66
|
const authz = yield* authorizedPerson(context, "credential management");
|
|
102
|
-
const registry = yield*
|
|
67
|
+
const registry = yield* managementRegistry(context, authz);
|
|
103
68
|
const connector = registry.getConnector(connectorId);
|
|
104
69
|
const credential = connector?.credential;
|
|
105
70
|
if (!connector || !credential)
|
|
106
71
|
return yield* refuse("unknown credential slot", 404);
|
|
107
|
-
if (!mayManageConnector(authz, connector)) {
|
|
72
|
+
if (!mayManageConnector(opts, authz, connector)) {
|
|
108
73
|
return yield* refuse("credential management is not permitted", 403);
|
|
109
74
|
}
|
|
110
75
|
const personal = connector.authScope === "personal";
|
|
@@ -191,6 +156,17 @@ function credentialRequest(context, connectorId, action) {
|
|
|
191
156
|
}).pipe(Effect.catch((error) => refuse(msg(error), 400)));
|
|
192
157
|
}
|
|
193
158
|
if (request.method === "DELETE") {
|
|
159
|
+
// Clearing nothing is refused, as testing nothing is, so the route
|
|
160
|
+
// agrees with the operator contract, which offers `clear` only once a
|
|
161
|
+
// value is stored in the caller's partition (#837). A value that cannot
|
|
162
|
+
// be read may still be cleared: removing it is how one recovers.
|
|
163
|
+
const stored = yield* Effect.tryPromise({
|
|
164
|
+
try: () => vault.metadata(connectorId, owner),
|
|
165
|
+
catch: (error) => error,
|
|
166
|
+
}).pipe(Effect.orElseSucceed(() => "unreadable"));
|
|
167
|
+
if (stored === null) {
|
|
168
|
+
return privateJson({ error: "no credential is stored to clear", problem: "credential_required" }, { status: 409 });
|
|
169
|
+
}
|
|
194
170
|
yield* Effect.promise(() => vault.delete(connectorId, owner));
|
|
195
171
|
yield* Effect.promise(() => registry.invalidateStored(connectorId));
|
|
196
172
|
return new Response(null, {
|
package/dist/routes/mcp.js
CHANGED
|
@@ -8,13 +8,13 @@ import { Duration, Effect, Exit, Option, Result, Scope } from "effect";
|
|
|
8
8
|
import { registerExecuteTool } from "../execute.js";
|
|
9
9
|
import { ExecutorAdmissionError } from "../executor-admission.js";
|
|
10
10
|
import { registerMetaTools } from "../meta-tools.js";
|
|
11
|
-
import {
|
|
12
|
-
import { CONNECTA_INSTRUCTIONS } from "../skills.js";
|
|
11
|
+
import { endpointInstructions } from "../skills.js";
|
|
13
12
|
import { registerSkills } from "./skills.js";
|
|
14
13
|
import { failureRecord, logFailure } from "../operator-record.js";
|
|
15
14
|
import { redactAgentOutput, sentSecretsForRequest } from "../sent-secrets.js";
|
|
16
15
|
import { detach } from "../runtime/run.js";
|
|
17
|
-
import {
|
|
16
|
+
import { agentEndpointPool, isAgentPath } from "./agent-path.js";
|
|
17
|
+
import { admitsMaintainer, agentPool, authorize, authorizeAgent, mayManageConnector, memberPool, } from "./shared.js";
|
|
18
18
|
/**
|
|
19
19
|
* The SDK's own bound on the body it reads. Since server 2.1.0 the SDK
|
|
20
20
|
* answers anything over 4 MiB with its own 413, which would quietly shadow
|
|
@@ -26,10 +26,22 @@ import { authorize, mayManageConnector, validateAuthPermissions, } from "./share
|
|
|
26
26
|
const SDK_BODY_BOUND = { maxRequestBodySize: Number.MAX_SAFE_INTEGER };
|
|
27
27
|
// Identity, not a status/header a custom auth response could imitate.
|
|
28
28
|
const deadlineResponses = new WeakSet();
|
|
29
|
-
|
|
29
|
+
/**
|
|
30
|
+
* The most a `subscriptions/listen` classification reads. A listen is a small
|
|
31
|
+
* JSON-RPC request; this read happens before request admission, so it is
|
|
32
|
+
* bounded by this fixed cap rather than by the host's body limit (#857).
|
|
33
|
+
*/
|
|
34
|
+
export const MAX_LISTEN_CLASSIFICATION_BYTES = 64 * 1024;
|
|
35
|
+
/**
|
|
36
|
+
* Whether an admitted, authenticated request is a modern `subscriptions/listen`.
|
|
37
|
+
* Reads at most `MAX_LISTEN_CLASSIFICATION_BYTES` of a clone, then stops and
|
|
38
|
+
* cancels the body: `"too_large"` past the cap, `"aborted"` when the request
|
|
39
|
+
* ended or its lifetime ran out while reading.
|
|
40
|
+
*/
|
|
41
|
+
async function classifyListen(request, signal) {
|
|
30
42
|
const reader = request.clone().body?.getReader();
|
|
31
43
|
if (!reader)
|
|
32
|
-
return
|
|
44
|
+
return "other";
|
|
33
45
|
const onAbort = () => {
|
|
34
46
|
void reader.cancel(signal.reason).catch(() => { });
|
|
35
47
|
void request.body?.cancel(signal.reason).catch(() => { });
|
|
@@ -46,12 +58,15 @@ async function isModernListen(request, signal) {
|
|
|
46
58
|
if (done)
|
|
47
59
|
break;
|
|
48
60
|
bytes += value.byteLength;
|
|
49
|
-
if (bytes >
|
|
50
|
-
|
|
61
|
+
if (bytes > MAX_LISTEN_CLASSIFICATION_BYTES) {
|
|
62
|
+
void reader.cancel().catch(() => { });
|
|
63
|
+
void request.body?.cancel().catch(() => { });
|
|
64
|
+
return "too_large";
|
|
65
|
+
}
|
|
51
66
|
text += decoder.decode(value, { stream: true });
|
|
52
67
|
}
|
|
53
68
|
if (signal.aborted)
|
|
54
|
-
return
|
|
69
|
+
return "aborted";
|
|
55
70
|
const protocolVersion = request.headers.get("MCP-Protocol-Version");
|
|
56
71
|
const method = request.headers.get("Mcp-Method");
|
|
57
72
|
const route = classifyInboundRequest({
|
|
@@ -60,11 +75,15 @@ async function isModernListen(request, signal) {
|
|
|
60
75
|
...(method !== null ? { mcpMethodHeader: method } : {}),
|
|
61
76
|
body: JSON.parse(text + decoder.decode()),
|
|
62
77
|
});
|
|
63
|
-
return
|
|
78
|
+
return route.kind === "modern" && route.messageKind === "request" && route.message.method === "subscriptions/listen"
|
|
79
|
+
? "listen"
|
|
80
|
+
: "other";
|
|
64
81
|
}
|
|
65
82
|
catch {
|
|
83
|
+
if (signal.aborted)
|
|
84
|
+
return "aborted";
|
|
66
85
|
// Leave unreadable, malformed, and legacy bodies to the normal SDK path.
|
|
67
|
-
return
|
|
86
|
+
return "other";
|
|
68
87
|
}
|
|
69
88
|
finally {
|
|
70
89
|
signal.removeEventListener("abort", onAbort);
|
|
@@ -322,7 +341,10 @@ function serveMcp(request, requestSignal, requestScope, opts, baseUrl, actor, re
|
|
|
322
341
|
// A request-local server cannot publish catalog changes. Set this before
|
|
323
342
|
// tool registration, whose SDK default otherwise advertises listChanged.
|
|
324
343
|
capabilities: { tools: { listChanged: false }, extensions: { "io.modelcontextprotocol/skills": {} } },
|
|
325
|
-
|
|
344
|
+
// Instructions differ per endpoint only by the admitted pool's
|
|
345
|
+
// description. Every cache hint below is private, so a client keeps
|
|
346
|
+
// what it learned per endpoint URL and never shares it across them.
|
|
347
|
+
instructions: endpointInstructions(pool !== undefined ? opts.pools.get(pool)?.description : undefined),
|
|
326
348
|
inputRequired: { legacyShim: false },
|
|
327
349
|
requestState: { verify: (state, context) => authElicitation.verify(state, context) },
|
|
328
350
|
cacheHints: {
|
|
@@ -456,6 +478,21 @@ function serveMcp(request, requestSignal, requestScope, opts, baseUrl, actor, re
|
|
|
456
478
|
return new Response(text, { status: response.status, statusText: response.statusText, headers });
|
|
457
479
|
})));
|
|
458
480
|
}
|
|
481
|
+
function mcpEndpoint(path) {
|
|
482
|
+
if (isAgentPath(path)) {
|
|
483
|
+
const pool = agentEndpointPool(path);
|
|
484
|
+
return pool === undefined ? { kind: "refused" } : { kind: "agent", pool };
|
|
485
|
+
}
|
|
486
|
+
if (path === "/mcp")
|
|
487
|
+
return { kind: "default" };
|
|
488
|
+
if (path.startsWith("/mcp/"))
|
|
489
|
+
return { kind: "pool", pool: path.slice("/mcp/".length) };
|
|
490
|
+
return null;
|
|
491
|
+
}
|
|
492
|
+
/** Whether `path` is an MCP or agent endpoint path, valid or not. */
|
|
493
|
+
function isMcpPath(path) {
|
|
494
|
+
return mcpEndpoint(path) !== null;
|
|
495
|
+
}
|
|
459
496
|
export function createMcpRoute(opts) {
|
|
460
497
|
const configuredOrigins = opts.config.allowedOrigins;
|
|
461
498
|
const isExactOrigin = (value) => {
|
|
@@ -490,8 +527,7 @@ export function createMcpRoute(opts) {
|
|
|
490
527
|
return hostname === "localhost" || hostname === "[::1]" || /^127\.\d+\.\d+\.\d+$/.test(hostname);
|
|
491
528
|
};
|
|
492
529
|
const rejectOrigin = (request) => {
|
|
493
|
-
|
|
494
|
-
if (path !== "/mcp" && !path.startsWith("/mcp/"))
|
|
530
|
+
if (!isMcpPath(new URL(request.url).pathname))
|
|
495
531
|
return null;
|
|
496
532
|
const origin = request.headers.get("Origin");
|
|
497
533
|
if (origin === null || allowsOrigin(origin))
|
|
@@ -516,25 +552,37 @@ export function createMcpRoute(opts) {
|
|
|
516
552
|
suppressedAdmissionWarnings = 0;
|
|
517
553
|
};
|
|
518
554
|
function routeMcp(context) {
|
|
519
|
-
const { path, request, baseUrl
|
|
555
|
+
const { path, request, baseUrl } = context;
|
|
520
556
|
// Protected-resource metadata stays public, including when a grant is
|
|
521
557
|
// configured, so a fresh OAuth client can discover how to authenticate.
|
|
522
|
-
|
|
558
|
+
const endpoint = mcpEndpoint(path);
|
|
559
|
+
if (!endpoint)
|
|
523
560
|
return Effect.succeed(null);
|
|
524
561
|
// Admission, refusal bodies, SDK serialization and connector work share
|
|
525
562
|
// one set. Connector contexts still receive an opaque scope, never the
|
|
526
563
|
// inbound Request or its headers.
|
|
527
564
|
const requestScope = {};
|
|
528
565
|
const sentSecrets = sentSecretsForRequest(requestScope, sentSecretsForRequest(request));
|
|
529
|
-
const poolName =
|
|
566
|
+
const poolName = endpoint.kind === "pool" || endpoint.kind === "agent" ? endpoint.pool : undefined;
|
|
567
|
+
const agentEndpoint = endpoint.kind === "agent";
|
|
568
|
+
// Access attaches `ctx.access` even to a path it bypasses; an agent
|
|
569
|
+
// endpoint never reads it, so nothing past this point receives it.
|
|
570
|
+
const runtimeContext = agentEndpoint && context.runtimeContext
|
|
571
|
+
? { waitUntil: context.runtimeContext.waitUntil.bind(context.runtimeContext) }
|
|
572
|
+
: context.runtimeContext;
|
|
530
573
|
const origin = request.headers.get("Origin");
|
|
531
574
|
const allowed = origin === null || allowsOrigin(origin);
|
|
532
575
|
const cors = (response) => withMcpCors(response, request, configuredOrigins === "*" ? "*" : allowed ? origin : null);
|
|
576
|
+
const notFound = () => cors(mcpRefusal(sentSecrets, 404, -33004, "MCP endpoint not found."));
|
|
533
577
|
// DNS-rebinding refusals cost neither a permit nor an auth lookup. This
|
|
534
578
|
// local header check also guards OPTIONS before any provider metadata.
|
|
535
579
|
const refusal = rejectOrigin(request);
|
|
536
580
|
if (refusal)
|
|
537
581
|
return Effect.succeed(refusal);
|
|
582
|
+
// Every other spelling in the agent namespace is the pool 404, before any
|
|
583
|
+
// admission or auth: no decoding, case folding, or slash collapsing.
|
|
584
|
+
if (endpoint.kind === "refused")
|
|
585
|
+
return Effect.succeed(notFound());
|
|
538
586
|
if (request.method === "OPTIONS") {
|
|
539
587
|
return Effect.succeed(cors(new Response(null, { status: 204 })));
|
|
540
588
|
}
|
|
@@ -555,38 +603,30 @@ export function createMcpRoute(opts) {
|
|
|
555
603
|
if (deadlineTimer !== undefined)
|
|
556
604
|
clearTimeout(deadlineTimer);
|
|
557
605
|
}));
|
|
558
|
-
//
|
|
559
|
-
//
|
|
606
|
+
// A request naming `subscriptions/listen` defers its permit: nothing of
|
|
607
|
+
// its body is read until auth and endpoint admission pass, and then at
|
|
608
|
+
// most MAX_LISTEN_CLASSIFICATION_BYTES (#857). Only a body-confirmed
|
|
609
|
+
// modern listen goes without a permit; trusting the header alone would
|
|
610
|
+
// let a legacy tools/call bypass the pool, so anything else takes one
|
|
611
|
+
// after classification. Every other request takes its permit here,
|
|
612
|
+
// before auth, and no body is read before auth on any path.
|
|
560
613
|
const startedAt = Date.now();
|
|
561
614
|
const maxDurationMs = opts.requestAdmission.maxDurationMs;
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
if (
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
}
|
|
574
|
-
const admission = listen
|
|
615
|
+
const listenHeader = request.method === "POST" && request.headers.get("Mcp-Method") === "subscriptions/listen";
|
|
616
|
+
const refuseAdmission = (error) => {
|
|
617
|
+
if (!(error instanceof ExecutorAdmissionError))
|
|
618
|
+
return Effect.fail(error);
|
|
619
|
+
if (error.code === "executor_cancelled")
|
|
620
|
+
return Effect.fail(request.signal.reason ?? error);
|
|
621
|
+
if (error.code === "executor_overloaded")
|
|
622
|
+
warnAdmissionRejected(error);
|
|
623
|
+
return Effect.succeed(cors(requestAdmissionFailure(sentSecrets, error)));
|
|
624
|
+
};
|
|
625
|
+
const admission = listenHeader
|
|
575
626
|
? Result.succeed(undefined)
|
|
576
627
|
: yield* Effect.result(admitted(opts.requestAdmission, request.signal));
|
|
577
|
-
if (Result.isFailure(admission))
|
|
578
|
-
|
|
579
|
-
if (!(error instanceof ExecutorAdmissionError)) {
|
|
580
|
-
return yield* Effect.fail(error);
|
|
581
|
-
}
|
|
582
|
-
if (error.code === "executor_cancelled") {
|
|
583
|
-
return yield* Effect.fail(request.signal.reason ?? error);
|
|
584
|
-
}
|
|
585
|
-
if (error.code === "executor_overloaded") {
|
|
586
|
-
warnAdmissionRejected(error);
|
|
587
|
-
}
|
|
588
|
-
return cors(requestAdmissionFailure(sentSecrets, error));
|
|
589
|
-
}
|
|
628
|
+
if (Result.isFailure(admission))
|
|
629
|
+
return yield* refuseAdmission(admission.failure);
|
|
590
630
|
const remainingMs = admission.success
|
|
591
631
|
? admission.success.remainingMs()
|
|
592
632
|
: maxDurationMs === undefined
|
|
@@ -613,8 +653,34 @@ export function createMcpRoute(opts) {
|
|
|
613
653
|
// Effect `authorize` would put Effect in the /activity bundle, which
|
|
614
654
|
// shares it, to save only the lookups an abandoned authorization
|
|
615
655
|
// finishes on its own.
|
|
656
|
+
// Agent endpoints consult only the `cta_` adapter; human endpoints refuse `cta_`.
|
|
657
|
+
const admit = () => agentEndpoint
|
|
658
|
+
? authorizeAgent(localRequest, baseUrl, opts.config.auth)
|
|
659
|
+
: authorize(localRequest, baseUrl, opts.config.auth, runtimeContext);
|
|
660
|
+
// Plain /mcp admits maintainers and serves the whole catalog; a pool
|
|
661
|
+
// admits its members, people at /mcp/<pool> and agents at
|
|
662
|
+
// /agent/<pool>, and serves exactly its tools (INV-4).
|
|
663
|
+
const scopeFor = (admitted) => {
|
|
664
|
+
if (poolName === undefined) {
|
|
665
|
+
return admitsMaintainer(opts, admitted)
|
|
666
|
+
? { access: { connectorIds: "all" }, readOnly: opts.config.readOnly }
|
|
667
|
+
: undefined;
|
|
668
|
+
}
|
|
669
|
+
const pool = agentEndpoint
|
|
670
|
+
? agentPool(opts, poolName, admitted.identity)
|
|
671
|
+
: memberPool(opts, poolName, admitted.identity);
|
|
672
|
+
// The owner travels with the scope, so both pool endpoints set the
|
|
673
|
+
// same caller facts (#837 P5: agents act as the pool owner).
|
|
674
|
+
return pool
|
|
675
|
+
? {
|
|
676
|
+
access: pool.access,
|
|
677
|
+
readOnly: pool.readOnly,
|
|
678
|
+
...(pool.owner !== undefined ? { poolOwner: pool.owner } : {}),
|
|
679
|
+
}
|
|
680
|
+
: undefined;
|
|
681
|
+
};
|
|
616
682
|
const handled = Effect.gen(function* () {
|
|
617
|
-
const authz = yield* Effect.promise(
|
|
683
|
+
const authz = yield* Effect.promise(admit);
|
|
618
684
|
if (!authz.ok) {
|
|
619
685
|
const response = authz.response;
|
|
620
686
|
// Preserve the auth adapter's challenge and status. The MCP host must
|
|
@@ -641,83 +707,80 @@ export function createMcpRoute(opts) {
|
|
|
641
707
|
},
|
|
642
708
|
})), { status: response.status, headers }));
|
|
643
709
|
}
|
|
644
|
-
// A
|
|
645
|
-
//
|
|
646
|
-
// one identical 404 so a credential never enumerates the other pools;
|
|
710
|
+
// A non-maintainer, a non-member, and an undeclared pool are one
|
|
711
|
+
// identical 404 so a credential never enumerates the other pools;
|
|
647
712
|
// the operator log is where the reason lives.
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
});
|
|
661
|
-
|
|
662
|
-
logFailure(opts.config.logger, "MCP default endpoint request denied", failureRecord({ reason: denialReason }));
|
|
663
|
-
return cors(mcpRefusal(sentSecrets, 404, -33004, "MCP endpoint not found."));
|
|
664
|
-
}
|
|
713
|
+
const scope = scopeFor(authz);
|
|
714
|
+
if (!scope) {
|
|
715
|
+
logFailure(opts.config.logger, poolName === undefined
|
|
716
|
+
? "MCP default endpoint request denied"
|
|
717
|
+
: agentEndpoint
|
|
718
|
+
? "MCP agent endpoint request denied"
|
|
719
|
+
: "MCP pool request denied", failureRecord({
|
|
720
|
+
reason: poolName === undefined
|
|
721
|
+
? "not_maintainer"
|
|
722
|
+
: opts.pools.has(poolName)
|
|
723
|
+
? "not_pool_member"
|
|
724
|
+
: "pool_not_declared",
|
|
725
|
+
}));
|
|
726
|
+
return notFound();
|
|
665
727
|
}
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
const
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
return cors(mcpRefusal(sentSecrets, 404, -33004, "MCP endpoint not found."));
|
|
728
|
+
const { access, readOnly, poolOwner } = scope;
|
|
729
|
+
if (listenHeader) {
|
|
730
|
+
const verdict = yield* Effect.promise(() => classifyListen(localRequest, localAbort.signal));
|
|
731
|
+
// The deadline timer aborts a stalled read; its body is already cancelled.
|
|
732
|
+
if (verdict === "aborted") {
|
|
733
|
+
return cors(mcpRefusal(sentSecrets, 504, -33003, "MCP request lifetime exceeded."));
|
|
734
|
+
}
|
|
735
|
+
if (verdict === "too_large") {
|
|
736
|
+
return cors(mcpRefusal(sentSecrets, 413, -33006, "MCP request body too large."));
|
|
737
|
+
}
|
|
738
|
+
if (verdict === "other") {
|
|
739
|
+
const late = yield* Effect.result(admitted(opts.requestAdmission, request.signal));
|
|
740
|
+
if (Result.isFailure(late))
|
|
741
|
+
return yield* refuseAdmission(late.failure);
|
|
681
742
|
}
|
|
682
|
-
access = intersectAccess(authz, pool.access);
|
|
683
|
-
readOnly = pool.readOnly;
|
|
684
743
|
}
|
|
744
|
+
// The caller rides on the view, and the paging recheck builds the
|
|
745
|
+
// same one: an agent's Workspace visibility depends on the owner.
|
|
746
|
+
const callerFor = (identity, owner) => ({
|
|
747
|
+
identity,
|
|
748
|
+
// `authorize` admits an open deployment's every request as the
|
|
749
|
+
// anonymous actor; only a provider's `ok` is an authentication.
|
|
750
|
+
authenticated: opts.config.auth.length > 0,
|
|
751
|
+
// Both pool endpoints build the caller here from the same
|
|
752
|
+
// admitted scope, so an agent at /agent/<pool> carries the owner.
|
|
753
|
+
...(poolName !== undefined ? { pool: poolName } : {}),
|
|
754
|
+
...(owner !== undefined ? { poolOwner: owner } : {}),
|
|
755
|
+
});
|
|
685
756
|
let scopedRegistry;
|
|
686
757
|
try {
|
|
687
|
-
validateAuthPermissions(authz, opts.registry);
|
|
688
758
|
scopedRegistry = opts.registry.scoped({
|
|
689
759
|
connectorIds: access.connectorIds,
|
|
690
760
|
...(access.toolAccess ? { toolAccess: access.toolAccess } : {}),
|
|
691
|
-
...(access.guardedToolAccess ? { guardedToolAccess: access.guardedToolAccess } : {}),
|
|
692
761
|
...(authz.subjectKey ? { subjectKey: authz.subjectKey } : {}),
|
|
693
762
|
...(authz.principalKey ? { principalKey: authz.principalKey } : {}),
|
|
694
763
|
endpoint: new URL(request.url).pathname,
|
|
695
764
|
origin: request.headers.get("Origin"),
|
|
696
765
|
currentResultAccess: async (address, classification, signal, access) => {
|
|
697
766
|
signal?.throwIfAborted();
|
|
698
|
-
const current = await
|
|
767
|
+
const current = await admit();
|
|
699
768
|
if (!current.ok) {
|
|
700
769
|
await current.response.body?.cancel().catch(() => { });
|
|
701
770
|
return false;
|
|
702
771
|
}
|
|
703
772
|
if (current.subjectKey !== authz.subjectKey || current.principalKey !== authz.principalKey)
|
|
704
773
|
return false;
|
|
705
|
-
|
|
706
|
-
|
|
774
|
+
// Paging re-admits the caller at this endpoint, as a new request would.
|
|
775
|
+
const currentScope = scopeFor(current);
|
|
776
|
+
if (!currentScope)
|
|
707
777
|
return false;
|
|
708
|
-
|
|
709
|
-
let currentReadOnly = opts.config.readOnly;
|
|
710
|
-
if (poolName !== undefined) {
|
|
711
|
-
const pool = opts.pools.get(poolName);
|
|
712
|
-
if (!pool || (await pool.grant(current.identity)) !== true)
|
|
713
|
-
return false;
|
|
714
|
-
currentAccess = intersectAccess(current, pool.access);
|
|
715
|
-
currentReadOnly = pool.readOnly;
|
|
716
|
-
}
|
|
778
|
+
const { access: currentAccess, readOnly: currentReadOnly, poolOwner: currentOwner } = currentScope;
|
|
717
779
|
const view = opts.registry.scoped({
|
|
718
780
|
...currentAccess,
|
|
719
781
|
...(current.subjectKey ? { subjectKey: current.subjectKey } : {}),
|
|
720
782
|
...(current.principalKey ? { principalKey: current.principalKey } : {}),
|
|
783
|
+
caller: callerFor(current.identity, currentOwner),
|
|
721
784
|
});
|
|
722
785
|
if (classification === "write" && currentReadOnly)
|
|
723
786
|
return false;
|
|
@@ -742,13 +805,7 @@ export function createMcpRoute(opts) {
|
|
|
742
805
|
await closeConnectorScope(resolved.connector, view.contextFor(resolved.connector.id, baseUrl, requestScope), runtimeContext?.waitUntil?.bind(runtimeContext));
|
|
743
806
|
}
|
|
744
807
|
},
|
|
745
|
-
caller:
|
|
746
|
-
identity: authz.identity,
|
|
747
|
-
// `authorize` admits an open deployment's every request as the
|
|
748
|
-
// anonymous actor; only a provider's `ok` is an authentication.
|
|
749
|
-
authenticated: opts.config.auth.length > 0,
|
|
750
|
-
...(poolName !== undefined ? { pool: poolName } : {}),
|
|
751
|
-
},
|
|
808
|
+
caller: callerFor(authz.identity, poolOwner),
|
|
752
809
|
});
|
|
753
810
|
}
|
|
754
811
|
catch {
|
|
@@ -759,7 +816,7 @@ export function createMcpRoute(opts) {
|
|
|
759
816
|
}
|
|
760
817
|
return cors(yield* serveMcp(localRequest, localAbort.signal, requestScope, opts, baseUrl, authz.actor, scopedRegistry, (id) => {
|
|
761
818
|
const connector = scopedRegistry.getConnector(id);
|
|
762
|
-
return Boolean(connector && mayManageConnector(authz, connector));
|
|
819
|
+
return Boolean(connector && mayManageConnector(opts, authz, connector));
|
|
763
820
|
}, authz.principalKey, runtimeContext, readOnly, poolName, authz.identity.principal
|
|
764
821
|
? {
|
|
765
822
|
kind: authz.actor.kind,
|
|
@@ -2,7 +2,7 @@ import { OAuthRevocationError } from "../auth/downstream-revocation.js";
|
|
|
2
2
|
import { Effect, Result } from "effect";
|
|
3
3
|
import { closeScope } from "../runtime/connector-scope.js";
|
|
4
4
|
import { oauthConnectUrl, oauthConnectUnavailable } from "../oauth-handoff.js";
|
|
5
|
-
import { authorizedPerson, refuse, serveOperator,
|
|
5
|
+
import { authorizedPerson, refuse, serveOperator, managementRegistry, Answer } from "./operator.js";
|
|
6
6
|
import { failureRecord, logFailure } from "../operator-record.js";
|
|
7
7
|
import { mayManageConnector, isSameOrigin, privateJson } from "./shared.js";
|
|
8
8
|
/** A UI action signs its requested mode; the verified /connect visit applies it. */
|
|
@@ -20,14 +20,17 @@ function oauthManagementRequest(context, connectorId) {
|
|
|
20
20
|
return yield* refuse("same-origin request required", 403);
|
|
21
21
|
}
|
|
22
22
|
const authz = yield* authorizedPerson(context, "OAuth management");
|
|
23
|
-
const registry = yield*
|
|
23
|
+
const registry = yield* managementRegistry(context, authz);
|
|
24
24
|
const connector = registry.getConnector(connectorId);
|
|
25
25
|
const disconnectAuth = connector?.disconnectAuth?.bind(connector);
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
// Each verb needs only its own hooks (#837): connecting needs the whole
|
|
27
|
+
// flow, through the callback's state check; disconnecting needs only
|
|
28
|
+
// disconnectAuth, so a grant provisioned out of band can still be dropped.
|
|
29
|
+
const connects = Boolean(connector?.startAuth && connector.finishAuth && connector.verifyState);
|
|
30
|
+
if (!connector || (!connects && !disconnectAuth)) {
|
|
28
31
|
return yield* refuse("unknown OAuth connector", 404);
|
|
29
32
|
}
|
|
30
|
-
if (!mayManageConnector(authz, connector)) {
|
|
33
|
+
if (!mayManageConnector(opts, authz, connector)) {
|
|
31
34
|
return yield* refuse("credential management is not permitted", 403);
|
|
32
35
|
}
|
|
33
36
|
if (connector.authScope === "personal" && !authz.principalKey) {
|
|
@@ -41,6 +44,9 @@ function oauthManagementRequest(context, connectorId) {
|
|
|
41
44
|
if (mode === null) {
|
|
42
45
|
return yield* refuse('mode must be "continue" or "restart"', 400);
|
|
43
46
|
}
|
|
47
|
+
if (disconnecting ? !disconnectAuth : !connects) {
|
|
48
|
+
return yield* refuse("unknown OAuth connector", 404);
|
|
49
|
+
}
|
|
44
50
|
if (!disconnecting) {
|
|
45
51
|
const unavailable = oauthConnectUnavailable(opts);
|
|
46
52
|
if (unavailable)
|
package/dist/routes/oauth.js
CHANGED
|
@@ -7,7 +7,7 @@ import { failureRecord, logFailure } from "../operator-record.js";
|
|
|
7
7
|
import { oauthFlowKeys } from "../storage/keys.js";
|
|
8
8
|
import { escapeHtml, renderPage, resolveBranding, STATUS_ICONS } from "../branding.js";
|
|
9
9
|
import { oauthCallbackOutcome, providerErrorReason } from "../oauth-callback-outcome.js";
|
|
10
|
-
import { authorizeUiIdentity, withSessionCookies, mayManageConnector,
|
|
10
|
+
import { authorizeUiIdentity, withSessionCookies, mayManageConnector, loggableValue, } from "./shared.js";
|
|
11
11
|
const TONE_MARKS = {
|
|
12
12
|
ok: { className: " ok", icon: STATUS_ICONS.ok, label: "Connected" },
|
|
13
13
|
declined: { className: "", icon: STATUS_ICONS.declined, label: "Not connected" },
|
|
@@ -38,7 +38,7 @@ function html(reason, opts, connector) {
|
|
|
38
38
|
const details = outcome.fixPrompt
|
|
39
39
|
? `<details class="status-details">
|
|
40
40
|
<summary>Details for the operator</summary>
|
|
41
|
-
<p>If this keeps happening, send this to whoever runs ${escapeHtml(brand.productName)}. It is
|
|
41
|
+
<p>If this keeps happening, send this to whoever runs ${escapeHtml(brand.productName)}. It is for a coding agent and holds nothing from the provider.</p>
|
|
42
42
|
<pre>${escapeHtml(outcome.fixPrompt)}</pre>
|
|
43
43
|
</details>`
|
|
44
44
|
: "";
|
|
@@ -55,7 +55,7 @@ function html(reason, opts, connector) {
|
|
|
55
55
|
</section>
|
|
56
56
|
</main>`;
|
|
57
57
|
return new Response(renderPage(opts.config.ui?.branding, {
|
|
58
|
-
title: `${outcome.heading}
|
|
58
|
+
title: `${outcome.heading} · ${brand.pageTitle}`,
|
|
59
59
|
uiMounted,
|
|
60
60
|
body,
|
|
61
61
|
}), { status: outcome.status, headers: htmlSecurityHeaders({ "Content-Type": "text/html; charset=utf-8" }) });
|
|
@@ -167,7 +167,7 @@ async function finishOAuthCallback(context) {
|
|
|
167
167
|
}
|
|
168
168
|
try {
|
|
169
169
|
const expectedPrincipalKey = callbackTarget?.principalKey;
|
|
170
|
-
const browserIdentity = await authorizeUiIdentity(context.request, baseUrl, opts.config.auth, "OAuth callback", context.runtimeContext
|
|
170
|
+
const browserIdentity = await authorizeUiIdentity(context.request, baseUrl, opts.config.auth, "OAuth callback", context.runtimeContext);
|
|
171
171
|
if (!browserIdentity.ok) {
|
|
172
172
|
if (browserIdentity.response.status === 401 && authorizationCredential(context.request).kind !== "absent")
|
|
173
173
|
return browserIdentity.response;
|
|
@@ -179,13 +179,7 @@ async function finishOAuthCallback(context) {
|
|
|
179
179
|
}
|
|
180
180
|
if (!expectedPrincipalKey || browserIdentity.principalKey !== expectedPrincipalKey)
|
|
181
181
|
return refused();
|
|
182
|
-
|
|
183
|
-
validateAuthPermissions(browserIdentity, opts.registry);
|
|
184
|
-
}
|
|
185
|
-
catch {
|
|
186
|
-
return refused();
|
|
187
|
-
}
|
|
188
|
-
if (!mayManageConnector(browserIdentity, connector))
|
|
182
|
+
if (!mayManageConnector(opts, browserIdentity, connector))
|
|
189
183
|
return refused();
|
|
190
184
|
// CSRF / login-fixation guard: this route is intentionally public, so verify
|
|
191
185
|
// the `state` matches the flow connecta started BEFORE exchanging the code.
|