machine-bridge-mcp 0.14.0 → 0.16.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.
Files changed (50) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +18 -8
  3. package/SECURITY.md +10 -3
  4. package/browser-extension/browser-operations.js +515 -0
  5. package/browser-extension/devtools-input.js +17 -2
  6. package/browser-extension/manifest.json +2 -2
  7. package/browser-extension/page-automation.js +245 -60
  8. package/browser-extension/pairing.js +1 -1
  9. package/browser-extension/service-worker.js +151 -354
  10. package/docs/ARCHITECTURE.md +6 -4
  11. package/docs/AUDIT.md +24 -1
  12. package/docs/LOCAL_AUTOMATION.md +10 -10
  13. package/docs/LOGGING.md +7 -1
  14. package/docs/OPERATIONS.md +13 -5
  15. package/docs/POLICY_REFERENCE.md +88 -0
  16. package/docs/TESTING.md +9 -2
  17. package/package.json +16 -3
  18. package/scripts/coverage-check.mjs +108 -0
  19. package/scripts/generate-policy-reference.mjs +95 -0
  20. package/src/local/agent-context.mjs +1 -103
  21. package/src/local/app-automation.mjs +7 -9
  22. package/src/local/browser-bridge.mjs +69 -143
  23. package/src/local/browser-extension-protocol.mjs +75 -0
  24. package/src/local/browser-pairing-store.mjs +83 -0
  25. package/src/local/call-registry.mjs +113 -0
  26. package/src/local/capability-ranking.mjs +103 -0
  27. package/src/local/cli-local-admin.mjs +308 -0
  28. package/src/local/cli-options.mjs +151 -0
  29. package/src/local/cli-policy.mjs +77 -0
  30. package/src/local/cli.mjs +16 -507
  31. package/src/local/errors.mjs +122 -0
  32. package/src/local/git-service.mjs +88 -0
  33. package/src/local/lifecycle.mjs +64 -0
  34. package/src/local/log.mjs +50 -19
  35. package/src/local/managed-job-plan.mjs +235 -0
  36. package/src/local/managed-jobs.mjs +16 -220
  37. package/src/local/observability.mjs +83 -0
  38. package/src/local/policy.mjs +148 -0
  39. package/src/local/process-execution.mjs +153 -0
  40. package/src/local/process-sessions.mjs +10 -20
  41. package/src/local/process-tracker.mjs +55 -0
  42. package/src/local/runtime.mjs +154 -672
  43. package/src/local/service.mjs +8 -3
  44. package/src/local/stdio.mjs +3 -11
  45. package/src/local/tool-executor.mjs +102 -0
  46. package/src/local/tools.mjs +21 -104
  47. package/src/local/workspace-file-service.mjs +451 -0
  48. package/src/shared/policy-contract.json +54 -0
  49. package/src/shared/tool-catalog.json +11 -11
  50. package/src/worker/index.ts +69 -524
@@ -1,9 +1,23 @@
1
1
  import { DurableObject } from "cloudflare:workers";
2
2
  import toolCatalog from "../shared/tool-catalog.json";
3
3
  import serverMetadata from "../shared/server-metadata.json";
4
+ import { PendingCallRegistry } from "./pending-calls";
5
+ import { WorkerObservability } from "./observability";
6
+ import { daemonToolError, publicWorkerToolError, WorkerToolError } from "./errors";
7
+ import { sanitizeDaemonPolicy, sanitizeDaemonTools, type DaemonPolicy } from "./policy";
8
+ import {
9
+ AUTH_BLOCK_SECONDS, authorizationIdentity, pkceS256, pruneAuthFailures, pruneClientRecordByExpiry, pruneRecordByExpiry,
10
+ randomToken, recordAuthorizationFailure, safeEqual, sha256Hex, validateAuthorizationRequest,
11
+ type OAuthClient, type OAuthStore, type ValidatedAuthorization,
12
+ } from "./oauth-state";
13
+ import {
14
+ HttpError, applyCors, authorizationRedirectLocation, baseUrl, bearerToken, corsPreflight, escapeHtml,
15
+ html, json, methodNotAllowed, normalizeDisplayText, normalizeRedirectUri, oauthRedirect, sanitizeMetadataText,
16
+ parseJsonRequest, parseRequestBody, searchParamsEntries, searchParamsObject, validateOrigin, workerErrorClass,
17
+ } from "./http";
4
18
 
5
19
  const SERVER_NAME = String(serverMetadata.name);
6
- const SERVER_VERSION = "0.14.0";
20
+ const SERVER_VERSION = "0.16.0";
7
21
  const MCP_PROTOCOL_VERSION = String(serverMetadata.protocolVersion);
8
22
  const MCP_SUPPORTED_PROTOCOL_VERSIONS = serverMetadata.supportedProtocolVersions.map((value) => String(value));
9
23
  const JSONRPC_VERSION = "2.0";
