machine-bridge-mcp 1.2.8 → 1.2.10

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 (57) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/CONTRIBUTING.md +16 -5
  3. package/README.md +115 -416
  4. package/SECURITY.md +2 -0
  5. package/browser-extension/manifest.json +2 -2
  6. package/docs/ARCHITECTURE.md +19 -9
  7. package/docs/AUDIT.md +20 -0
  8. package/docs/ENGINEERING.md +2 -2
  9. package/docs/LOGGING.md +1 -1
  10. package/docs/MULTI_ACCOUNT.md +2 -2
  11. package/docs/OPERATIONS.md +2 -2
  12. package/docs/OVERVIEW.md +113 -0
  13. package/docs/PROJECT_STANDARDS.md +4 -4
  14. package/docs/RELEASING.md +25 -12
  15. package/docs/TESTING.md +12 -8
  16. package/docs/THREAT_MODEL.md +142 -0
  17. package/package.json +10 -3
  18. package/scripts/check-plan.mjs +91 -0
  19. package/scripts/coverage-check.mjs +22 -0
  20. package/scripts/github-push.mjs +1 -1
  21. package/scripts/github-release.mjs +1 -1
  22. package/scripts/local-release-acceptance.mjs +56 -6
  23. package/scripts/release-acceptance.mjs +15 -3
  24. package/scripts/release-state.mjs +1 -1
  25. package/scripts/run-checks.mjs +29 -0
  26. package/scripts/start-release-candidate.mjs +113 -0
  27. package/src/local/agent-context-projection.mjs +158 -0
  28. package/src/local/agent-context.mjs +23 -332
  29. package/src/local/agent-skill-discovery.mjs +230 -0
  30. package/src/local/agent-text-file.mjs +41 -0
  31. package/src/local/browser-bridge-http.mjs +48 -0
  32. package/src/local/browser-bridge.mjs +48 -222
  33. package/src/local/browser-broker-routes.mjs +136 -0
  34. package/src/local/browser-broker-server.mjs +59 -0
  35. package/src/local/browser-request-registry.mjs +67 -0
  36. package/src/local/managed-job-lock.mjs +99 -0
  37. package/src/local/managed-job-projection.mjs +68 -0
  38. package/src/local/managed-job-runner.mjs +73 -0
  39. package/src/local/managed-job-storage.mjs +93 -0
  40. package/src/local/managed-jobs.mjs +12 -297
  41. package/src/local/relay-call-recovery.mjs +148 -0
  42. package/src/local/relay-connection.mjs +5 -0
  43. package/src/local/runtime-paths.mjs +107 -0
  44. package/src/local/runtime-relay.mjs +89 -0
  45. package/src/local/runtime-tool-handlers.mjs +66 -0
  46. package/src/local/runtime.mjs +80 -242
  47. package/src/local/windows-launcher.mjs +57 -0
  48. package/src/local/windows-service.mjs +7 -56
  49. package/src/worker/daemon-sockets.ts +10 -1
  50. package/src/worker/index.ts +51 -124
  51. package/src/worker/mcp-jsonrpc.ts +94 -0
  52. package/src/worker/oauth-authorization-page.ts +70 -0
  53. package/src/worker/oauth-controller.ts +9 -58
  54. package/src/worker/pending-call-contract.ts +26 -0
  55. package/src/worker/pending-calls.ts +41 -25
  56. package/src/worker/websocket-protocol.ts +24 -0
  57. package/tsconfig.local.json +7 -1
@@ -25,16 +25,23 @@ import {
25
25
  HttpError, applyCors, baseUrl, bearerToken, corsPreflight, json, methodNotAllowed,
26
26
  parseJsonRequest, workerErrorClass,
27
27
  } from "./http.ts";
28
+ import {
29
+ asObject, isJsonRpcRequest, isJsonRpcResponse, requiredString, rpcError, rpcResult,
30
+ sessionInstructionText, textToolResult, validateProtocolVersionHeader, type JsonRpcRequest,
31
+ } from "./mcp-jsonrpc.ts";
32
+ import {
33
+ closeWebSocketQuietly, isObjectRecord, rejectDaemonMessage, sendWebSocketQuietly,
34
+ } from "./websocket-protocol.ts";
28
35
 
29
36
  const SERVER_NAME = String(serverMetadata.name);
30
- const SERVER_VERSION = "1.2.8";
37
+ const SERVER_VERSION = "1.2.10";
31
38
  const MCP_PROTOCOL_VERSION = String(serverMetadata.protocolVersion);
