machine-bridge-mcp 3.0.0-beta.17 → 3.0.0-beta.21

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 (74) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +2 -0
  3. package/SECURITY.md +9 -7
  4. package/browser-extension/browser-error-boundary.js +45 -0
  5. package/browser-extension/browser-operations.js +1 -1
  6. package/browser-extension/manifest.json +1 -1
  7. package/browser-extension/service-worker.js +9 -8
  8. package/docs/ARCHITECTURE.md +17 -15
  9. package/docs/AUDIT.md +30 -0
  10. package/docs/ENGINEERING.md +3 -3
  11. package/docs/GETTING_STARTED.md +1 -1
  12. package/docs/LOCAL_AUTHORIZATION.md +2 -2
  13. package/docs/LOCAL_AUTOMATION.md +1 -1
  14. package/docs/LOGGING.md +3 -1
  15. package/docs/MULTI_ACCOUNT.md +2 -2
  16. package/docs/OPERATIONS.md +13 -5
  17. package/docs/OVERVIEW.md +2 -2
  18. package/docs/PRIVACY.md +1 -1
  19. package/docs/TESTING.md +17 -13
  20. package/docs/THREAT_MODEL.md +13 -8
  21. package/docs/UPGRADING.md +19 -1
  22. package/package.json +1 -1
  23. package/scripts/coverage-check.mjs +12 -0
  24. package/src/local/account-admin.mjs +68 -1
  25. package/src/local/agent-skill-discovery.mjs +16 -5
  26. package/src/local/app-automation.mjs +27 -7
  27. package/src/local/cli-options.mjs +1 -1
  28. package/src/local/path-inspection.mjs +23 -0
  29. package/src/local/process-tree-ownership.mjs +17 -5
  30. package/src/local/process-tree.mjs +6 -2
  31. package/src/local/relay-connection.mjs +24 -11
  32. package/src/local/resource-operations.mjs +37 -8
  33. package/src/local/runtime-activation.mjs +16 -0
  34. package/src/local/runtime-capabilities.mjs +15 -3
  35. package/src/local/runtime.mjs +7 -4
  36. package/src/local/worker-deployment.mjs +16 -6
  37. package/src/local/workspace-file-service.mjs +29 -11
  38. package/src/worker/daemon-socket-attachment.ts +52 -0
  39. package/src/worker/daemon-sockets.ts +17 -48
  40. package/src/worker/durable-stream-calls.ts +129 -0
  41. package/src/worker/durable-stream-result.ts +22 -0
  42. package/src/worker/http.ts +37 -15
  43. package/src/worker/index.ts +143 -146
  44. package/src/worker/mcp-pending-call-expiry.ts +20 -0
  45. package/src/worker/mcp-pending-call-inspection.ts +33 -0
  46. package/src/worker/mcp-pending-call-records.ts +58 -0
  47. package/src/worker/mcp-pending-call-storage.ts +18 -0
  48. package/src/worker/mcp-pending-call-store.ts +285 -0
  49. package/src/worker/mcp-resumption-index.ts +41 -0
  50. package/src/worker/mcp-resumption-records.ts +8 -2
  51. package/src/worker/mcp-resumption.ts +84 -86
  52. package/src/worker/mcp-stream-dispatch.ts +42 -54
  53. package/src/worker/mcp-stream-proxy.ts +7 -69
  54. package/src/worker/mcp-stream-subscription.ts +98 -0
  55. package/src/worker/oauth-controller.ts +11 -2
  56. package/src/worker/oauth-refresh-exchange.ts +147 -0
  57. package/src/worker/oauth-refresh-families.ts +59 -17
  58. package/src/worker/oauth-state.ts +13 -2
  59. package/src/worker/oauth-token-derivation.ts +33 -0
  60. package/src/worker/oauth-token-issuance.ts +106 -0
  61. package/src/worker/oauth-tokens.ts +12 -184
  62. package/src/worker/observability.ts +19 -0
  63. package/src/worker/pending-admission.ts +15 -0
  64. package/src/worker/pending-call-contract.ts +4 -7
  65. package/src/worker/pending-calls.ts +4 -10
  66. package/src/worker/runtime-alarm-storage.ts +35 -0
  67. package/src/worker/runtime-alarm.ts +30 -20
  68. package/src/worker/websocket-protocol.ts +4 -0
  69. package/src/worker/worker-edge-guard.ts +81 -0
  70. package/src/worker/worker-edge-log.ts +63 -0
  71. package/src/worker/worker-entry.ts +50 -0
  72. package/src/worker/worker-metadata.ts +39 -0
  73. package/src/worker/worker-static-routes.ts +38 -50
  74. package/wrangler.jsonc +8 -0