@@ -23,11 +37,7 @@ const MAX_CODES_PER_CLIENT = 10;
23
37
  const MAX_OAUTH_CODES = 200;
24
38
  const MAX_OAUTH_TOKENS = 500;
25
39
  const MAX_AUTH_FAILURE_IDENTITIES = 200;
26
- const AUTH_FAILURE_WINDOW_SECONDS = 10 * 60;
27
- const AUTH_BLOCK_SECONDS = 15 * 60;
28
- const AUTH_FAILURE_LIMIT = 10;
29
40
  const AUTHORIZATION_FIELDS = new Set(["response_type", "client_id", "redirect_uri", "code_challenge", "code_challenge_method", "scope", "resource", "state"]);
30
- const UNSAFE_DISPLAY_CONTROLS = /[\u0000-\u001f\u007f\u200B-\u200F\u202A-\u202E\u2066-\u2069\uFEFF]/g;
31
41
 
32
42
  interface BridgeEnv {
33
43
  BRIDGE: DurableObjectNamespace<BridgeRoom>;
@@ -47,70 +57,14 @@ interface JsonRpcRequest {
47
57
  params?: unknown;
48
58
  }
49
59
 
50
- interface OAuthClient {
51
- client_id: string;
52
- client_name: string;
53
- redirect_uris: string[];
54
- created_at: number;
55
- last_used_at: number;
56
- registration_identity?: string;
57
- }
58
-
59
- interface OAuthCode {
60
- client_id: string;
61
- redirect_uri: string;
62
- code_challenge: string;
63
- scope: string;
64
- resource: string;
65
- expires_at: number;
66
- }
67
-
68
- interface OAuthToken {
69
- client_id: string;
70
- scope: string;
71
- resource: string;
72
- version: string;
73
- expires_at: number;
74
- }
75
-
76
- interface OAuthFailure {
77
- count: number;
78
- window_started: number;
79
- blocked_until: number;
80
- last_attempt: number;
81
- }
82
-
83
- interface OAuthStore {
84
- clients: Record<string, OAuthClient>;
85
- codes: Record<string, OAuthCode>;
86
- tokens: Record<string, OAuthToken>;
87
- auth_failures: Record<string, OAuthFailure>;
88
- }
89
-
90
- interface ValidatedAuthorization {
91
- client: OAuthClient;
92
- clientId: string;
93
- redirectUri: string;
94
- codeChallenge: string;
95
- requestedResource: string;
96
- scope: string;
97
- state: string;
98
- }
99
-
100
60
  interface DaemonAttachment {
101
61
  role: "candidate" | "expired" | "daemon";
102
62
  connectedAt: string;
103
- policy?: Record<string, unknown>;
63
+ policy?: DaemonPolicy;
104
64
  tools?: string[];
105
65
  }
106
66
 
107
- interface PendingCall {
108
- resolve: (value: unknown) => void;
109
- reject: (error: Error) => void;
110
- timeout: ReturnType<typeof setTimeout>;
111
- socket: WebSocket;
112
- clientRequestKey?: string;
113
- }
67
+
114
68
 
115
69
  interface AuthorizedToken {
116
70
  tokenKey: string;
@@ -132,7 +86,8 @@ function publicTool(tool: ToolDefinition): ToolDefinition {
132
86
  }
133
87
 
134
88
  export class BridgeRoom extends DurableObject<BridgeEnv> {
135
- private readonly pending = new Map<string, PendingCall>();
89
+ private readonly pending = new PendingCallRegistry(MAX_PENDING_CALLS);
90
+ private readonly observability = new WorkerObservability();
136
91
  private oauthQueue: Promise<void> = Promise.resolve();
137
92
 
138
93
  constructor(ctx: DurableObjectState, env: BridgeEnv) {
@@ -142,12 +97,12 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
142
97
  async fetch(request: Request): Promise<Response> {
143
98
  const base = baseUrl(request);
144
99
  const configuredOrigins = this.env.MBM_ALLOWED_ORIGINS ?? "";
145
- if (!validateOrigin(request, base, configuredOrigins)) return json({ error: "origin_not_allowed" }, 403);
146
- if (request.method === "OPTIONS" && request.headers.has("Origin")) {
147
- return corsPreflight(request, base, configuredOrigins);
148
- }
149
- const response = await this.handleRequest(request, base);
150
- return applyCors(response, request, base, configuredOrigins);
100
+ let response: Response;
101
+ if (!validateOrigin(request, base, configuredOrigins)) response = json({ error: "origin_not_allowed" }, 403);
102
+ else if (request.method === "OPTIONS" && request.headers.has("Origin")) response = corsPreflight(request, base, configuredOrigins);
103
+ else response = applyCors(await this.handleRequest(request, base), request, base, configuredOrigins);
104
+ this.observability.requestFinished(response.status);
105
+ return response;
151
106
  }
152
107
 
153
108
  private async handleRequest(request: Request, base: string): Promise<Response> {
@@ -199,7 +154,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
199
154
  return json({ error: "not_found" }, 404);
200
155
  } catch (error) {
201
156
  if (error instanceof HttpError) return json({ error: error.code, message: error.message }, error.status);
202
- console.error(JSON.stringify({ level: "error", message: "request_failed", path: url.pathname, error_class: workerErrorClass(error) }));
157
+ this.observability.event("error", "http.request.failed", { path: url.pathname, error_class: workerErrorClass(error) });
203
158
  return json({ error: "internal_server_error" }, 500);
204
159
  }
205
160
  }
@@ -261,6 +216,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
261
216
  policy: daemonPolicy,
262
217
  tools: sanitizeDaemonTools(body.tools, daemonPolicy),
263
218
  } satisfies DaemonAttachment);
219
+ this.observability.socketAuthenticated();
264
220
  await this.scheduleCandidateAlarm();
265
221
  try {
266
222
  ws.send(JSON.stringify({ type: "hello_ack", server: SERVER_NAME, version: SERVER_VERSION }));
@@ -293,12 +249,8 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
293
249
  return;
294
250
  }
295
251
 
296
- const pending = this.pending.get(body.id);
297
- if (!pending || pending.socket !== ws) return;
298
- clearTimeout(pending.timeout);
299
- this.pending.delete(body.id);
300
- if (body.ok === false) pending.reject(new Error(errorMessage(body.error)));
301
- else pending.resolve(body.result);
252
+ if (body.ok === false) this.pending.reject(body.id, daemonToolError(body.error), ws);
253
+ else this.pending.resolve(body.id, ws, body.result);
302
254
  }