32
39
  const MCP_SUPPORTED_PROTOCOL_VERSIONS = serverMetadata.supportedProtocolVersions.map((value) => String(value));
33
- const JSONRPC_VERSION = "2.0";
34
40
  const DEFAULT_MAX_BODY_BYTES = 8 * 1024 * 1024;
35
41
  const MAX_BODY_BYTES = 16 * 1024 * 1024;
36
42
  const MAX_PENDING_CALLS = 32;
37
43
  const MAX_DAEMON_MESSAGE_BYTES = 8 * 1024 * 1024;
44
+ const DAEMON_RECONNECT_GRACE_MS = 30_000;
38
45
 
39
46
  interface BridgeEnv extends OAuthControllerEnv {
40
47
  BRIDGE: DurableObjectNamespace<BridgeRoom>;
@@ -45,15 +52,6 @@ interface BridgeEnv extends OAuthControllerEnv {
45
52
  MBM_ALLOWED_ORIGINS?: string;
46
53
  }
47
54
 
48
- type JsonRpcId = string | number | null;
49
-
50
- interface JsonRpcRequest {
51
- jsonrpc: "2.0";
52
- id?: JsonRpcId;
53
- method: string;
54
- params?: unknown;
55
- }
56
-
57
55
  const MCP_INSTRUCTIONS = serverMetadata.instructions.map((value) => String(value)).join("\n");
58
56
 
59
57
  export class BridgeRoom extends DurableObject<BridgeEnv> {
@@ -182,11 +180,17 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
182
180
  return;
183
181
  }
184
182
  const daemonPolicy = sanitizeDaemonPolicy(body.policy);
183
+ const instanceId = daemonInstanceId(body.instance_id);
184
+ if (!instanceId) {
185
+ rejectDaemonMessage(ws, "invalid_daemon_instance", 1002, "daemon instance id required");
186
+ return;
187
+ }
185
188
  const authenticatedAt = new Date().toISOString();
186
189
  const probeId = randomToken("probe");
187
190
  this.daemonRegistry.beginProbe(ws, {
188
191
  connectedAt: authenticatedAt,
189
192
  probeId,
193
+ instanceId,
190
194
  policy: daemonPolicy,
191
195
  tools: sanitizeDaemonTools(body.tools, daemonPolicy),
192
196
  });
@@ -221,11 +225,17 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
221
225
  return;
222
226
  }
223
227
  const readyAt = new Date().toISOString();