@@ -1,10 +1,13 @@
1
- import type { PendingCallOutcome, PendingCallRecord } from "./pending-call-contract.ts";
2
- import { PendingCallRegistrationError, PendingCallRegistry } from "./pending-calls.ts";
3
- import type { DaemonSocketRegistry } from "./daemon-sockets.ts";
1
+ import type { PendingCallOutcome } from "./pending-call-contract.ts";
2
+ import type { JsonRpcMessage, McpResumptionStore } from "./mcp-resumption.ts";
3
+ import {
4
+ McpPendingCallConflictError,
5
+ McpPendingCallLimitError,
6
+ } from "./mcp-pending-call-store.ts";
7
+ import type { PendingStreamTransform } from "./mcp-pending-call-records.ts";
4
8
  import { publicWorkerToolError, WorkerToolError } from "./errors.ts";
5
9
  import { workerErrorClass } from "./http.ts";
6
10
  import { rpcResult, textToolResult } from "./mcp-jsonrpc.ts";
7
- import type { JsonRpcMessage, McpResumptionStore } from "./mcp-resumption.ts";
8
11
  import { randomToken } from "./oauth-state.ts";
9
12
  import type { WorkerObservability } from "./observability.ts";
10
13
 
@@ -17,7 +20,6 @@ export type StreamCallAuthorization = {
17
20
  };
18
21
 