303
255
 
304
256
  async webSocketClose(ws: WebSocket): Promise<void> {
@@ -306,18 +258,14 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
306
258
  }
307
259
 
308
260
  async webSocketError(ws: WebSocket, error: unknown): Promise<void> {
309
- console.error(JSON.stringify({ level: "warn", message: "daemon_websocket_error", error_class: workerErrorClass(error) }));
261
+ this.observability.event("warn", "daemon.websocket.error", { error_class: workerErrorClass(error) });
310
262
  await this.cleanupDaemonSocket(ws, "daemon transport error");
311
263
  }
312
264
 
313
265
  private async cleanupDaemonSocket(ws: WebSocket, message: string): Promise<void> {
266
+ this.observability.socketDisconnected();
314
267
  await this.scheduleCandidateAlarm();
315
- for (const [id, pending] of this.pending) {
316
- if (pending.socket !== ws) continue;
317
- clearTimeout(pending.timeout);
318
- pending.reject(new Error(message));
319
- this.pending.delete(id);
320
- }
268
+ this.pending.rejectSocket(ws, () => new WorkerToolError("unavailable", message, true));
321
269
  }
322
270
 
323
271
  private async handleMcp(request: Request, base: string): Promise<Response> {
@@ -390,7 +338,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
390
338
  const result = await this.callTool(name, args, base, clientRequestKey(authorized, request.id));
391
339
  return rpcResult(request.id, textToolResult(result));
392
340
  } catch (error) {
393
- return rpcResult(request.id, textToolResult({ error: errorMessage(error) }, true));
341
+ return rpcResult(request.id, textToolResult({ error: publicWorkerToolError(error) }, true));
394
342
  }
395
343
  }
396
344
  return rpcError(request.id, -32601, `Method not found: ${request.method}`);
@@ -406,6 +354,11 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
406
354
  mcp_url: `${base}/mcp`,
407
355
  oauth: this.authorizationServerMetadata(base),
408
356
  daemon,
357
+ worker: {
358
+ pending_calls: this.pending.snapshot(),
359
+ daemon_candidates: this.candidateSockets().length,
360
+ observability: this.observability.snapshot(),
361
+ },
409
362
  tools,