224
- if (!this.daemonRegistry.promote(ws, readyAt)) {
228
+ const readyAttachment = this.daemonRegistry.promote(ws, readyAt);
229
+ if (!readyAttachment) {
225
230
  rejectDaemonMessage(ws, "invalid_relay_readiness_state", 1002, "invalid daemon readiness state");
226
231
  return;
227
232
  }
233
+ const reboundCallIds = this.pending.rebindInstance(readyAttachment.instanceId ?? "", ws);
234
+ if (reboundCallIds.length > 0) {
235
+ this.observability.event("info", "daemon.calls.rebound", { rebound_calls: reboundCallIds.length });
236
+ }
228
237
  try {
238
+ ws.send(JSON.stringify({ type: "resume_calls", ids: reboundCallIds }));
229
239
  ws.send(JSON.stringify({ type: "ready_ack", server: SERVER_NAME, version: SERVER_VERSION }));
230
240
  } catch {
231
241
  this.invalidateDaemonSocket(ws, "daemon readiness acknowledgement failed", "daemon ready timeout", "daemon_ready_timeout");
@@ -266,7 +276,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
266
276
 
267
277
  private async cleanupDaemonSocket(ws: WebSocket, message: string): Promise<void> {
268
278
  this.observability.socketDisconnected();
269
- this.pending.rejectSocket(ws, () => new WorkerToolError("unavailable", message, true));
279
+ this.detachDaemonSocketCalls(ws, message);
270
280
  await this.scheduleSocketAlarms();
271
281
  }
272
282
 
@@ -295,7 +305,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
295
305
  const body = await parseJsonRequest(request, this.bodyLimitBytes());
296
306
  if (isJsonRpcResponse(body)) return new Response(null, { status: 202 });
297
307
  if (!isJsonRpcRequest(body)) return json(rpcError(null, -32600, "Invalid JSON-RPC request"), 400);
298
- const protocolError = validateProtocolVersionHeader(request, body);
308
+ const protocolError = validateProtocolVersionHeader(request, body, MCP_SUPPORTED_PROTOCOL_VERSIONS);
299
309
  if (protocolError) return json(protocolError, 400);
300
310
 
301
311
  const session = await resolveMcpSession(request, body.method, this.oauth.identityKey(), authorized.tokenKey);
@@ -431,6 +441,9 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
431
441
  this.reclaimStaleDaemonSockets();
432
442
  const socket = this.daemonRegistry.readySockets()[0];
433
443
  if (!socket) throw new WorkerToolError("unavailable", "local daemon is not connected; keep the CLI start command running", true);
444
+ const daemonAttachment = this.daemonRegistry.readyAttachment(socket);
445
+ const daemonInstanceId = daemonAttachment?.instanceId ?? "";
446
+ if (!daemonInstanceId) throw new WorkerToolError("unavailable", "local daemon connection is missing its instance identity", true);
434
447
  const id = randomToken("call");
435
448
  const timeoutMs = daemonToolTimeoutMs(name, args);
436
449
  let result: Promise<unknown>;
@@ -438,20 +451,23 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
438
451
  result = this.pending.register({
439
452
  id,
440
453
  socket,
454
+ daemonInstanceId,
441
455
  clientRequestKey: requestKey,
442
456
  tool: name,
443
457
  timeoutMs,
444
458
  onTimeout: (record) => {
445
- sendWebSocketQuietly(record.socket, { type: "cancel_call", id: record.id });
446
- const silentForMs = Date.now() - daemonLastSeenMs(this.daemonRegistry.readyAttachment(record.socket));
447
- if (!Number.isFinite(silentForMs) || silentForMs > 45_000) {
459
+ if (record.socket) sendWebSocketQuietly(record.socket, { type: "cancel_call", id: record.id });
460
+ const silentForMs = record.socket
461
+ ? Date.now() - daemonLastSeenMs(this.daemonRegistry.readyAttachment(record.socket))
462
+ : 0;
463
+ if (record.socket && (!Number.isFinite(silentForMs) || silentForMs > 45_000)) {
448
464
  this.invalidateDaemonSocket(record.socket, "daemon became unresponsive", "daemon liveness timeout");
449
465
  }
450
466
  return new WorkerToolError("timeout", `daemon tool timed out: ${name}`, true);
451
467
  },
452
468
  signal,
453
469
  onAbort: (record) => {
454
- sendWebSocketQuietly(record.socket, { type: "cancel_call", id: record.id });
470
+ if (record.socket) sendWebSocketQuietly(record.socket, { type: "cancel_call", id: record.id });
455
471
  return new WorkerToolError("cancelled", "MCP client stopped waiting for the tool result");
456
472
  },
457
473
  });
@@ -485,7 +501,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
485
501
  private cancelClientRequest(requestKey?: string): void {
486
502
  if (!requestKey) return;
487
503
  this.pending.cancelRequest(requestKey, (record) => {
488
- sendWebSocketQuietly(record.socket, { type: "cancel_call", id: record.id });
504
+ if (record.socket) sendWebSocketQuietly(record.socket, { type: "cancel_call", id: record.id });
489
505
  return new WorkerToolError("cancelled", "tool call cancelled by client");
490
506
  });
491
507
  }
@@ -545,12 +561,24 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
545
561
  closeReason: string,
546
562
  errorCode = "daemon_liveness_timeout",
547
563
  ): void {
564
+ this.detachDaemonSocketCalls(ws, message);
548
565
  this.daemonRegistry.expire(ws);
549
- this.pending.rejectSocket(ws, () => new WorkerToolError("unavailable", message, true));
550
566
  sendWebSocketQuietly(ws, { type: "error", error: errorCode });
551
567
  closeWebSocketQuietly(ws, 1008, closeReason);
552
568
  }
553
569
 
570
+ private detachDaemonSocketCalls(ws: WebSocket, message: string): number {
571
+ const attachment = this.daemonRegistry.attachment(ws);
572
+ if (!attachment?.instanceId) {
573
+ return this.pending.rejectSocket(ws, () => new WorkerToolError("unavailable", message, true));
574
+ }
575
+ return this.pending.detachSocket(
576
+ ws,
577
+ DAEMON_RECONNECT_GRACE_MS,
578
+ () => new WorkerToolError("unavailable", `${message}; reconnect grace expired`, true),
579
+ );
580
+ }
581
+
554
582
  private reclaimStaleDaemonSockets(now = Date.now()): void {
555
583
  for (const socket of this.daemonRegistry.readyRoleSockets()) {
556
584
  const deadline = daemonLivenessDeadlineMs(this.daemonRegistry.readyAttachment(socket));
@@ -701,108 +729,7 @@ export default {
701
729
  },
702
730
  } satisfies ExportedHandler<BridgeEnv>;
703
731
 
704
- function isObjectRecord(value: unknown): value is Record<string, unknown> {
705
- return Boolean(value) && typeof value === "object" && !Array.isArray(value);
706
- }
707
-
708
- function rejectDaemonMessage(ws: WebSocket, error: string, closeCode: number, closeReason: string): void {
709
- sendWebSocketQuietly(ws, { type: "error", error });
710
- closeWebSocketQuietly(ws, closeCode, closeReason);
711
- }
712
-
713
- function sendWebSocketQuietly(ws: WebSocket, value: unknown): void {
714
- try {
715
- ws.send(typeof value === "string" ? value : JSON.stringify(value));
716
- } catch {
717
- // Best-effort protocol cleanup must not replace the primary timeout or rejection.
718
- }
719
- }
720
-
721
- function closeWebSocketQuietly(ws: WebSocket, code?: number, reason?: string): void {
722
- try {
723
- ws.close(code, reason);
724
- } catch {
725
- // The socket may already be closed or detached; no recovery remains at this boundary.
726
- }
727
- }
728
-
729
- function isJsonRpcRequest(value: unknown): value is JsonRpcRequest {
730
- if (!value || typeof value !== "object" || Array.isArray(value)) return false;
731
- const candidate = value as Record<string, unknown>;
732
- if (candidate.jsonrpc !== JSONRPC_VERSION || typeof candidate.method !== "string" || !candidate.method.trim() || candidate.method.length > 256) return false;
733
- if ("id" in candidate && !isJsonRpcId(candidate.id)) return false;
734
- return true;
735
- }
736
-
737
- function isJsonRpcId(value: unknown): value is JsonRpcId {
738
- return value === null || typeof value === "string" || (typeof value === "number" && Number.isFinite(value));
739
- }
740
-
741
- function isJsonRpcResponse(value: unknown): boolean {
742
- if (!value || typeof value !== "object" || Array.isArray(value)) return false;
743
- const candidate = value as Record<string, unknown>;
744
- if (candidate.jsonrpc !== JSONRPC_VERSION || !("id" in candidate) || !isJsonRpcId(candidate.id) || typeof candidate.method === "string") return false;
745
- return ("result" in candidate) !== ("error" in candidate);
746
- }
747
-
748
- function rpcResult(id: JsonRpcId | undefined, result: unknown): Record<string, unknown> | null {
749
- if (id === undefined) return null;
750
- return { jsonrpc: JSONRPC_VERSION, id, result };
751
- }
752
-
753
- function rpcError(id: JsonRpcId | undefined, code: number, message: string, data?: unknown): Record<string, unknown> {
754
- const error: Record<string, unknown> = { code, message };
755
- if (data !== undefined) error.data = data;
756
- return { jsonrpc: JSONRPC_VERSION, id: id ?? null, error };
757
- }
758
-
759
- function textToolResult(value: unknown, isError = false): Record<string, unknown> {
760
- const special = asObject(value).$mcp;
761
- if (special && typeof special === "object" && !Array.isArray(special)) {
762
- const specialObject = special as Record<string, unknown>;
763
- if (Array.isArray(specialObject.content)) {
764
- const result: Record<string, unknown> = { content: specialObject.content, isError };
765
- if (specialObject.structuredContent && typeof specialObject.structuredContent === "object" && !Array.isArray(specialObject.structuredContent)) {
766
- result.structuredContent = specialObject.structuredContent;
767
- }
768
- return result;
769
- }
770
- }
771
- const result: Record<string, unknown> = {
772
- content: [{ type: "text", text: typeof value === "string" ? value : JSON.stringify(value, null, 2) }],
773
- isError,
774
- };
775
- if (value && typeof value === "object" && !Array.isArray(value)) result.structuredContent = value;
776
- return result;
777
- }
778
-
779
- function asObject(value: unknown): Record<string, unknown> {
780
- if (!value || typeof value !== "object" || Array.isArray(value)) return {};
781
- return value as Record<string, unknown>;
782
- }
783
-
784
- function requiredString(value: Record<string, unknown>, key: string): string {
785
- const field = value[key];
786
- if (typeof field !== "string" || !field.trim()) throw new Error(`${key} must be a non-empty string`);
787
- return field.trim();
788
- }
789
-
790
- function sessionInstructionText(value: unknown): string {
791
- const object = asObject(value);
792
- const instructions = typeof object.instructions === "string" ? object.instructions : "";
793
- if (!instructions) return "";
794
- const bytes = new TextEncoder().encode(instructions);
795
- if (bytes.byteLength > 3 * 1024 * 1024) return "";
796
- return instructions;
797
- }
798
-
799
- function validateProtocolVersionHeader(request: Request, body: JsonRpcRequest): Record<string, unknown> | null {
800
- if (body.method === "initialize") return null;
801
- const version = request.headers.get("MCP-Protocol-Version");
802
- if (!version) return null;
803
- if (MCP_SUPPORTED_PROTOCOL_VERSIONS.includes(version as typeof MCP_SUPPORTED_PROTOCOL_VERSIONS[number])) return null;
804
- return rpcError(body.id, -32602, "Unsupported MCP protocol version", {
805
- requested: version,
806
- supported: [...MCP_SUPPORTED_PROTOCOL_VERSIONS],
807
- });
732
+ function daemonInstanceId(value: unknown): string {
733
+ if (typeof value !== "string" || !/^daemon_[A-Za-z0-9_-]{16,96}$/.test(value)) return "";
734
+ return value;
808
735
  }
@@ -0,0 +1,94 @@
1
+ const JSONRPC_VERSION = "2.0";
2
+ const MAX_SESSION_INSTRUCTION_BYTES = 3 * 1024 * 1024;
3
+
4
+ export type JsonRpcId = string | number | null;
5
+
6
+ export interface JsonRpcRequest {
7
+ jsonrpc: "2.0";
8
+ id?: JsonRpcId;
9
+ method: string;
10
+ params?: unknown;
11
+ }
12
+
13
+ export function isJsonRpcRequest(value: unknown): value is JsonRpcRequest {
14
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
15
+ const candidate = value as Record<string, unknown>;
16
+ if (candidate.jsonrpc !== JSONRPC_VERSION || typeof candidate.method !== "string" || !candidate.method.trim() || candidate.method.length > 256) return false;
17
+ if ("id" in candidate && !isJsonRpcId(candidate.id)) return false;
18
+ return true;
19
+ }
20
+
21
+ export function isJsonRpcResponse(value: unknown): boolean {
22
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
23
+ const candidate = value as Record<string, unknown>;
24
+ if (candidate.jsonrpc !== JSONRPC_VERSION || !("id" in candidate) || !isJsonRpcId(candidate.id) || typeof candidate.method === "string") return false;
25
+ return ("result" in candidate) !== ("error" in candidate);
26
+ }
27
+
28
+ export function rpcResult(id: JsonRpcId | undefined, result: unknown): Record<string, unknown> | null {
29
+ if (id === undefined) return null;
30
+ return { jsonrpc: JSONRPC_VERSION, id, result };
31
+ }
32
+
33
+ export function rpcError(id: JsonRpcId | undefined, code: number, message: string, data?: unknown): Record<string, unknown> {
34
+ const error: Record<string, unknown> = { code, message };
35
+ if (data !== undefined) error.data = data;
36
+ return { jsonrpc: JSONRPC_VERSION, id: id ?? null, error };
37
+ }
38
+
39
+ export function textToolResult(value: unknown, isError = false): Record<string, unknown> {
40
+ const special = asObject(value).$mcp;
41
+ if (special && typeof special === "object" && !Array.isArray(special)) {
42
+ const specialObject = special as Record<string, unknown>;
43
+ if (Array.isArray(specialObject.content)) {
44
+ const result: Record<string, unknown> = { content: specialObject.content, isError };
45
+ if (specialObject.structuredContent && typeof specialObject.structuredContent === "object" && !Array.isArray(specialObject.structuredContent)) {
46
+ result.structuredContent = specialObject.structuredContent;
47
+ }
48
+ return result;
49
+ }
50
+ }
51
+ const result: Record<string, unknown> = {
52
+ content: [{ type: "text", text: typeof value === "string" ? value : JSON.stringify(value, null, 2) }],
53
+ isError,
54
+ };
55
+ if (value && typeof value === "object" && !Array.isArray(value)) result.structuredContent = value;
56
+ return result;
57
+ }
58
+
59
+ export function asObject(value: unknown): Record<string, unknown> {
60
+ if (!value || typeof value !== "object" || Array.isArray(value)) return {};
61
+ return value as Record<string, unknown>;
62
+ }
63
+
64
+ export function requiredString(value: Record<string, unknown>, key: string): string {
65
+ const field = value[key];
66
+ if (typeof field !== "string" || !field.trim()) throw new Error(`${key} must be a non-empty string`);
67
+ return field.trim();
68
+ }
69
+
70
+ export function sessionInstructionText(value: unknown): string {
71
+ const object = asObject(value);
72
+ const instructions = typeof object.instructions === "string" ? object.instructions : "";
73
+ if (!instructions) return "";
74
+ if (new TextEncoder().encode(instructions).byteLength > MAX_SESSION_INSTRUCTION_BYTES) return "";
75
+ return instructions;
76
+ }
77
+
78
+ export function validateProtocolVersionHeader(
79
+ request: Request,
80
+ body: JsonRpcRequest,
81
+ supportedVersions: readonly string[],
82
+ ): Record<string, unknown> | null {
83
+ if (body.method === "initialize") return null;
84
+ const version = request.headers.get("MCP-Protocol-Version");
85
+ if (!version || supportedVersions.includes(version)) return null;
86
+ return rpcError(body.id, -32602, "Unsupported MCP protocol version", {
87
+ requested: version,
88
+ supported: [...supportedVersions],
89
+ });
90
+ }
91
+
92
+ function isJsonRpcId(value: unknown): value is JsonRpcId {
93
+ return value === null || typeof value === "string" || (typeof value === "number" && Number.isFinite(value));
94
+ }
@@ -0,0 +1,70 @@
1
+ import type { ValidatedAuthorization } from "./oauth-state.ts";
2
+ import {
3
+ escapeHtml, html, normalizeDisplayText, searchParamsEntries,
4
+ } from "./http.ts";
5
+
6
+ const AUTHORIZATION_FIELDS = new Set([
7
+ "response_type", "client_id", "redirect_uri", "code_challenge",
8
+ "code_challenge_method", "scope", "resource", "state",
9
+ ]);
10
+
11
+ export interface AuthorizationPageOptions {
12
+ request: Request;
13
+ base: string;
14
+ serverName: string;
15
+ error?: string;
16
+ submitted?: Record<string, unknown>;
17
+ status?: number;
18
+ authorization?: ValidatedAuthorization;
19
+ allowSubmit?: boolean;
20
+ }
21
+
22
+ export function authorizationPage({
23
+ request,
24
+ base,
25
+ serverName,
26
+ error = "",
27
+ submitted,
28
+ status = 200,
29
+ authorization,
30
+ allowSubmit = true,
31
+ }: AuthorizationPageOptions): Response {
32
+ const url = new URL(request.url);
33
+ const sourceEntries = submitted ? Object.entries(submitted) : searchParamsEntries(url.searchParams);
34
+ const hidden = sourceEntries
35
+ .filter(([key]) => AUTHORIZATION_FIELDS.has(key))
36
+ .map(([key, value]) => `<input type="hidden" name="${escapeHtml(key)}" value="${escapeHtml(String(value))}">`)
37
+ .join("\n");
38
+ const resource = normalizeDisplayText(
39
+ authorization?.requestedResource ?? String(submitted?.resource ?? url.searchParams.get("resource") ?? `${base}/mcp`),
40
+ 1024,
41
+ `${base}/mcp`,
42
+ );
43
+ const clientBlock = authorization
44
+ ? `<p><strong>Client:</strong> ${escapeHtml(authorization.client.client_name)}</p>
45
+ <p><strong>Redirect URI:</strong> <code>${escapeHtml(authorization.redirectUri)}</code></p>`
46
+ : "";
47
+ const errorBlock = error ? `<p role="alert" aria-live="assertive" style="color:#b91c1c; font-weight:600">${escapeHtml(error)}</p>` : "";
48
+ const accountName = normalizeDisplayText(String(submitted?.account_name ?? ""), 64, "");
49
+ const form = allowSubmit
50
+ ? `<form method="post" action="/oauth/authorize">
51
+ ${hidden}
52
+ <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>
53
+ <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>
54
+ <p><button type="submit">Authorize</button></p>
55
+ </form>`
56
+ : "<p>Authorization cannot continue. Return to the MCP client and start the connection again.</p>";
57
+ const redirectOrigin = authorization ? new URL(authorization.redirectUri).origin : "";
58
+ return html(`<!doctype html>
59
+ <html>
60
+ <head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Authorize ${serverName}</title></head>
61
+ <body style="font-family: system-ui, sans-serif; max-width: 640px; margin: 48px auto; line-height: 1.5; padding: 0 16px;">
62
+ <h1>Authorize ${serverName}</h1>
63
+ <p>Only continue if you initiated this MCP connection and recognize the client and redirect URI below.</p>
64
+ ${clientBlock}
65
+ <p><strong>Resource:</strong> <code>${escapeHtml(resource)}</code></p>
66
+ ${errorBlock}
67
+ ${form}
68
+ </body>
69
+ </html>`, status, redirectOrigin);
70
+ }
@@ -5,12 +5,13 @@ import {
5
5
  AUTH_BLOCK_SECONDS, accountByName, authorizationIdentity, emptyOAuthStore,
6
6
  isCurrentOAuthStore, pruneAuthFailures, pruneClientRecordByExpiry, pruneRecordByExpiry, randomToken,
7
7
  recordAuthorizationFailure, safeEqual, sha256Hex, validateAuthorizationRequest, verifyAccountPassword,
8
- type OAuthClient, type OAuthStore, type ValidatedAuthorization,
8
+ type OAuthClient, type OAuthStore,
9
9
  } from "./oauth-state.ts";
10
10
  import {
11
- HttpError, authorizationRedirectLocation, escapeHtml, html, json, normalizeDisplayText,
12
- normalizeRedirectUri, oauthRedirect, parseRequestBody, searchParamsEntries, searchParamsObject,
11
+ HttpError, authorizationRedirectLocation, json, normalizeDisplayText,
12
+ normalizeRedirectUri, oauthRedirect, parseRequestBody, searchParamsObject,
13
13
  } from "./http.ts";
14
+ import { authorizationPage } from "./oauth-authorization-page.ts";
14
15
 
15
16
  const OAUTH_BODY_LIMIT_BYTES = 64 * 1024;
16
17
  const OAUTH_UNUSED_CLIENT_TTL_SECONDS = 60 * 60;
@@ -20,7 +21,6 @@ const OAUTH_CLIENT_IDLE_TTL_SECONDS = 60 * 60 * 24 * 90;
20
21
  const MAX_CODES_PER_CLIENT = 10;
21
22
  const MAX_OAUTH_CODES = 200;
22
23
  const MAX_AUTH_FAILURE_IDENTITIES = 200;
23
- const AUTHORIZATION_FIELDS = new Set(["response_type", "client_id", "redirect_uri", "code_challenge", "code_challenge_method", "scope", "resource", "state"]);
24
24
 
25
25
  export interface OAuthControllerEnv {
26
26
  ACCOUNT_ADMIN_SECRET: string;
@@ -176,75 +176,26 @@ export class OAuthController {
176
176
  const store = await this.oauthStore();
177
177
  const validation = validateAuthorizationRequest(body, base, this.serverName, store);
178
178
  if ("error" in validation) {
179
- return this.authorizePage(request, base, validation.error, body, validation.status, undefined, false);
179
+ return authorizationPage({ request, base, serverName: this.serverName, error: validation.error, submitted: body, status: validation.status, allowSubmit: false });
180
180
  }
181
- return this.authorizePage(request, base, "", body, 200, validation.value, true);
181
+ return authorizationPage({ request, base, serverName: this.serverName, submitted: body, authorization: validation.value });
182
182
  });
183
183
  }
184
184
 
185
- private authorizePage(
186
- request: Request,
187
- base: string,
188
- error = "",
189
- submitted?: Record<string, unknown>,
190
- status = 200,
191
- authorization?: ValidatedAuthorization,
192
- allowSubmit = true,
193
- ): Response {
194
- const url = new URL(request.url);
195
- const sourceEntries = submitted ? Object.entries(submitted) : searchParamsEntries(url.searchParams);
196
- const hidden = sourceEntries
197
- .filter(([key]) => AUTHORIZATION_FIELDS.has(key))
198
- .map(([key, value]) => `<input type="hidden" name="${escapeHtml(key)}" value="${escapeHtml(String(value))}">`)
199
- .join("\n");
200
- const resource = normalizeDisplayText(
201
- authorization?.requestedResource ?? String(submitted?.resource ?? url.searchParams.get("resource") ?? `${base}/mcp`),
202
- 1024,
203
- `${base}/mcp`,
204
- );
205
- const clientBlock = authorization
206
- ? `<p><strong>Client:</strong> ${escapeHtml(authorization.client.client_name)}</p>
207
- <p><strong>Redirect URI:</strong> <code>${escapeHtml(authorization.redirectUri)}</code></p>`
208
- : "";
209
- const errorBlock = error ? `<p role="alert" aria-live="assertive" style="color:#b91c1c; font-weight:600">${escapeHtml(error)}</p>` : "";
210
- const accountName = normalizeDisplayText(String(submitted?.account_name ?? ""), 64, "");
211
- const form = allowSubmit
212
- ? `<form method="post" action="/oauth/authorize">
213
- ${hidden}
214
- <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>
215
- <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>
216
- <p><button type="submit">Authorize</button></p>
217
- </form>`
218
- : "<p>Authorization cannot continue. Return to the MCP client and start the connection again.</p>";
219
- const redirectOrigin = authorization ? new URL(authorization.redirectUri).origin : "";
220
- return html(`<!doctype html>
221
- <html>
222
- <head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Authorize ${this.serverName}</title></head>
223
- <body style="font-family: system-ui, sans-serif; max-width: 640px; margin: 48px auto; line-height: 1.5; padding: 0 16px;">
224
- <h1>Authorize ${this.serverName}</h1>
225
- <p>Only continue if you initiated this MCP connection and recognize the client and redirect URI below.</p>
226
- ${clientBlock}
227
- <p><strong>Resource:</strong> <code>${escapeHtml(resource)}</code></p>
228
- ${errorBlock}
229
- ${form}
230
- </body>
231
- </html>`, status, redirectOrigin);
232
- }
233
-
234
185
  async authorizeSubmit(request: Request, base: string): Promise<Response> {
235
186
  const body = await parseRequestBody(request, OAUTH_BODY_LIMIT_BYTES);
236
187
  return this.withOAuthLock(async () => {
237
188
  const store = await this.oauthStore();
238
189
  const validation = validateAuthorizationRequest(body, base, this.serverName, store);
239
190
  if ("error" in validation) {
240
- return this.authorizePage(request, base, validation.error, body, validation.status, undefined, false);
191
+ return authorizationPage({ request, base, serverName: this.serverName, error: validation.error, submitted: body, status: validation.status, allowSubmit: false });
241
192
  }
242
193
  const { client, clientId, redirectUri, codeChallenge, requestedResource, scope, state } = validation.value;
243
194
  const now = Math.floor(Date.now() / 1000);
244
195
  const identity = await authorizationIdentity(request, this.identityKey());
245
196
  const failure = store.auth_failures[identity];
246
197
  if (failure?.blocked_until > now) {
247
- return this.authorizePage(request, base, "Too many failed attempts. Try again later.", body, 429, validation.value);
198
+ return authorizationPage({ request, base, serverName: this.serverName, error: "Too many failed attempts. Try again later.", submitted: body, status: 429, authorization: validation.value });
248
199
  }
249
200
 
250
201
  const account = accountByName(store, body.account_name);
@@ -254,7 +205,7 @@ export class OAuthController {
254
205
  pruneAuthFailures(store, MAX_AUTH_FAILURE_IDENTITIES);
255
206
  await this.ctx.storage.put("oauth", store);
256
207
  const status = store.auth_failures[identity]?.blocked_until > now ? 429 : 401;
257
- return this.authorizePage(request, base, "Invalid account credentials.", body, status, validation.value);
208
+ return authorizationPage({ request, base, serverName: this.serverName, error: "Invalid account credentials.", submitted: body, status, authorization: validation.value });
258
209
  }
259
210
  delete store.auth_failures[identity];
260
211
  client.last_used_at = now;
@@ -0,0 +1,26 @@
1
+ export interface PendingCallRecord {
2
+ id: string;
3
+ socket?: WebSocket;
4
+ daemonInstanceId?: string;
5
+ reconnectTimeout?: ReturnType<typeof setTimeout>;
6
+ clientRequestKey?: string;
7
+ tool: string;
8
+ startedAt: number;
9
+ timeout: ReturnType<typeof setTimeout>;
10
+ resolve: (value: unknown) => void;
11
+ reject: (error: Error) => void;
12
+ signal?: AbortSignal;
13
+ abortHandler?: () => void;
14
+ }
15
+
16
+ export interface RegisterPendingCall {
17
+ id: string;
18
+ socket: WebSocket;
19
+ daemonInstanceId?: string;
20
+ clientRequestKey?: string;
21
+ tool: string;
22
+ timeoutMs: number;
23
+ onTimeout: (record: PendingCallRecord) => Error;
24
+ signal?: AbortSignal;
25
+ onAbort?: (record: PendingCallRecord) => Error;
26
+ }