machine-bridge-mcp 1.1.5 → 1.2.1
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 +21 -0
- package/CODE_OF_CONDUCT.md +24 -0
- package/CONTRIBUTING.md +3 -1
- package/GOVERNANCE.md +50 -0
- package/README.md +26 -0
- package/SUPPORT.md +31 -0
- package/browser-extension/browser-operations.js +10 -4
- package/browser-extension/devtools-input.js +2 -2
- package/browser-extension/manifest.json +2 -2
- package/browser-extension/page-automation.js +1 -1
- package/docs/ARCHITECTURE.md +14 -17
- package/docs/AUDIT.md +28 -0
- package/docs/ENGINEERING.md +8 -2
- package/docs/PROJECT_STANDARDS.md +4 -2
- package/docs/TESTING.md +9 -3
- package/docs/UPGRADING.md +32 -0
- package/package.json +12 -4
- package/scripts/coverage-check.mjs +27 -10
- package/src/local/account-access.mjs +7 -5
- package/src/local/agent-context.mjs +7 -148
- package/src/local/agent-contract.mjs +206 -0
- package/src/local/browser-bridge.mjs +30 -281
- package/src/local/browser-extension-protocol.mjs +19 -4
- package/src/local/browser-operation-service.mjs +325 -0
- package/src/local/call-registry.mjs +44 -1
- package/src/local/capability-ranking.mjs +13 -0
- package/src/local/cli-local-admin.mjs +74 -38
- package/src/local/cli-options.mjs +18 -18
- package/src/local/cli-policy.mjs +1 -1
- package/src/local/cli-service.mjs +132 -0
- package/src/local/cli.mjs +27 -109
- package/src/local/daemon-process.mjs +1 -1
- package/src/local/full-access-test.mjs +1 -1
- package/src/local/job-runner.mjs +9 -3
- package/src/local/managed-job-plan.mjs +3 -3
- package/src/local/monotonic-deadline.mjs +7 -0
- package/src/local/numbers.mjs +8 -0
- package/src/local/policy.mjs +97 -16
- package/src/local/project-metadata.mjs +7 -1
- package/src/local/project-package.mjs +1 -1
- package/src/local/records.mjs +6 -0
- package/src/local/resource-operations.mjs +1 -1
- package/src/local/runtime-capabilities.mjs +66 -0
- package/src/local/runtime-diagnostics.mjs +91 -0
- package/src/local/runtime-reporting.mjs +113 -0
- package/src/local/runtime.mjs +54 -191
- package/src/local/service-convergence.mjs +1 -1
- package/src/local/service.mjs +1 -1
- package/src/local/state.mjs +1 -1
- package/src/local/windows-service.mjs +1 -1
- package/src/local/worker-deployment.mjs +15 -10
- package/src/local/worker-health.mjs +8 -4
- package/src/worker/access.ts +8 -7
- package/src/worker/account-admin.ts +2 -2
- package/src/worker/authority.ts +3 -3
- package/src/worker/http.ts +2 -2
- package/src/worker/index.ts +29 -337
- package/src/worker/oauth-controller.ts +343 -0
- package/src/worker/oauth-state.ts +1 -1
- package/src/worker/oauth-tokens.ts +2 -2
- package/src/worker/tool-catalog.ts +1 -1
- package/tsconfig.json +2 -1
- package/tsconfig.local.json +27 -0
package/src/worker/index.ts
CHANGED
|
@@ -1,49 +1,33 @@
|
|
|
1
1
|
import { DurableObject } from "cloudflare:workers";
|
|
2
|
-
import serverMetadata from "../shared/server-metadata.json";
|
|
3
|
-
import { PendingCallRegistrationError, PendingCallRegistry } from "./pending-calls";
|
|
4
|
-
import { mcpClientRequestKey, resolveMcpSession } from "./mcp-session";
|
|
5
|
-
import { daemonToolTimeoutMs } from "./tool-timeout";
|
|
6
|
-
import { WorkerObservability } from "./observability";
|
|
7
|
-
import { daemonToolError, publicWorkerToolError, WorkerToolError } from "./errors";
|
|
8
|
-
import { sanitizeDaemonPolicy, sanitizeDaemonTools, type DaemonPolicy } from "./policy";
|
|
9
|
-
import { accountRoleAllowsTool, accountRoleToolNames, type AccountRole } from "./access";
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
2
|
+
import serverMetadata from "../shared/server-metadata.json" with { type: "json" };
|
|
3
|
+
import { PendingCallRegistrationError, PendingCallRegistry } from "./pending-calls.ts";
|
|
4
|
+
import { mcpClientRequestKey, resolveMcpSession } from "./mcp-session.ts";
|
|
5
|
+
import { daemonToolTimeoutMs } from "./tool-timeout.ts";
|
|
6
|
+
import { WorkerObservability } from "./observability.ts";
|
|
7
|
+
import { daemonToolError, publicWorkerToolError, WorkerToolError } from "./errors.ts";
|
|
8
|
+
import { sanitizeDaemonPolicy, sanitizeDaemonTools, type DaemonPolicy } from "./policy.ts";
|
|
9
|
+
import { accountRoleAllowsTool, accountRoleToolNames, type AccountRole } from "./access.ts";
|
|
10
|
+
import { OAuthController, type AuthorizedToken, type OAuthControllerEnv } from "./oauth-controller.ts";
|
|
11
|
+
import { accountAuthoritySnapshot, decorateProjectOverview, describeDaemonCeiling } from "./authority.ts";
|
|
12
|
+
import { serverInfoTool, workspaceTools } from "./tool-catalog.ts";
|
|
13
|
+
import { OFFLINE_ACCESS_SCOPE, randomToken, safeEqual } from "./oauth-state.ts";
|
|
14
14
|
import {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
type OAuthClient, type OAuthStore, type ValidatedAuthorization,
|
|
19
|
-
} from "./oauth-state";
|
|
20
|
-
import {
|
|
21
|
-
HttpError, applyCors, authorizationRedirectLocation, baseUrl, bearerToken, corsPreflight, escapeHtml,
|
|
22
|
-
html, json, methodNotAllowed, normalizeDisplayText, normalizeRedirectUri, oauthRedirect, sanitizeMetadataText,
|
|
23
|
-
parseJsonRequest, parseRequestBody, searchParamsEntries, searchParamsObject, workerErrorClass,
|
|
24
|
-
} from "./http";
|
|
15
|
+
HttpError, applyCors, baseUrl, bearerToken, corsPreflight, json, methodNotAllowed, sanitizeMetadataText,
|
|
16
|
+
parseJsonRequest, workerErrorClass,
|
|
17
|
+
} from "./http.ts";
|
|
25
18
|
|
|
26
19
|
const SERVER_NAME = String(serverMetadata.name);
|
|
27
|
-
const SERVER_VERSION = "1.1
|
|
20
|
+
const SERVER_VERSION = "1.2.1";
|
|
28
21
|
const MCP_PROTOCOL_VERSION = String(serverMetadata.protocolVersion);
|
|
29
22
|
const MCP_SUPPORTED_PROTOCOL_VERSIONS = serverMetadata.supportedProtocolVersions.map((value) => String(value));
|
|
30
23
|
const JSONRPC_VERSION = "2.0";
|
|
31
24
|
const DEFAULT_MAX_BODY_BYTES = 8 * 1024 * 1024;
|
|
32
25
|
const MAX_BODY_BYTES = 16 * 1024 * 1024;
|
|
33
|
-
const OAUTH_BODY_LIMIT_BYTES = 64 * 1024;
|
|
34
26
|
const MAX_PENDING_CALLS = 32;
|
|
35
27
|
const MAX_DAEMON_MESSAGE_BYTES = 8 * 1024 * 1024;
|
|
36
28
|
const DAEMON_HELLO_TIMEOUT_MS = 10_000;
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const MAX_OAUTH_CLIENTS_PER_IDENTITY = 5;
|
|
40
|
-
const OAUTH_CLIENT_IDLE_TTL_SECONDS = 60 * 60 * 24 * 90;
|
|
41
|
-
const MAX_CODES_PER_CLIENT = 10;
|
|
42
|
-
const MAX_OAUTH_CODES = 200;
|
|
43
|
-
const MAX_AUTH_FAILURE_IDENTITIES = 200;
|
|
44
|
-
const AUTHORIZATION_FIELDS = new Set(["response_type", "client_id", "redirect_uri", "code_challenge", "code_challenge_method", "scope", "resource", "state"]);
|
|
45
|
-
|
|
46
|
-
interface BridgeEnv {
|
|
29
|
+
|
|
30
|
+
interface BridgeEnv extends OAuthControllerEnv {
|
|
47
31
|
BRIDGE: DurableObjectNamespace<BridgeRoom>;
|
|
48
32
|
ACCOUNT_ADMIN_SECRET: string;
|
|
49
33
|
DAEMON_SHARED_SECRET: string;
|
|
@@ -68,23 +52,16 @@ interface DaemonAttachment {
|
|
|
68
52
|
tools?: string[];
|
|
69
53
|
}
|
|
70
54
|
|
|
71
|
-
interface AuthorizedToken {
|
|
72
|
-
tokenKey: string;
|
|
73
|
-
clientId: string;
|
|
74
|
-
accountId: string;
|
|
75
|
-
accountVersion: number;
|
|
76
|
-
role: AccountRole;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
55
|
const MCP_INSTRUCTIONS = serverMetadata.instructions.map((value) => String(value)).join("\n");
|
|
80
56
|
|
|
81
57
|
export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
82
58
|
private readonly pending = new PendingCallRegistry(MAX_PENDING_CALLS);
|
|
83
59
|
private readonly observability = new WorkerObservability();
|
|
84
|
-
private
|
|
60
|
+
private readonly oauth: OAuthController;
|
|
85
61
|
|
|
86
62
|
constructor(ctx: DurableObjectState, env: BridgeEnv) {
|
|
87
63
|
super(ctx, env);
|
|
64
|
+
this.oauth = new OAuthController(ctx, env, SERVER_NAME);
|
|
88
65
|
}
|
|
89
66
|
|
|
90
67
|
async fetch(request: Request): Promise<Response> {
|
|
@@ -125,20 +102,20 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
125
102
|
if (request.method !== "GET") return methodNotAllowed("GET");
|
|
126
103
|
return json(this.protectedResourceMetadata(base));
|
|
127
104
|
}
|
|
128
|
-
if (url.pathname === "/admin/accounts") return await this.handleAccountAdmin(request, "accounts");
|
|
129
|
-
if (url.pathname === "/admin/accounts/rotate-password") return await this.handleAccountAdmin(request, "rotate-password");
|
|
130
|
-
|
|
105
|
+
if (url.pathname === "/admin/accounts") return await this.oauth.handleAccountAdmin(request, "accounts");
|
|
106
|
+
if (url.pathname === "/admin/accounts/rotate-password") return await this.oauth.handleAccountAdmin(request, "rotate-password");
|
|
107
|
+
if (url.pathname === "/oauth/register") {
|
|
131
108
|
if (request.method !== "POST") return methodNotAllowed("POST");
|
|
132
|
-
return await this.registerClient(request);
|
|
109
|
+
return await this.oauth.registerClient(request);
|
|
133
110
|
}
|
|
134
111
|
if (url.pathname === "/oauth/authorize") {
|
|
135
|
-
if (request.method === "GET") return await this.authorizeGet(request, base);
|
|
136
|
-
if (request.method === "POST") return await this.authorizeSubmit(request, base);
|
|
112
|
+
if (request.method === "GET") return await this.oauth.authorizeGet(request, base);
|
|
113
|
+
if (request.method === "POST") return await this.oauth.authorizeSubmit(request, base);
|
|
137
114
|
return methodNotAllowed("GET, POST");
|
|
138
115
|
}
|
|
139
116
|
if (url.pathname === "/oauth/token") {
|
|
140
117
|
if (request.method !== "POST") return methodNotAllowed("POST");
|
|
141
|
-
return await this.exchangeToken(request, base);
|
|
118
|
+
return await this.oauth.exchangeToken(request, base);
|
|
142
119
|
}
|
|
143
120
|
if (url.pathname === "/daemon/ws") {
|
|
144
121
|
if (request.method !== "GET") return methodNotAllowed("GET");
|
|
@@ -270,7 +247,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
270
247
|
});
|
|
271
248
|
}
|
|
272
249
|
|
|
273
|
-
const authorized = await this.verifyAccessToken(bearerToken(request), base);
|
|
250
|
+
const authorized = await this.oauth.verifyAccessToken(bearerToken(request), base);
|
|
274
251
|
if (!authorized) {
|
|
275
252
|
try { await request.arrayBuffer(); } catch { /* The rejected body may already be unavailable; the 401 remains authoritative. */ }
|
|
276
253
|
return new Response("OAuth bearer token required", {
|
|
@@ -290,7 +267,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
290
267
|
const protocolError = validateProtocolVersionHeader(request, body);
|
|
291
268
|
if (protocolError) return json(protocolError, 400);
|
|
292
269
|
|
|
293
|
-
const session = await resolveMcpSession(request, body.method, this.identityKey(), authorized.tokenKey);
|
|
270
|
+
const session = await resolveMcpSession(request, body.method, this.oauth.identityKey(), authorized.tokenKey);
|
|
294
271
|
if (session.kind === "invalid") return json(rpcError(body.id, -32001, "MCP session not found"), 404);
|
|
295
272
|
const response = await this.dispatchJsonRpc(body, base, authorized, session.kind === "active" ? session.sessionId : "");
|
|
296
273
|
if (response === null) return new Response(null, { status: 202 });
|
|
@@ -606,287 +583,6 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
606
583
|
};
|
|
607
584
|
}
|
|
608
585
|
|
|
609
|
-
private async oauthStore(): Promise<OAuthStore> {
|
|
610
|
-
const raw = await this.ctx.storage.get<unknown>("oauth");
|
|
611
|
-
if (raw !== undefined && !isCurrentOAuthStore(raw)) {
|
|
612
|
-
throw new HttpError(503, "oauth_state_schema_mismatch", "OAuth state requires the one-time multi-account upgrade");
|
|
613
|
-
}
|
|
614
|
-
const store = isCurrentOAuthStore(raw) ? raw : emptyOAuthStore();
|
|
615
|
-
let changed = false;
|
|
616
|
-
const now = Math.floor(Date.now() / 1000);
|
|
617
|
-
|
|
618
|
-
for (const [code, value] of Object.entries(store.codes)) {
|
|
619
|
-
const account = store.accounts[value.account_id];
|
|
620
|
-
if (value.expires_at <= now || !account || !account.active || account.version !== value.account_version || account.role !== value.role) {
|
|
621
|
-
delete store.codes[code];
|
|
622
|
-
changed = true;
|
|
623
|
-
}
|
|
624
|
-
}
|
|
625
|
-
for (const [token, value] of Object.entries(store.tokens)) {
|
|
626
|
-
const account = store.accounts[value.account_id];
|
|
627
|
-
if (value.expires_at <= now || !account || !account.active || account.version !== value.account_version || account.role !== value.role) {
|
|
628
|
-
delete store.tokens[token];
|
|
629
|
-
changed = true;
|
|
630
|
-
}
|
|
631
|
-
}
|
|
632
|
-
for (const [identity, value] of Object.entries(store.auth_failures)) {
|
|
633
|
-
if (!identity.startsWith("hmac-sha256:") || value.last_attempt + AUTH_BLOCK_SECONDS <= now) {
|
|
634
|
-
delete store.auth_failures[identity];
|
|
635
|
-
changed = true;
|
|
636
|
-
}
|
|
637
|
-
}
|
|
638
|
-
const activeClientIds = new Set([
|
|
639
|
-
...Object.values(store.codes).map((value) => value.client_id),
|
|
640
|
-
...Object.values(store.tokens).map((value) => value.client_id),
|
|
641
|
-
]);
|
|
642
|
-
for (const [clientId, client] of Object.entries(store.clients)) {
|
|
643
|
-
if (client.registration_identity && !client.registration_identity.startsWith("hmac-sha256:")) {
|
|
644
|
-
delete client.registration_identity;
|
|
645
|
-
changed = true;
|
|
646
|
-
}
|
|
647
|
-
const ttl = client.has_been_authorized === false ? OAUTH_UNUSED_CLIENT_TTL_SECONDS : OAUTH_CLIENT_IDLE_TTL_SECONDS;
|
|
648
|
-
if (!activeClientIds.has(clientId) && client.last_used_at + ttl <= now) {
|
|
649
|
-
delete store.clients[clientId];
|
|
650
|
-
changed = true;
|
|
651
|
-
}
|
|
652
|
-
}
|
|
653
|
-
if (changed) await this.ctx.storage.put("oauth", store);
|
|
654
|
-
return store;
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
private async handleAccountAdmin(request: Request, operation: "accounts" | "rotate-password"): Promise<Response> {
|
|
658
|
-
if (!(await accountAdminAuthorized(request, this.env.ACCOUNT_ADMIN_SECRET ?? ""))) return json({ error: "unauthorized" }, 401);
|
|
659
|
-
return this.withOAuthLock(async () => {
|
|
660
|
-
const store = await this.oauthStore();
|
|
661
|
-
return handleAccountAdminOperation({
|
|
662
|
-
request, operation, store, now: Math.floor(Date.now() / 1000),
|
|
663
|
-
save: () => this.ctx.storage.put("oauth", store),
|
|
664
|
-
});
|
|
665
|
-
});
|
|
666
|
-
}
|
|
667
|
-
|
|
668
|
-
private async registerClient(request: Request): Promise<Response> {
|
|
669
|
-
const body = await parseRequestBody(request, OAUTH_BODY_LIMIT_BYTES);
|
|
670
|
-
const redirectUris = body.redirect_uris;
|
|
671
|
-
if (!Array.isArray(redirectUris) || redirectUris.length === 0) {
|
|
672
|
-
return json({ error: "invalid_client_metadata", error_description: "redirect_uris must be a non-empty array" }, 400);
|
|
673
|
-
}
|
|
674
|
-
if (redirectUris.length > 5) {
|
|
675
|
-
return json({ error: "invalid_client_metadata", error_description: "redirect_uris must contain at most 5 entries" }, 400);
|
|
676
|
-
}
|
|
677
|
-
const suppliedRedirectUris = redirectUris.map((item) => String(item));
|
|
678
|
-
if (suppliedRedirectUris.some((item) => item.length > 1024)) {
|
|
679
|
-
return json({ error: "invalid_client_metadata", error_description: "redirect_uri is too long" }, 400);
|
|
680
|
-
}
|
|
681
|
-
const canonicalRedirectUris = suppliedRedirectUris.map(normalizeRedirectUri);
|
|
682
|
-
if (canonicalRedirectUris.some((item) => item === null)) {
|
|
683
|
-
return json({ error: "invalid_client_metadata", error_description: "redirect_uris must be canonicalizable https or local http URLs without credentials or fragments" }, 400);
|
|
684
|
-
}
|
|
685
|
-
const normalized = [...new Set(canonicalRedirectUris as string[])];
|
|
686
|
-
|
|
687
|
-
return this.withOAuthLock(async () => {
|
|
688
|
-
const store = await this.oauthStore();
|
|
689
|
-
const registrationIdentity = await authorizationIdentity(request, this.identityKey());
|
|
690
|
-
const pendingIdentityClientCount = Object.values(store.clients).filter((client) => (
|
|
691
|
-
client.registration_identity === registrationIdentity && client.has_been_authorized === false
|
|
692
|
-
)).length;
|
|
693
|
-
if (pendingIdentityClientCount >= MAX_OAUTH_CLIENTS_PER_IDENTITY) {
|
|
694
|
-
return json({ error: "too_many_requests", error_description: "pending client registration limit reached for this source" }, 429);
|
|
695
|
-
}
|
|
696
|
-
if (Object.keys(store.clients).length >= MAX_OAUTH_CLIENTS) {
|
|
697
|
-
return json({ error: "temporarily_unavailable", error_description: "client registry is full; remove stale state or retry after inactive clients expire" }, 503);
|
|
698
|
-
}
|
|
699
|
-
const now = Math.floor(Date.now() / 1000);
|
|
700
|
-
const client: OAuthClient = {
|
|
701
|
-
client_id: randomToken("mcp_client"),
|
|
702
|
-
client_name: normalizeDisplayText(stringOrUndefined(body.client_name) ?? "MCP Client", 128),
|
|
703
|
-
redirect_uris: normalized,
|
|
704
|
-
created_at: now,
|
|
705
|
-
last_used_at: now,
|
|
706
|
-
has_been_authorized: false,
|
|
707
|
-
registration_identity: registrationIdentity,
|
|
708
|
-
};
|
|
709
|
-
store.clients[client.client_id] = client;
|
|
710
|
-
await this.ctx.storage.put("oauth", store);
|
|
711
|
-
return json({
|
|
712
|
-
client_id: client.client_id,
|
|
713
|
-
client_name: client.client_name,
|
|
714
|
-
redirect_uris: client.redirect_uris,
|
|
715
|
-
grant_types: ["authorization_code", "refresh_token"],
|
|
716
|
-
response_types: ["code"],
|
|
717
|
-
token_endpoint_auth_method: "none",
|
|
718
|
-
client_id_issued_at: client.created_at,
|
|
719
|
-
});
|
|
720
|
-
});
|
|
721
|
-
}
|
|
722
|
-
|
|
723
|
-
private async authorizeGet(request: Request, base: string): Promise<Response> {
|
|
724
|
-
const body = searchParamsObject(new URL(request.url).searchParams);
|
|
725
|
-
return this.withOAuthLock(async () => {
|
|
726
|
-
const store = await this.oauthStore();
|
|
727
|
-
const validation = validateAuthorizationRequest(body, base, SERVER_NAME, store);
|
|
728
|
-
if ("error" in validation) {
|
|
729
|
-
return this.authorizePage(request, base, validation.error, body, validation.status, undefined, false);
|
|
730
|
-
}
|
|
731
|
-
return this.authorizePage(request, base, "", body, 200, validation.value, true);
|
|
732
|
-
});
|
|
733
|
-
}
|
|
734
|
-
|
|
735
|
-
private authorizePage(
|
|
736
|
-
request: Request,
|
|
737
|
-
base: string,
|
|
738
|
-
error = "",
|
|
739
|
-
submitted?: Record<string, unknown>,
|
|
740
|
-
status = 200,
|
|
741
|
-
authorization?: ValidatedAuthorization,
|
|
742
|
-
allowSubmit = true,
|
|
743
|
-
): Response {
|
|
744
|
-
const url = new URL(request.url);
|
|
745
|
-
const sourceEntries = submitted ? Object.entries(submitted) : searchParamsEntries(url.searchParams);
|
|
746
|
-
const hidden = sourceEntries
|
|
747
|
-
.filter(([key]) => AUTHORIZATION_FIELDS.has(key))
|
|
748
|
-
.map(([key, value]) => `<input type="hidden" name="${escapeHtml(key)}" value="${escapeHtml(String(value))}">`)
|
|
749
|
-
.join("\n");
|
|
750
|
-
const resource = normalizeDisplayText(
|
|
751
|
-
authorization?.requestedResource ?? String(submitted?.resource ?? url.searchParams.get("resource") ?? `${base}/mcp`),
|
|
752
|
-
1024,
|
|
753
|
-
`${base}/mcp`,
|
|
754
|
-
);
|
|
755
|
-
const clientBlock = authorization
|
|
756
|
-
? `<p><strong>Client:</strong> ${escapeHtml(authorization.client.client_name)}</p>
|
|
757
|
-
<p><strong>Redirect URI:</strong> <code>${escapeHtml(authorization.redirectUri)}</code></p>`
|
|
758
|
-
: "";
|
|
759
|
-
const errorBlock = error ? `<p role="alert" aria-live="assertive" style="color:#b91c1c; font-weight:600">${escapeHtml(error)}</p>` : "";
|
|
760
|
-
const accountName = normalizeDisplayText(String(submitted?.account_name ?? ""), 64, "");
|
|
761
|
-
const form = allowSubmit
|
|
762
|
-
? `<form method="post" action="/oauth/authorize">
|
|
763
|
-
${hidden}
|
|
764
|
-
<label>Account name<br><input name="account_name" value="${escapeHtml(accountName)}" autocomplete="username" autofocus required style="width: 100%; box-sizing: border-box; padding: 8px;"></label>
|
|
765
|
-
<p><label>Account password<br><input name="account_password" type="password" autocomplete="current-password" required style="width: 100%; box-sizing: border-box; padding: 8px;"></label></p>
|
|
766
|
-
<p><button type="submit">Authorize</button></p>
|
|
767
|
-
</form>`
|
|
768
|
-
: "<p>Authorization cannot continue. Return to the MCP client and start the connection again.</p>";
|
|
769
|
-
const redirectOrigin = authorization ? new URL(authorization.redirectUri).origin : "";
|
|
770
|
-
return html(`<!doctype html>
|
|
771
|
-
<html>
|
|
772
|
-
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Authorize ${SERVER_NAME}</title></head>
|
|
773
|
-
<body style="font-family: system-ui, sans-serif; max-width: 640px; margin: 48px auto; line-height: 1.5; padding: 0 16px;">
|
|
774
|
-
<h1>Authorize ${SERVER_NAME}</h1>
|
|
775
|
-
<p>Only continue if you initiated this MCP connection and recognize the client and redirect URI below.</p>
|
|
776
|
-
${clientBlock}
|
|
777
|
-
<p><strong>Resource:</strong> <code>${escapeHtml(resource)}</code></p>
|
|
778
|
-
${errorBlock}
|
|
779
|
-
${form}
|
|
780
|
-
</body>
|
|
781
|
-
</html>`, status, redirectOrigin);
|
|
782
|
-
}
|
|
783
|
-
|
|
784
|
-
private async authorizeSubmit(request: Request, base: string): Promise<Response> {
|
|
785
|
-
const body = await parseRequestBody(request, OAUTH_BODY_LIMIT_BYTES);
|
|
786
|
-
return this.withOAuthLock(async () => {
|
|
787
|
-
const store = await this.oauthStore();
|
|
788
|
-
const validation = validateAuthorizationRequest(body, base, SERVER_NAME, store);
|
|
789
|
-
if ("error" in validation) {
|
|
790
|
-
return this.authorizePage(request, base, validation.error, body, validation.status, undefined, false);
|
|
791
|
-
}
|
|
792
|
-
const { client, clientId, redirectUri, codeChallenge, requestedResource, scope, state } = validation.value;
|
|
793
|
-
const now = Math.floor(Date.now() / 1000);
|
|
794
|
-
const identity = await authorizationIdentity(request, this.identityKey());
|
|
795
|
-
const failure = store.auth_failures[identity];
|
|
796
|
-
if (failure?.blocked_until > now) {
|
|
797
|
-
return this.authorizePage(request, base, "Too many failed attempts. Try again later.", body, 429, validation.value);
|
|
798
|
-
}
|
|
799
|
-
|
|
800
|
-
const account = accountByName(store, body.account_name);
|
|
801
|
-
const credentialsValid = Boolean(account?.active && await verifyAccountPassword(account, body.account_password));
|
|
802
|
-
if (!account || !credentialsValid) {
|
|
803
|
-
recordAuthorizationFailure(store, identity, now);
|
|
804
|
-
pruneAuthFailures(store, MAX_AUTH_FAILURE_IDENTITIES);
|
|
805
|
-
await this.ctx.storage.put("oauth", store);
|
|
806
|
-
const status = store.auth_failures[identity]?.blocked_until > now ? 429 : 401;
|
|
807
|
-
return this.authorizePage(request, base, "Invalid account credentials.", body, status, validation.value);
|
|
808
|
-
}
|
|
809
|
-
delete store.auth_failures[identity];
|
|
810
|
-
client.last_used_at = now;
|
|
811
|
-
client.has_been_authorized = true;
|
|
812
|
-
|
|
813
|
-
const code = randomToken("mcp_code");
|
|
814
|
-
const redirectLocation = authorizationRedirectLocation(redirectUri, code, state);
|
|
815
|
-
store.codes[code] = {
|
|
816
|
-
client_id: clientId,
|
|
817
|
-
account_id: account.account_id,
|
|
818
|
-
account_version: account.version,
|
|
819
|
-
role: account.role,
|
|
820
|
-
redirect_uri: redirectUri,
|
|
821
|
-
code_challenge: codeChallenge,
|
|
822
|
-
scope,
|
|
823
|
-
resource: requestedResource,
|
|
824
|
-
expires_at: now + 300,
|
|
825
|
-
};
|
|
826
|
-
pruneClientRecordByExpiry(store.codes, clientId, MAX_CODES_PER_CLIENT);
|
|
827
|
-
pruneRecordByExpiry(store.codes, MAX_OAUTH_CODES);
|
|
828
|
-
await this.ctx.storage.put("oauth", store);
|
|
829
|
-
|
|
830
|
-
return oauthRedirect(redirectLocation);
|
|
831
|
-
});
|
|
832
|
-
}
|
|
833
|
-
|
|
834
|
-
private exchangeToken(request: Request, base: string): Promise<Response> {
|
|
835
|
-
return exchangeOAuthToken(request, base, {
|
|
836
|
-
storage: this.ctx.storage,
|
|
837
|
-
tokenVersion: this.env.OAUTH_TOKEN_VERSION ?? "",
|
|
838
|
-
serverName: SERVER_NAME,
|
|
839
|
-
loadOAuthStore: () => this.oauthStore(),
|
|
840
|
-
withLock: (callback) => this.withOAuthLock(callback),
|
|
841
|
-
});
|
|
842
|
-
}
|
|
843
|
-
|
|
844
|
-
private async verifyAccessToken(token: string, base: string): Promise<AuthorizedToken | null> {
|
|
845
|
-
return this.withOAuthLock(async () => {
|
|
846
|
-
if (!token) return null;
|
|
847
|
-
const store = await this.oauthStore();
|
|
848
|
-
const key = `sha256:${await sha256Hex(token)}`;
|
|
849
|
-
const record = store.tokens[key];
|
|
850
|
-
if (!record) return null;
|
|
851
|
-
if (record.expires_at <= Math.floor(Date.now() / 1000)) {
|
|
852
|
-
delete store.tokens[key];
|
|
853
|
-
await this.ctx.storage.put("oauth", store);
|
|
854
|
-
return null;
|
|
855
|
-
}
|
|
856
|
-
const currentVersion = this.env.OAUTH_TOKEN_VERSION ?? "";
|
|
857
|
-
if (!record.version || !currentVersion || !(await safeEqual(record.version, currentVersion))) return null;
|
|
858
|
-
if (record.resource !== `${base}/mcp`) return null;
|
|
859
|
-
const account = store.accounts[record.account_id];
|
|
860
|
-
if (!account || !account.active || account.version !== record.account_version || account.role !== record.role) {
|
|
861
|
-
delete store.tokens[key];
|
|
862
|
-
await this.ctx.storage.put("oauth", store);
|
|
863
|
-
return null;
|
|
864
|
-
}
|
|
865
|
-
return {
|
|
866
|
-
tokenKey: key, clientId: record.client_id, accountId: account.account_id,
|
|
867
|
-
accountVersion: account.version, role: account.role,
|
|
868
|
-
};
|
|
869
|
-
});
|
|
870
|
-
}
|
|
871
|
-
|
|
872
|
-
private async withOAuthLock<T>(callback: () => Promise<T>): Promise<T> {
|
|
873
|
-
const previous = this.oauthQueue;
|
|
874
|
-
let release = () => {};
|
|
875
|
-
this.oauthQueue = new Promise<void>((resolve) => { release = resolve; });
|
|
876
|
-
await previous;
|
|
877
|
-
try {
|
|
878
|
-
return await callback();
|
|
879
|
-
} finally {
|
|
880
|
-
release();
|
|
881
|
-
}
|
|
882
|
-
}
|
|
883
|
-
|
|
884
|
-
private identityKey(): string {
|
|
885
|
-
const key = this.env.OAUTH_TOKEN_VERSION || this.env.DAEMON_SHARED_SECRET || this.env.ACCOUNT_ADMIN_SECRET;
|
|
886
|
-
if (!key) throw new HttpError(503, "server_not_configured", "OAuth identity key is not configured");
|
|
887
|
-
return key;
|
|
888
|
-
}
|
|
889
|
-
|
|
890
586
|
private bodyLimitBytes(): number {
|
|
891
587
|
const parsed = Number.parseInt(this.env.MBM_WORKER_MAX_BODY_BYTES ?? "", 10);
|
|
892
588
|
if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_MAX_BODY_BYTES;
|
|
@@ -994,10 +690,6 @@ function requiredString(value: Record<string, unknown>, key: string): string {
|
|
|
994
690
|
return field.trim();
|
|
995
691
|
}
|
|
996
692
|
|
|
997
|
-
function stringOrUndefined(value: unknown): string | undefined {
|
|
998
|
-
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
999
|
-
}
|
|
1000
|
-
|
|
1001
693
|
function sessionInstructionText(value: unknown): string {
|
|
1002
694
|
const object = asObject(value);
|
|
1003
695
|
const instructions = typeof object.instructions === "string" ? object.instructions : "";
|