410
363
  tool_delivery: {
411
364
  full_profile_scope: "local-daemon-and-relay-advertisement",
@@ -425,39 +378,41 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
425
378
 
426
379
  private async callDaemonTool(name: string, args: Record<string, unknown>, requestKey?: string): Promise<unknown> {
427
380
  const socket = this.daemonSockets()[0];
428
- if (!socket) throw new Error("local daemon is not connected; keep the CLI start command running");
429
- if (this.pending.size >= MAX_PENDING_CALLS) throw new Error("too many concurrent daemon tool calls");
430
- if (requestKey && [...this.pending.values()].some((pending) => pending.clientRequestKey === requestKey)) {
431
- throw new Error("duplicate in-flight JSON-RPC request id for this access token");
432
- }
381
+ if (!socket) throw new WorkerToolError("unavailable", "local daemon is not connected; keep the CLI start command running", true);
433
382
  const id = randomToken("call");
434
383
  const timeoutMs = daemonToolTimeoutMs(name, args);
435
- return await new Promise((resolve, reject) => {
436
- const timeout = setTimeout(() => {
437
- this.pending.delete(id);
438
- try { socket.send(JSON.stringify({ type: "cancel_call", id })); } catch {}
439
- reject(new Error(`daemon tool timed out: ${name}`));
440
- }, timeoutMs);
441
- this.pending.set(id, { resolve, reject, timeout, socket, clientRequestKey: requestKey });
442
- try {
443
- socket.send(JSON.stringify({ type: "tool_call", id, tool: name, arguments: args, timeout_ms: timeoutMs }));
444
- } catch (error) {
445
- clearTimeout(timeout);
446
- this.pending.delete(id);
447
- reject(new Error(`failed to send daemon tool call: ${errorMessage(error)}`));
448
- }
384
+ const result = this.pending.register({
385
+ id,
386
+ socket,
387
+ clientRequestKey: requestKey,
388
+ timeoutMs,
389
+ onTimeout: (record) => {
390
+ try { record.socket.send(JSON.stringify({ type: "cancel_call", id: record.id })); } catch {}
391
+ return new WorkerToolError("timeout", `daemon tool timed out: ${name}`, true);
392
+ },
449
393
  });
394
+ this.observability.callStarted(name);
395
+ try {
396
+ socket.send(JSON.stringify({ type: "tool_call", id, tool: name, arguments: args, timeout_ms: timeoutMs }));
397
+ } catch {
398
+ this.pending.reject(id, new WorkerToolError("network_error", "failed to send daemon tool call", true), socket);
399
+ }
400
+ try {
401
+ const value = await result;
402
+ this.observability.callFinished(name);
403
+ return value;
404
+ } catch (error) {
405
+ this.observability.callFinished(name, publicWorkerToolError(error).code);
406
+ throw error;
407
+ }
450
408
  }
451
409
 
452
410
  private cancelClientRequest(requestKey?: string): void {
453
411
  if (!requestKey) return;
454
- for (const [id, pending] of this.pending) {
455
- if (pending.clientRequestKey !== requestKey) continue;
456
- clearTimeout(pending.timeout);
457
- this.pending.delete(id);
458
- try { pending.socket.send(JSON.stringify({ type: "cancel_call", id })); } catch {}
459
- pending.reject(new Error("tool call cancelled by client"));
460
- }
412
+ this.pending.cancelRequest(requestKey, (record) => {
413
+ try { record.socket.send(JSON.stringify({ type: "cancel_call", id: record.id })); } catch {}
414
+ return new WorkerToolError("cancelled", "tool call cancelled by client");
415
+ });
461
416
  }
462
417
 
463
418
  private async acceptDaemonWebSocket(request: Request): Promise<Response> {
@@ -473,6 +428,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
473
428
  const pair = new WebSocketPair();
474
429
  const [client, server] = Object.values(pair) as [WebSocket, WebSocket];
475
430
  this.ctx.acceptWebSocket(server);
431
+ this.observability.socketCandidate();
476
432
  server.serializeAttachment({
477
433
  role: "candidate",
478
434
  connectedAt: new Date().toISOString(),
@@ -735,7 +691,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
735
691
  const body = searchParamsObject(new URL(request.url).searchParams);
736
692
  return this.withOAuthLock(async () => {
737
693
  const store = await this.oauthStore();
738
- const validation = validateAuthorizationRequest(body, base, store);
694
+ const validation = validateAuthorizationRequest(body, base, SERVER_NAME, store);
739
695
  if ("error" in validation) {
740
696
  return this.authorizePage(request, base, validation.error, body, validation.status, undefined, false);
741
697
  }
@@ -793,7 +749,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
793
749
  const body = await parseRequestBody(request, OAUTH_BODY_LIMIT_BYTES);
794
750
  return this.withOAuthLock(async () => {
795
751
  const store = await this.oauthStore();
796
- const validation = validateAuthorizationRequest(body, base, store);
752
+ const validation = validateAuthorizationRequest(body, base, SERVER_NAME, store);
797
753
  if ("error" in validation) {
798
754
  return this.authorizePage(request, base, validation.error, body, validation.status, undefined, false);
799
755
  }
@@ -983,37 +939,6 @@ function textToolResult(value: unknown, isError = false): Record<string, unknown
983
939
  return result;
984
940
  }
985
941
 
986
- function methodNotAllowed(allow: string): Response {
987
- return new Response(JSON.stringify({ error: "method_not_allowed" }), {
988
- status: 405,
989
- headers: {
990
- allow,
991
- "cache-control": "no-store",
992
- "content-type": "application/json; charset=utf-8",
993
- "x-content-type-options": "nosniff",
994
- },
995
- });
996
- }
997
-
998
- function oauthRedirect(location: URL): Response {
999
- return new Response(null, {
1000
- status: 303,
1001
- headers: {
1002
- location: location.href,
1003
- "cache-control": "no-store",
1004
- "referrer-policy": "no-referrer",
1005
- "x-content-type-options": "nosniff",
1006
- },
1007
- });
1008
- }
1009
-
1010
- function authorizationRedirectLocation(redirectUri: string, code: string, state: string): URL {
1011
- const location = new URL(redirectUri);
1012
- location.searchParams.append("code", code);
1013
- if (state) location.searchParams.append("state", state);
1014
- return location;
1015
- }
1016
-
1017
942
  function isFreshDaemonCandidate(connectedAt: string): boolean {
1018
943
  const timestamp = Date.parse(connectedAt);
1019
944
  if (!Number.isFinite(timestamp)) return false;
@@ -1021,152 +946,6 @@ function isFreshDaemonCandidate(connectedAt: string): boolean {
1021
946
  return age >= 0 && age <= DAEMON_HELLO_TIMEOUT_MS;
1022
947
  }
1023
948
 
1024
- function sanitizeMetadataText(value: unknown, maxLength: number): string | undefined {
1025
- if (typeof value !== "string") return undefined;
1026
- const normalized = value.replace(UNSAFE_DISPLAY_CONTROLS, " ").replace(/\s+/g, " ").trim();
1027
- return normalized ? normalized.slice(0, maxLength) : undefined;
1028
- }
1029
-
1030
-
1031
- function sanitizeDaemonTools(value: unknown, policy: Record<string, unknown>): string[] {
1032
- if (!Array.isArray(value)) return [];
1033
- const definitions = new Map(allCatalogTools.map((tool) => [tool.name, tool]));
1034
- return [...new Set(value.filter((item): item is string => {
1035
- if (typeof item !== "string" || item === "server_info") return false;
1036
- const definition = definitions.get(item);
1037
- return Boolean(definition && daemonPolicyAllows(definition.availability, policy));
1038
- }))];
1039
- }
1040
-
1041
- function daemonPolicyAllows(availability: unknown, policy: Record<string, unknown>): boolean {
1042
- if (availability === "always") return true;
1043
- if (availability === "write") return policy.allowWrite === true;
1044
- if (availability === "direct-exec") return policy.execMode === "direct" || policy.execMode === "shell";
1045
- if (availability === "shell-exec") return policy.execMode === "shell";
1046
- if (availability === "full") return policy.profile === "full"
1047
- && policy.allowWrite === true
1048
- && policy.execMode === "shell"
1049
- && policy.unrestrictedPaths === true
1050
- && policy.minimalEnv === false
1051
- && policy.exposeAbsolutePaths === true;
1052
- return false;
1053
- }
1054
-
1055
- function sanitizeDaemonPolicy(value: unknown): Record<string, unknown> {
1056
- const policy = asObject(value);
1057
- const execMode = policy.execMode === "shell" || policy.execMode === "direct" ? policy.execMode : "off";
1058
- const origin = sanitizeMetadataText(policy.origin, 32);
1059
- const revision = Number.isInteger(policy.revision) && Number(policy.revision) > 0
1060
- ? Math.min(Number(policy.revision), 1_000_000)
1061
- : 1;
1062
- return {
1063
- profile: sanitizeMetadataText(policy.profile, 32) ?? "custom",
1064
- origin: ["default", "explicit", "custom", "migrated", "legacy-preserved"].includes(origin ?? "") ? origin : "custom",
1065
- revision,
1066
- allowWrite: policy.allowWrite === true,
1067
- allowExec: execMode !== "off",
1068
- execMode,
1069
- unrestrictedPaths: policy.unrestrictedPaths === true,
1070
- minimalEnv: policy.minimalEnv !== false,
1071
- exposeAbsolutePaths: policy.exposeAbsolutePaths === true,
1072
- };
1073
- }
1074
-
1075
- function json(value: unknown, status = 200): Response {
1076
- return new Response(JSON.stringify(value), {
1077
- status,
1078
- headers: {
1079
- "content-type": "application/json; charset=utf-8",
1080
- "cache-control": "no-store",
1081
- "x-content-type-options": "nosniff",
1082
- },
1083
- });
1084
- }
1085
-
1086
- function html(value: string, status = 200): Response {
1087
- return new Response(value, {
1088
- status,
1089
- headers: {
1090
- "content-type": "text/html; charset=utf-8",
1091
- "cache-control": "no-store",
1092
- "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'",
1093
- "referrer-policy": "no-referrer",
1094
- "x-content-type-options": "nosniff",
1095
- "x-frame-options": "DENY",
1096
- },
1097
- });
1098
- }
1099
-
1100
- function baseUrl(request: Request): string {
1101
- return new URL(request.url).origin;
1102
- }
1103
-
1104
- function bearerToken(request: Request): string {
1105
- const match = (request.headers.get("Authorization") ?? "").match(/^Bearer\s+(.+)$/i);
1106
- return match?.[1]?.trim() ?? "";
1107
- }
1108
-
1109
- async function parseJsonRequest(request: Request, limit: number): Promise<unknown> {
1110
- const text = await readBoundedText(request, limit);
1111
- try {
1112
- return JSON.parse(text);
1113
- } catch {
1114
- throw new HttpError(400, "invalid_json", "Request body is not valid JSON");
1115
- }
1116
- }
1117
-
1118
- async function parseRequestBody(request: Request, limit: number): Promise<Record<string, unknown>> {
1119
- const contentType = (request.headers.get("content-type") ?? "").toLowerCase();
1120
- const text = await readBoundedText(request, limit);
1121
- if (contentType.includes("application/json") || text.trim().startsWith("{")) {
1122
- let parsed: unknown;
1123
- try {
1124
- parsed = text.trim() ? JSON.parse(text) : {};
1125
- } catch {
1126
- throw new HttpError(400, "invalid_json", "Request body is not valid JSON");
1127
- }
1128
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new HttpError(400, "bad_request", "JSON body must be an object");
1129
- return parsed as Record<string, unknown>;
1130
- }
1131
- return searchParamsObject(new URLSearchParams(text));
1132
- }
1133
-
1134
-
1135
- async function readBoundedText(request: Request, limit: number): Promise<string> {
1136
- const length = Number(request.headers.get("content-length") ?? "0");
1137
- if (Number.isFinite(length) && length > limit) throw new HttpError(413, "request_body_too_large", `request body exceeds ${limit} bytes`);
1138
- if (!request.body) return "";
1139
- const reader = request.body.getReader();
1140
- const chunks: Uint8Array[] = [];
1141
- let total = 0;
1142
- try {
1143
- for (;;) {
1144
- const { done, value } = await reader.read();
1145
- if (done) break;
1146
- if (!value) continue;
1147
- total += value.byteLength;
1148
- if (total > limit) {
1149
- await reader.cancel();
1150
- throw new HttpError(413, "request_body_too_large", `request body exceeds ${limit} bytes`);
1151
- }
1152
- chunks.push(value);
1153
- }
1154
- } finally {
1155
- reader.releaseLock();
1156
- }
1157
- const combined = new Uint8Array(total);
1158
- let offset = 0;
1159
- for (const chunk of chunks) {
1160
- combined.set(chunk, offset);
1161
- offset += chunk.byteLength;
1162
- }
1163
- try {
1164
- return new TextDecoder("utf-8", { fatal: true }).decode(combined);
1165
- } catch {
1166
- throw new HttpError(400, "invalid_encoding", "Request body must be valid UTF-8");
1167
- }
1168
- }
1169
-
1170
949
  function asObject(value: unknown): Record<string, unknown> {
1171
950
  if (!value || typeof value !== "object" || Array.isArray(value)) return {};
1172
951
  return value as Record<string, unknown>;
@@ -1225,237 +1004,3 @@ function clientRequestKey(authorized: AuthorizedToken, requestId: unknown): stri
1225
1004
  if (requestId === null || (typeof requestId !== "string" && typeof requestId !== "number")) return undefined;
1226
1005
  return `${authorized.tokenKey}:${typeof requestId}:${String(requestId)}`;
1227
1006
  }
1228
-
1229
- function validateAuthorizationRequest(
1230
- body: Record<string, unknown>,
1231
- base: string,
1232
- store: OAuthStore,
1233
- ): { value: ValidatedAuthorization } | { error: string; status: number } {
1234
- const responseType = String(body.response_type ?? "");
1235
- const clientId = String(body.client_id ?? "");
1236
- const redirectUri = String(body.redirect_uri ?? "");
1237
- const codeChallenge = String(body.code_challenge ?? "");
1238
- const codeChallengeMethod = String(body.code_challenge_method ?? "");
1239
- const requestedResource = String(body.resource ?? `${base}/mcp`);
1240
- const scope = String(body.scope ?? SERVER_NAME).trim();
1241
- const state = body.state === undefined ? "" : typeof body.state === "string" ? body.state : "";
1242
-
1243
- if (responseType !== "code") return { error: "response_type must be code.", status: 400 };
1244
- if (requestedResource !== `${base}/mcp`) return { error: "resource mismatch.", status: 400 };
1245
- if (scope !== SERVER_NAME) return { error: "unsupported scope.", status: 400 };
1246
- if (body.state !== undefined && typeof body.state !== "string") return { error: "state must be a string.", status: 400 };
1247
- if (state.length > 1024) return { error: "state is too long.", status: 400 };
1248
- if (codeChallengeMethod !== "S256" || !/^[A-Za-z0-9_-]{43}$/.test(codeChallenge)) {
1249
- return { error: "A valid PKCE S256 challenge is required.", status: 400 };
1250
- }
1251
- const client = store.clients[clientId];
1252
- if (!client) return { error: "Unknown OAuth client.", status: 400 };
1253
- if (!client.redirect_uris.includes(redirectUri)) return { error: "redirect_uri is not registered.", status: 400 };
1254
- return { value: { client, clientId, redirectUri, codeChallenge, requestedResource, scope, state } };
1255
- }
1256
-
1257
- function pruneClientRecordByExpiry<T extends { client_id: string; expires_at: number }>(record: Record<string, T>, clientId: string, keep: number): void {
1258
- const allowed = new Set(Object.entries(record)
1259
- .filter(([, value]) => value.client_id === clientId)
1260
- .sort((left, right) => right[1].expires_at - left[1].expires_at)
1261
- .slice(0, keep)
1262
- .map(([key]) => key));
1263
- for (const [key, value] of Object.entries(record)) {
1264
- if (value.client_id === clientId && !allowed.has(key)) delete record[key];
1265
- }
1266
- }
1267
-
1268
- function pruneRecordByExpiry<T extends { expires_at: number }>(record: Record<string, T>, keep: number): void {
1269
- const allowed = new Set(Object.entries(record)
1270
- .sort((left, right) => right[1].expires_at - left[1].expires_at)
1271
- .slice(0, keep)
1272
- .map(([key]) => key));
1273
- for (const key of Object.keys(record)) if (!allowed.has(key)) delete record[key];
1274
- }
1275
-
1276
- async function authorizationIdentity(request: Request, keyMaterial: string): Promise<string> {
1277
- const source = (request.headers.get("CF-Connecting-IP") || "unknown").slice(0, 128);
1278
- const encoder = new TextEncoder();
1279
- const key = await crypto.subtle.importKey(
1280
- "raw",
1281
- encoder.encode(keyMaterial),
1282
- { name: "HMAC", hash: "SHA-256" },
1283
- false,
1284
- ["sign"],
1285
- );
1286
- const digest = await crypto.subtle.sign("HMAC", key, encoder.encode(source));
1287
- return `hmac-sha256:${[...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("")}`;
1288
- }
1289
-
1290
- function recordAuthorizationFailure(store: OAuthStore, identity: string, now: number): void {
1291
- const current = store.auth_failures[identity];
1292
- const activeWindow = current && current.window_started + AUTH_FAILURE_WINDOW_SECONDS > now;
1293
- const count = activeWindow ? current.count + 1 : 1;
1294
- store.auth_failures[identity] = {
1295
- count,
1296
- window_started: activeWindow ? current.window_started : now,
1297
- blocked_until: count >= AUTH_FAILURE_LIMIT ? now + AUTH_BLOCK_SECONDS : 0,
1298
- last_attempt: now,
1299
- };
1300
- }
1301
-
1302
- function pruneAuthFailures(store: OAuthStore, keep: number): void {
1303
- const allowed = new Set(Object.entries(store.auth_failures)
1304
- .sort((left, right) => right[1].last_attempt - left[1].last_attempt)
1305
- .slice(0, keep)
1306
- .map(([key]) => key));
1307
- for (const key of Object.keys(store.auth_failures)) if (!allowed.has(key)) delete store.auth_failures[key];
1308
- }
1309
-
1310
- function randomToken(prefix: string): string {
1311
- const bytes = new Uint8Array(32);
1312
- crypto.getRandomValues(bytes);
1313
- return `${prefix}_${base64Url(bytes)}`;
1314
- }
1315
-
1316
- function base64Url(bytes: Uint8Array): string {
1317
- return btoa(String.fromCharCode(...bytes)).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
1318
- }
1319
-
1320
- async function sha256Hex(value: string): Promise<string> {
1321
- const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
1322
- return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
1323
- }
1324
-
1325
- async function safeEqual(left: string, right: string): Promise<boolean> {
1326
- const encoder = new TextEncoder();
1327
- const [leftHash, rightHash] = await Promise.all([
1328
- crypto.subtle.digest("SHA-256", encoder.encode(left)),
1329
- crypto.subtle.digest("SHA-256", encoder.encode(right)),
1330
- ]);
1331
- const leftBytes = new Uint8Array(leftHash);
1332
- const rightBytes = new Uint8Array(rightHash);
1333
- let diff = leftBytes.length ^ rightBytes.length;
1334
- for (let index = 0; index < Math.max(leftBytes.length, rightBytes.length); index += 1) diff |= (leftBytes[index] ?? 0) ^ (rightBytes[index] ?? 0);
1335
- return diff === 0;
1336
- }
1337
-
1338
- async function pkceS256(verifier: string): Promise<string> {
1339
- const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
1340
- return base64Url(new Uint8Array(digest));
1341
- }
1342
-
1343
- function normalizeRedirectUri(value: string): string | null {
1344
- try {
1345
- const url = new URL(value);
1346
- if (url.username || url.password || url.hash) return null;
1347
- if (url.protocol === "https:" && url.hostname) return url.toString();
1348
- if (url.protocol === "http:" && isLoopbackHost(url.hostname)) return url.toString();
1349
- return null;
1350
- } catch {
1351
- return null;
1352
- }
1353
- }
1354
-
1355
- function corsPreflight(request: Request, base: string, configured: string): Response {
1356
- const origin = request.headers.get("Origin") ?? "";
1357
- if (!isConfiguredOrSameOrigin(origin, base, configured)) return json({ error: "origin_not_allowed" }, 403);
1358
- const requestedMethod = (request.headers.get("Access-Control-Request-Method") ?? "").toUpperCase();
1359
- if (requestedMethod && !["GET", "POST"].includes(requestedMethod)) return methodNotAllowed("GET, POST, OPTIONS");
1360
- return new Response(null, {
1361
- status: 204,
1362
- headers: {
1363
- "access-control-allow-origin": origin,
1364
- "access-control-allow-methods": "GET, POST, OPTIONS",
1365
- "access-control-allow-headers": "authorization, content-type, mcp-protocol-version, mcp-session-id",
1366
- "access-control-max-age": "600",
1367
- "cache-control": "no-store",
1368
- "vary": "Origin, Access-Control-Request-Method, Access-Control-Request-Headers",
1369
- },
1370
- });
1371
- }
1372
-
1373
- function applyCors(response: Response, request: Request, base: string, configured: string): Response {
1374
- if (response.status === 101) return response;
1375
- const origin = request.headers.get("Origin") ?? "";
1376
- if (!origin || !isConfiguredOrSameOrigin(origin, base, configured)) return response;
1377
- const headers = new Headers(response.headers);
1378
- headers.set("access-control-allow-origin", origin);
1379
- headers.set("access-control-expose-headers", "www-authenticate, mcp-session-id");
1380
- appendVary(headers, "Origin");
1381
- return new Response(response.body, { status: response.status, statusText: response.statusText, headers });
1382
- }
1383
-
1384
- function appendVary(headers: Headers, value: string): void {
1385
- const existing = headers.get("vary");
1386
- const values = new Set((existing ?? "").split(",").map((item) => item.trim()).filter(Boolean));
1387
- values.add(value);
1388
- headers.set("vary", [...values].join(", "));
1389
- }
1390
-
1391
- function isConfiguredOrSameOrigin(origin: string, base: string, configured: string): boolean {
1392
- if (isDefaultAllowedOrigin(origin, base)) return true;
1393
- const allowed = configured.split(",").map((item) => item.trim()).filter((item) => item && item !== "null");
1394
- return allowed.includes(origin);
1395
- }
1396
-
1397
- function validateOrigin(request: Request, base: string, configured = ""): boolean {
1398
- const origin = request.headers.get("Origin");
1399
- return !origin || isConfiguredOrSameOrigin(origin, base, configured);
1400
- }
1401
-
1402
- function isDefaultAllowedOrigin(origin: string, base: string): boolean {
1403
- try {
1404
- return new URL(origin).origin === new URL(base).origin;
1405
- } catch {
1406
- return false;
1407
- }
1408
- }
1409
-
1410
- function isLoopbackHost(hostname: string): boolean {
1411
- return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]";
1412
- }
1413
-
1414
- function searchParamsEntries(params: URLSearchParams): Array<[string, string]> {
1415
- const entries: Array<[string, string]> = [];
1416
- params.forEach((value, key) => entries.push([key, value]));
1417
- return entries;
1418
- }
1419
-
1420
- function searchParamsObject(params: URLSearchParams): Record<string, unknown> {
1421
- const out: Record<string, unknown> = {};
1422
- params.forEach((value, key) => {
1423
- if (out[key] === undefined) out[key] = value;
1424
- else if (Array.isArray(out[key])) (out[key] as string[]).push(value);
1425
- else out[key] = [out[key] as string, value];
1426
- });
1427
- return out;
1428
- }
1429
-
1430
- function normalizeDisplayText(value: string, maxLength: number, fallback = "MCP Client"): string {
1431
- const normalized = value.replace(UNSAFE_DISPLAY_CONTROLS, " ").replace(/\s+/g, " ").trim();
1432
- return (normalized || fallback).slice(0, maxLength);
1433
- }
1434
-
1435
- function escapeHtml(value: string): string {
1436
- return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
1437
- }
1438
-
1439
- function workerErrorClass(error: unknown): string {
1440
- if (error instanceof HttpError) return error.code;
1441
- if (error instanceof TypeError) return "type_error";
1442
- if (error instanceof RangeError) return "range_error";
1443
- if (error instanceof Error) return error.name.replace(/[^A-Za-z0-9_-]/g, "_").toLowerCase().slice(0, 64) || "error";
1444
- return "unknown_error";
1445
- }
1446
-
1447
- function errorMessage(error: unknown): string {
1448
- if (error instanceof Error) return error.message;
1449
- if (typeof error === "string") return error;
1450
- try {
1451
- return JSON.stringify(error);
1452
- } catch {
1453
- return String(error);
1454
- }
1455
- }
1456
-
1457
- class HttpError extends Error {
1458
- constructor(readonly status: number, readonly code: string, message: string) {
1459
- super(message);
1460
- }
1461
- }