19
22
  type StartStreamCallInput = {
20
- pending: PendingCallRegistry;
21
23
  resumption: McpResumptionStore;
22
24
  observability: WorkerObservability;
23
25
  streamId: string;
@@ -27,11 +29,13 @@ type StartStreamCallInput = {
27
29
  arguments: Record<string, unknown>;
28
30
  socket: WebSocket;
29
31
  daemonInstanceId: string;
32
+ connectionId: string;
30
33
  timeoutMs: number;
31
34
  authorization: StreamCallAuthorization;
32
- onTimeout: (record: PendingCallRecord) => Error;
35
+ transientActiveCount: number;
36
+ maximumPendingCalls: number;
37
+ transform?: PendingStreamTransform;
33
38
  onSendFailure: () => void | Promise<void>;
34
- transformResult?: (value: unknown) => unknown;
35
39
  };
36
40
 
37
41
  type ImmediateOutcomeInput = {
@@ -50,9 +54,10 @@ export function buildServerInfoResult(input: {
50
54
  oauth: Record<string, unknown>;
51
55
  authorization: Record<string, any>;
52
56
  daemon: Record<string, any>;
53
- tools: string[];
54
- pending: PendingCallRegistry;
55
- daemonRegistry: DaemonSocketRegistry;
57
+ effectiveTools: string[];
58
+ advertisedTools: string[];
59
+ pendingSnapshot: Record<string, unknown>;
60
+ daemonRegistry: import("./daemon-sockets.ts").DaemonSocketRegistry;
56
61
  observability: WorkerObservability;
57
62
  }): Record<string, unknown> {
58
63
  const probing = input.daemonRegistry.probingSockets().length;
@@ -68,7 +73,7 @@ export function buildServerInfoResult(input: {
68
73
  authority_summary: input.authorization.summary,
69
74
  daemon: input.daemon,
70
75
  worker: {
71
- pending_calls: input.pending.snapshot(),
76
+ pending_calls: input.pendingSnapshot,
72
77
  daemon_candidates: candidates,
73
78
  daemon_probes: probing,
74
79
  sockets_live: {
@@ -79,14 +84,15 @@ export function buildServerInfoResult(input: {
79
84
  },
80
85
  observability: input.observability.snapshot(),
81
86
  },
82
- tools: input.tools,
87
+ tools: input.effectiveTools,
83
88
  tools_scope: "authenticated_account_effective_tools_before_host_filtering",
84
89
  tool_delivery: {
85
90
  full_profile_scope: "daemon-capability-ceiling-before-account-filtering",
86
91
  daemon_advertised_tool_count: input.daemon.tool_count,
87
- relay_advertised_tool_count: input.tools.length,
88
- effective_account_tool_count: input.tools.length,
89
- relay_advertised_scope: "authenticated_account_effective_tools_before_host_filtering",
92
+ relay_advertised_tool_count: input.advertisedTools.length,
93
+ effective_account_tool_count: input.effectiveTools.length,
94
+ relay_advertised_scope: "stable_authenticated_account_catalog_before_host_filtering",
95
+ effective_scope: "live_daemon_and_account_intersection_before_host_filtering",
90
96
  host_exposed_tools_known_to_server: false,
91
97
  host_may_expose_subset: true,
92
98
  },
@@ -95,30 +101,28 @@ export function buildServerInfoResult(input: {
95
101
 
96
102
  export async function startEventDrivenStreamCall(input: StartStreamCallInput): Promise<void> {
97
103
  const callId = randomToken("call");
98
- const settle = createStreamSettlement({
99
- resumption: input.resumption,
100
- observability: input.observability,
101
- streamId: input.streamId,
102
- requestId: input.requestId,
103
- tool: input.tool,
104
- transformResult: input.transformResult,
105
- });
106
104
  try {
107
- input.pending.registerEvent({
108
- id: callId,
109
- socket: input.socket,
105
+ await input.resumption.calls.activate({
106
+ streamId: input.streamId,
107
+ callId,
110
108
  daemonInstanceId: input.daemonInstanceId,
109
+ connectionId: input.connectionId,
111
110
  clientRequestKey: input.clientRequestKey,
112
111
  tool: input.tool,
113
112
  timeoutMs: input.timeoutMs,
114
- onTimeout: input.onTimeout,
115
- settle,
113
+ transform: input.transform,
114
+ transientActiveCount: input.transientActiveCount,
115
+ maximumPendingCalls: input.maximumPendingCalls,
116
116
  });
117
117
  } catch (error) {
118
- if (error instanceof PendingCallRegistrationError) throw new WorkerToolError(error.code, error.message, error.retryable);
118
+ if (error instanceof McpPendingCallLimitError) {
119
+ throw new WorkerToolError("limit_exceeded", error.message, true);
120
+ }
121
+ if (error instanceof McpPendingCallConflictError) {
122
+ throw new WorkerToolError("conflict", error.message);
123
+ }
119
124
  throw error;
120
125
  }
121
- input.resumption.activate(input.streamId);
122
126
  input.observability.callStarted(input.tool);
123
127
  try {
124
128
  input.socket.send(JSON.stringify({
@@ -130,7 +134,9 @@ export async function startEventDrivenStreamCall(input: StartStreamCallInput): P
130
134
  authorization: input.authorization,
131
135
  }));
132
136
  } catch {
133
- await input.pending.reject(callId, new WorkerToolError("network_error", "failed to send daemon tool call", true), input.socket);
137
+ const error = new WorkerToolError("network_error", "failed to send daemon tool call", true);
138
+ await input.resumption.calls.complete(callId, input.connectionId, streamTerminalMessage(input.requestId, { ok: false, error }));
139
+ input.observability.callFinished(input.tool, error.code);
134
140
  await input.onSendFailure();
135
141
  }
136
142
  }
@@ -144,28 +150,7 @@ export async function persistImmediateStreamOutcome(input: ImmediateOutcomeInput
144
150
  }
145
151
  }
146
152
 
147
- function createStreamSettlement(input: {
148
- resumption: McpResumptionStore;
149
- observability: WorkerObservability;
150
- streamId: string;
151
- requestId: string | number;
152
- tool: string;
153
- transformResult?: (value: unknown) => unknown;
154
- }): (outcome: PendingCallOutcome) => Promise<void> {
155
- return async (outcome) => {
156
- const normalized = normalizeOutcome(outcome, input.transformResult);
157
- const code = normalized.ok ? "" : publicWorkerToolError(normalized.error).code;
158
- const message = streamTerminalMessage(input.requestId, normalized);
159
- try {
160
- await input.resumption.complete(input.streamId, message);
161
- } catch (error) {
162
- input.observability.event("error", "mcp.stream.persist.failed", { error_class: workerErrorClass(error) });
163
- }
164
- input.observability.callFinished(input.tool, code);
165
- };
166
- }
167
-
168
- function streamTerminalMessage(
153
+ export function streamTerminalMessage(
169
154
  requestId: string | number,
170
155
  outcome: PendingCallOutcome,
171
156
  transformResult?: (value: unknown) => unknown,
@@ -179,7 +164,10 @@ function streamTerminalMessage(
179
164
  return message;
180
165
  }
181
166
 
182
- function normalizeOutcome(outcome: PendingCallOutcome, transformResult?: (value: unknown) => unknown): PendingCallOutcome {
167
+ export function normalizeOutcome(
168
+ outcome: PendingCallOutcome,
169
+ transformResult?: (value: unknown) => unknown,
170
+ ): PendingCallOutcome {
183
171
  if (!outcome.ok || !transformResult) return outcome;
184
172
  try { return { ok: true, value: transformResult(outcome.value) }; }
185
173
  catch (error) { return { ok: false, error: error instanceof Error ? error : new Error("stream result transformation failed") }; }
@@ -1,14 +1,12 @@
1
- import relayContract from "../shared/relay-contract.json" with { type: "json" };
2
1
  import { applyCors, baseUrl, json } from "./http.ts";
3
2
  import { acceptsEventStream, resumeJsonRpcResponse, streamJsonRpcResponse } from "./mcp-stream.ts";
4
3
  import type { McpStreamChannel } from "./mcp-stream-channel.ts";
5
- import type { JsonRpcMessage } from "./mcp-resumption.ts";
4
+ import { DEFAULT_SUBSCRIBE_RETRY_DELAYS_MS, subscribeTerminalMessage } from "./mcp-stream-subscription.ts";
6
5
 
7
6
  export const MCP_STREAM_PROXY_MODE_HEADER = "x-machine-bridge-internal-mcp-stream-mode";
8
7
  export const MCP_STREAM_PROXY_ID_HEADER = "x-machine-bridge-internal-mcp-stream-id";
9
8
  const MCP_STREAM_DESCRIPTOR_HEADER = "x-machine-bridge-mcp-stream-descriptor";
10
9
  const STREAM_ID_PATTERN = /^stream_[A-Za-z0-9_-]{43}$/;
11
- const MAX_TERMINAL_MESSAGE_BYTES = relayContract.maximumResumableMessageBytes + 1024;
12
10
 
13
11
  type StreamProxyMode = "prepare" | "subscribe" | "";
14
12
  type StreamDescriptorKind = "initial" | "resume" | "complete";
@@ -21,6 +19,7 @@ export async function proxyMcpEventStream(input: {
21
19
  bridge: BridgeFetcher;
22
20
  extraOrigins: string;
23
21
  ctx: StreamExecutionContext;
22
+ subscribeRetryDelaysMs?: readonly number[];
24
23
  }): Promise<Response | null> {
25
24
  const url = new URL(input.request.url);
26
25
  const eligible = input.request.method === "GET"
@@ -39,8 +38,11 @@ export async function proxyMcpEventStream(input: {
39
38
  );
40
39
  }
41
40
 
42
- const terminal = subscribeTerminalMessage(input.bridge, input.request.url, descriptor.stream_id);
43
- input.ctx.waitUntil(terminal.then(() => undefined, () => undefined));
41
+ const terminal = subscribeTerminalMessage(
42
+ input.bridge,
43
+ () => withProxyHeaders(new Request(input.request.url, { method: "GET" }), "subscribe", descriptor.stream_id),
44
+ input.subscribeRetryDelaysMs ?? DEFAULT_SUBSCRIBE_RETRY_DELAYS_MS,
45
+ );
44
46
  const options = {
45
47
  streamId: descriptor.stream_id,
46
48
  keepAlive: (promise: Promise<void>) => input.ctx.waitUntil(promise),
@@ -94,70 +96,6 @@ function withProxyHeaders(request: Request, mode: Exclude<StreamProxyMode, "">,
94
96
  return new Request(sanitized, { method: mode === "subscribe" ? "GET" : sanitized.method, headers });
95
97
  }
96
98
 
97
- async function subscribeTerminalMessage(
98
- bridge: BridgeFetcher,
99
- requestUrl: string,
100
- streamId: string,
101
- ): Promise<JsonRpcMessage> {
102
- const internal = withProxyHeaders(new Request(requestUrl, { method: "GET" }), "subscribe", streamId);
103
- const response = await bridge.fetch(internal);
104
- if (response.status === 200) return await readJsonRpcResponse(response);
105
- if (response.status !== 101 || !response.webSocket) {
106
- throw new Error(`internal MCP stream subscription failed (${response.status})`);
107
- }
108
- const socket = response.webSocket;
109
- socket.accept();
110
- return await terminalMessageFromSocket(socket);
111
- }
112
-
113
- function terminalMessageFromSocket(socket: WebSocket): Promise<JsonRpcMessage> {
114
- return new Promise((resolve, reject) => {
115
- let settled = false;
116
- const fail = (message: string) => {
117
- if (settled) return;
118
- settled = true;
119
- reject(new Error(message));
120
- };
121
- const succeed = (message: JsonRpcMessage) => {
122
- if (settled) return;
123
- settled = true;
124
- resolve(message);
125
- try { socket.close(1000, "terminal received"); } catch { /* Peer may have already completed the close handshake. */ }
126
- };
127
- socket.addEventListener("message", (event) => {
128
- if (settled) return;
129
- try {
130
- const text = webSocketText(event.data);
131
- if (new TextEncoder().encode(text).byteLength > MAX_TERMINAL_MESSAGE_BYTES) throw new Error("terminal message is too large");
132
- succeed(jsonRpcMessage(JSON.parse(text)));
133
- } catch (error) {
134
- fail(error instanceof Error ? error.message : "invalid terminal message");
135
- try { socket.close(1008, "invalid terminal message"); } catch { /* socket may already be closing */ }
136
- }
137
- });
138
- socket.addEventListener("close", () => fail("internal MCP stream subscription closed before terminal result"));
139
- socket.addEventListener("error", () => fail("internal MCP stream subscription failed"));
140
- });
141
- }
142
-
143
- async function readJsonRpcResponse(response: Response): Promise<JsonRpcMessage> {
144
- return jsonRpcMessage(await response.json());
145
- }
146
-
147
- function jsonRpcMessage(value: unknown): JsonRpcMessage {
148
- if (!value || typeof value !== "object" || Array.isArray(value) || (value as { jsonrpc?: unknown }).jsonrpc !== "2.0") {
149
- throw new Error("internal MCP stream subscription returned an invalid JSON-RPC message");
150
- }
151
- return value as JsonRpcMessage;
152
- }
153
-
154
- function webSocketText(value: unknown): string {
155
- if (typeof value === "string") return value;
156
- if (value instanceof ArrayBuffer) return new TextDecoder("utf-8", { fatal: true }).decode(value);
157
- if (ArrayBuffer.isView(value)) return new TextDecoder("utf-8", { fatal: true }).decode(value);
158
- throw new Error("terminal message must be text or binary UTF-8");
159
- }
160
-
161
99
  async function readDescriptor(response: Response): Promise<StreamDescriptor> {
162
100
  const value: unknown = await response.json();
163
101
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("MCP stream descriptor is invalid");
@@ -0,0 +1,98 @@
1
+ import relayContract from "../shared/relay-contract.json" with { type: "json" };
2
+ import type { JsonRpcMessage } from "./mcp-resumption.ts";
3
+ import { createThrottledEdgeLogger } from "./worker-edge-log.ts";
4
+
5
+ const MAX_TERMINAL_MESSAGE_BYTES = relayContract.maximumResumableMessageBytes + 1024;
6
+ export const DEFAULT_SUBSCRIBE_RETRY_DELAYS_MS = [0, 100, 300] as const;
7
+ type BridgeFetcher = { fetch(request: Request): Promise<Response> };
8
+ const logSubscriptionFailure = createThrottledEdgeLogger();
9
+
10
+ export async function subscribeTerminalMessage(
11
+ bridge: BridgeFetcher,
12
+ createRequest: () => Request,
13
+ retryDelaysMs: readonly number[],
14
+ ): Promise<JsonRpcMessage> {
15
+ const delays = retryDelaysMs.length > 0 ? retryDelaysMs.slice(0, 4) : [0];
16
+ let lastError: unknown = new Error("internal MCP stream subscription failed");
17
+ for (const rawDelay of delays) {
18
+ const delayMs = Math.max(0, Math.min(2_000, Math.floor(Number(rawDelay) || 0)));
19
+ if (delayMs > 0) await delay(delayMs);
20
+ try {
21
+ const response = await bridge.fetch(createRequest());
22
+ if (response.status === 200) return await readJsonRpcResponse(response);
23
+ if (response.status !== 101 || !response.webSocket) {
24
+ const retryable = response.status === 429 || response.status >= 500;
25
+ throw new StreamSubscriptionError(`internal MCP stream subscription failed (${response.status})`, retryable);
26
+ }
27
+ const socket = response.webSocket;
28
+ socket.accept();
29
+ return await terminalMessageFromSocket(socket);
30
+ } catch (error) {
31
+ lastError = error;
32
+ if (error instanceof StreamSubscriptionError && !error.retryable) break;
33
+ }
34
+ }
35
+ logSubscriptionFailure("warn", "mcp.stream.subscription.failed", { attempts: delays.length });
36
+ throw lastError;
37
+ }
38
+
39
+ class StreamSubscriptionError extends Error {
40
+ readonly retryable: boolean;
41
+ constructor(message: string, retryable: boolean) {
42
+ super(message);
43
+ this.name = "StreamSubscriptionError";
44
+ this.retryable = retryable;
45
+ }
46
+ }
47
+
48
+ function terminalMessageFromSocket(socket: WebSocket): Promise<JsonRpcMessage> {
49
+ return new Promise((resolve, reject) => {
50
+ let settled = false;
51
+ const fail = (message: string, retryable: boolean) => {
52
+ if (settled) return;
53
+ settled = true;
54
+ reject(new StreamSubscriptionError(message, retryable));
55
+ };
56
+ const succeed = (message: JsonRpcMessage) => {
57
+ if (settled) return;
58
+ settled = true;
59
+ resolve(message);
60
+ try { socket.close(1000, "terminal received"); } catch { /* Peer may have closed first. */ }
61
+ };
62
+ socket.addEventListener("message", (event) => {
63
+ if (settled) return;
64
+ try {
65
+ const text = webSocketText(event.data);
66
+ if (new TextEncoder().encode(text).byteLength > MAX_TERMINAL_MESSAGE_BYTES) throw new Error("terminal message is too large");
67
+ succeed(jsonRpcMessage(JSON.parse(text)));
68
+ } catch (error) {
69
+ fail(error instanceof Error ? error.message : "invalid terminal message", false);
70
+ try { socket.close(1008, "invalid terminal message"); } catch { /* socket may already be closing */ }
71
+ }
72
+ });
73
+ socket.addEventListener("close", () => fail("internal MCP stream subscription closed before terminal result", true));
74
+ socket.addEventListener("error", () => fail("internal MCP stream subscription failed", true));
75
+ });
76
+ }
77
+
78
+ async function readJsonRpcResponse(response: Response): Promise<JsonRpcMessage> {
79
+ return jsonRpcMessage(await response.json());
80
+ }
81
+
82
+ function jsonRpcMessage(value: unknown): JsonRpcMessage {
83
+ if (!value || typeof value !== "object" || Array.isArray(value) || (value as { jsonrpc?: unknown }).jsonrpc !== "2.0") {
84
+ throw new Error("internal MCP stream subscription returned an invalid JSON-RPC message");
85
+ }
86
+ return value as JsonRpcMessage;
87
+ }
88
+
89
+ function webSocketText(value: unknown): string {
90
+ if (typeof value === "string") return value;
91
+ if (value instanceof ArrayBuffer) return new TextDecoder("utf-8", { fatal: true }).decode(value);
92
+ if (ArrayBuffer.isView(value)) return new TextDecoder("utf-8", { fatal: true }).decode(value);
93
+ throw new Error("terminal message must be text or binary UTF-8");
94
+ }
95
+
96
+ function delay(ms: number): Promise<void> {
97
+ return new Promise((resolve) => setTimeout(resolve, ms));
98
+ }
@@ -1,6 +1,6 @@
1
1
  import { DEFAULT_ACCOUNT_ROLE, normalizeAccountRole, type AccountRole } from "./access.ts";
2
2
  import { accountAdminAuthorized, consumeAccountAdminNonce, handleAccountAdminOperation } from "./account-admin.ts";
3
- import { exchangeOAuthToken } from "./oauth-tokens.ts";
3
+ import { exchangeOAuthToken, type OAuthRefreshEvent } from "./oauth-tokens.ts";
4
4
  import {
5
5
  AUTH_BLOCK_SECONDS, accountByName, authorizationIdentity, emptyOAuthStore,
6
6
  isCurrentOAuthStore, pruneAuthFailures, pruneClientRecordByExpiry, pruneRecordByExpiry, randomToken,
@@ -44,13 +44,21 @@ export class OAuthController {
44
44
  private readonly env: OAuthControllerEnv;
45
45
  private readonly serverName: string;
46
46
  private readonly serverVersion: string;
47
+ private readonly onRefreshEvent?: (event: OAuthRefreshEvent) => void;
47
48
  private oauthQueue: Promise<void> = Promise.resolve();
48
49
 
49
- constructor(ctx: DurableObjectState, env: OAuthControllerEnv, serverName: string, serverVersion: string) {
50
+ constructor(
51
+ ctx: DurableObjectState,
52
+ env: OAuthControllerEnv,
53
+ serverName: string,
54
+ serverVersion: string,
55
+ onRefreshEvent?: (event: OAuthRefreshEvent) => void,
56
+ ) {
50
57
  this.ctx = ctx;
51
58
  this.env = env;
52
59
  this.serverName = serverName;
53
60
  this.serverVersion = serverVersion;
61
+ this.onRefreshEvent = onRefreshEvent;
54
62
  }
55
63
 
56
64
  private async oauthStore(): Promise<OAuthStore> {
@@ -283,6 +291,7 @@ export class OAuthController {
283
291
  serverName: this.serverName,
284
292
  loadOAuthStore: () => this.oauthStore(),
285
293
  withLock: (callback) => this.withOAuthLock(callback),
294
+ onRefreshEvent: this.onRefreshEvent,
286
295
  });
287
296
  }
288
297
 
@@ -0,0 +1,147 @@
1
+ import { consumeDpopProof, type VerifiedDpopProof } from "./dpop.ts";
2
+ import { HttpError, json } from "./http.ts";
3
+ import {
4
+ consumedRefreshRetrySource,
5
+ loadOAuthRefreshStore,
6
+ MAX_REFRESH_RETRY_ISSUES,
7
+ OAUTH_REFRESH_STORE_KEY,
8
+ recordConsumedRefreshRetry,
9
+ recordConsumedRefreshToken,
10
+ revokeOAuthRefreshFamily,
11
+ } from "./oauth-refresh-families.ts";
12
+ import {
13
+ issueTokenPair,
14
+ saveOAuthStores,
15
+ tokenResponse,
16
+ type IssuedTokenPair,
17
+ type OAuthTokenExchangeOptions,
18
+ } from "./oauth-token-issuance.ts";
19
+ import { normalizeOAuthScope, safeEqual, sha256Hex, type OAuthRefreshToken, type OAuthStore } from "./oauth-state.ts";
20
+
21
+ export async function exchangeRefreshToken(
22
+ body: Record<string, unknown>,
23
+ base: string,
24
+ options: OAuthTokenExchangeOptions,
25
+ dpop?: VerifiedDpopProof,
26
+ ): Promise<Response> {
27
+ const refreshToken = String(body.refresh_token ?? "");
28
+ if (!/^mcp_rt_[A-Za-z0-9_-]{43}$/.test(refreshToken)) return reject(options);
29
+ return options.withLock(async () => {
30
+ const now = Math.floor(Date.now() / 1000);
31
+ const oauthStore = await options.loadOAuthStore();
32
+ const refreshStore = await loadOAuthRefreshStore(oauthStore, options.storage);
33
+ const refreshKey = `sha256:${await sha256Hex(refreshToken)}`;
34
+ const record = refreshStore.tokens[refreshKey];
35
+ if (!record) {
36
+ const consumed = refreshStore.consumed[refreshKey];
37
+ if (!consumed || consumed.expires_at <= now) return reject(options);
38
+ const retrySource = consumedRefreshRetrySource(consumed, now);
39
+ if (retrySource) {
40
+ const validation = await validateRefreshGrant(retrySource, body, base, options, dpop, oauthStore, false);
41
+ if (validation.response) return rejected(options, validation.response);
42
+ if (dpop && !(await consumeDpopProof(options.storage, dpop))) return rejectDpop(options);
43
+ const issued = await issueTokenPair(
44
+ oauthStore,
45
+ refreshStore,
46
+ retrySource,
47
+ options.tokenVersion,
48
+ retrySource.dpop_jkt,
49
+ { derivationSeed: refreshToken, issuedAt: consumed.consumed_at },
50
+ );
51
+ recordConsumedRefreshRetry(consumed);
52
+ validation.client.last_used_at = now;
53
+ await saveOAuthStores(oauthStore, refreshStore, options.storage);
54
+ options.onRefreshEvent?.("retry_issued");
55
+ return tokenResponse(issued, retrySource.scope, retrySource.dpop_jkt);
56
+ }
57
+ if (Number.isSafeInteger(consumed.retry_until) && consumed.retry_until! >= now
58
+ && Number(consumed.retry_issues) >= MAX_REFRESH_RETRY_ISSUES) {
59
+ options.onRefreshEvent?.("retry_exhausted");
60
+ return json({
61
+ error: "temporarily_unavailable",
62
+ error_description: "concurrent refresh retry limit reached; retry with the newest token response",
63
+ }, 429, { "retry-after": "1" });
64
+ }
65
+ revokeOAuthRefreshFamily(oauthStore, refreshStore, consumed.family_id, consumed.expires_at);
66
+ await saveOAuthStores(oauthStore, refreshStore, options.storage);
67
+ options.onRefreshEvent?.("family_revoked");
68
+ return json({ error: "invalid_grant" }, 400);
69
+ }
70
+
71
+ const validation = await validateRefreshGrant(record, body, base, options, dpop, oauthStore, true);
72
+ if (validation.response) return rejected(options, validation.response);
73
+ if (dpop && !(await consumeDpopProof(options.storage, dpop))) return rejectDpop(options);
74
+ let issued: IssuedTokenPair;
75
+ try {
76
+ issued = await issueTokenPair(
77
+ oauthStore,
78
+ refreshStore,
79
+ record,
80
+ options.tokenVersion,
81
+ record.dpop_jkt,
82
+ { derivationSeed: refreshToken, issuedAt: now },
83
+ );
84
+ } catch (error) {
85
+ if (!(error instanceof HttpError) || error.code !== "invalid_grant") throw error;
86
+ delete refreshStore.tokens[refreshKey];
87
+ await options.storage.put(OAUTH_REFRESH_STORE_KEY, refreshStore);
88
+ return reject(options);
89
+ }
90
+ delete refreshStore.tokens[refreshKey];
91
+ recordConsumedRefreshToken(oauthStore, refreshStore, refreshKey, record, record.family_expires_at, now);
92
+ validation.client.last_used_at = now;
93
+ await saveOAuthStores(oauthStore, refreshStore, options.storage);
94
+ options.onRefreshEvent?.("rotated");
95
+ return tokenResponse(issued, record.scope, record.dpop_jkt);
96
+ });
97
+ }
98
+
99
+ async function validateRefreshGrant(
100
+ record: OAuthRefreshToken,
101
+ body: Record<string, unknown>,
102
+ base: string,
103
+ options: OAuthTokenExchangeOptions,
104
+ dpop: VerifiedDpopProof | undefined,
105
+ oauthStore: OAuthStore,
106
+ allowDpopBinding: boolean,
107
+ ): Promise<{ client: OAuthStore["clients"][string]; response?: never } | { client?: never; response: Response }> {
108
+ if (record.dpop_jkt && record.dpop_jkt !== dpop?.jkt) return { response: json({ error: "invalid_dpop_proof" }, 400) };
109
+ if (!record.dpop_jkt && dpop?.jkt) {
110
+ if (!allowDpopBinding) return { response: json({ error: "invalid_dpop_proof" }, 400) };
111
+ record.dpop_jkt = dpop.jkt;
112
+ }
113
+ if (String(body.client_id ?? "") !== record.client_id) {
114
+ return { response: json({ error: "invalid_grant", error_description: "client mismatch" }, 400) };
115
+ }
116
+ if (String(body.resource ?? record.resource) !== record.resource || record.resource !== `${base}/mcp`) {
117
+ return { response: json({ error: "invalid_target", error_description: "resource mismatch" }, 400) };
118
+ }
119
+ if (body.scope !== undefined && normalizeOAuthScope(body.scope, options.serverName) !== record.scope) {
120
+ return { response: json({ error: "invalid_scope" }, 400) };
121
+ }
122
+ if (!options.tokenVersion) throw new HttpError(503, "server_error", "OAuth token version is not configured");
123
+ const account = oauthStore.accounts[record.account_id];
124
+ const client = oauthStore.clients[record.client_id];
125
+ if (!record.version || !(await safeEqual(record.version, options.tokenVersion)) || !account || !account.active
126
+ || account.version !== record.account_version || account.role !== record.role || !client
127
+ || client.trusted_account_id !== account.account_id || client.trusted_account_version !== account.version
128
+ || client.trusted_role !== account.role) {
129
+ return { response: json({ error: "invalid_grant" }, 400) };
130
+ }
131
+ return { client };
132
+ }
133
+
134
+ function reject(options: OAuthTokenExchangeOptions): Response {
135
+ options.onRefreshEvent?.("rejected");
136
+ return json({ error: "invalid_grant" }, 400);
137
+ }
138
+
139
+ function rejectDpop(options: OAuthTokenExchangeOptions): Response {
140
+ options.onRefreshEvent?.("rejected");
141
+ return json({ error: "invalid_dpop_proof" }, 400);
142
+ }
143
+
144
+ function rejected(options: OAuthTokenExchangeOptions, response: Response): Response {
145
+ options.onRefreshEvent?.("rejected");
146
+ return response;
147
+ }