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.
- package/CHANGELOG.md +50 -0
- package/README.md +2 -0
- package/SECURITY.md +9 -7
- package/browser-extension/browser-error-boundary.js +45 -0
- package/browser-extension/browser-operations.js +1 -1
- package/browser-extension/manifest.json +1 -1
- package/browser-extension/service-worker.js +9 -8
- package/docs/ARCHITECTURE.md +17 -15
- package/docs/AUDIT.md +30 -0
- package/docs/ENGINEERING.md +3 -3
- package/docs/GETTING_STARTED.md +1 -1
- package/docs/LOCAL_AUTHORIZATION.md +2 -2
- package/docs/LOCAL_AUTOMATION.md +1 -1
- package/docs/LOGGING.md +3 -1
- package/docs/MULTI_ACCOUNT.md +2 -2
- package/docs/OPERATIONS.md +13 -5
- package/docs/OVERVIEW.md +2 -2
- package/docs/PRIVACY.md +1 -1
- package/docs/TESTING.md +17 -13
- package/docs/THREAT_MODEL.md +13 -8
- package/docs/UPGRADING.md +19 -1
- package/package.json +1 -1
- package/scripts/coverage-check.mjs +12 -0
- package/src/local/account-admin.mjs +68 -1
- package/src/local/agent-skill-discovery.mjs +16 -5
- package/src/local/app-automation.mjs +27 -7
- package/src/local/cli-options.mjs +1 -1
- package/src/local/path-inspection.mjs +23 -0
- package/src/local/process-tree-ownership.mjs +17 -5
- package/src/local/process-tree.mjs +6 -2
- package/src/local/relay-connection.mjs +24 -11
- package/src/local/resource-operations.mjs +37 -8
- package/src/local/runtime-activation.mjs +16 -0
- package/src/local/runtime-capabilities.mjs +15 -3
- package/src/local/runtime.mjs +7 -4
- package/src/local/worker-deployment.mjs +16 -6
- package/src/local/workspace-file-service.mjs +29 -11
- package/src/worker/daemon-socket-attachment.ts +52 -0
- package/src/worker/daemon-sockets.ts +17 -48
- package/src/worker/durable-stream-calls.ts +129 -0
- package/src/worker/durable-stream-result.ts +22 -0
- package/src/worker/http.ts +37 -15
- package/src/worker/index.ts +143 -146
- package/src/worker/mcp-pending-call-expiry.ts +20 -0
- package/src/worker/mcp-pending-call-inspection.ts +33 -0
- package/src/worker/mcp-pending-call-records.ts +58 -0
- package/src/worker/mcp-pending-call-storage.ts +18 -0
- package/src/worker/mcp-pending-call-store.ts +285 -0
- package/src/worker/mcp-resumption-index.ts +41 -0
- package/src/worker/mcp-resumption-records.ts +8 -2
- package/src/worker/mcp-resumption.ts +84 -86
- package/src/worker/mcp-stream-dispatch.ts +42 -54
- package/src/worker/mcp-stream-proxy.ts +7 -69
- package/src/worker/mcp-stream-subscription.ts +98 -0
- package/src/worker/oauth-controller.ts +11 -2
- package/src/worker/oauth-refresh-exchange.ts +147 -0
- package/src/worker/oauth-refresh-families.ts +59 -17
- package/src/worker/oauth-state.ts +13 -2
- package/src/worker/oauth-token-derivation.ts +33 -0
- package/src/worker/oauth-token-issuance.ts +106 -0
- package/src/worker/oauth-tokens.ts +12 -184
- package/src/worker/observability.ts +19 -0
- package/src/worker/pending-admission.ts +15 -0
- package/src/worker/pending-call-contract.ts +4 -7
- package/src/worker/pending-calls.ts +4 -10
- package/src/worker/runtime-alarm-storage.ts +35 -0
- package/src/worker/runtime-alarm.ts +30 -20
- package/src/worker/websocket-protocol.ts +4 -0
- package/src/worker/worker-edge-guard.ts +81 -0
- package/src/worker/worker-edge-log.ts +63 -0
- package/src/worker/worker-entry.ts +50 -0
- package/src/worker/worker-metadata.ts +39 -0
- package/src/worker/worker-static-routes.ts +38 -50
- package/wrangler.jsonc +8 -0
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
import type { PendingCallOutcome
|
|
2
|
-
import {
|
|
3
|
-
import
|
|
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
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
88
|
-
effective_account_tool_count: input.
|
|
89
|
-
relay_advertised_scope: "
|
|
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.
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
115
|
-
|
|
113
|
+
transform: input.transform,
|
|
114
|
+
transientActiveCount: input.transientActiveCount,
|
|
115
|
+
maximumPendingCalls: input.maximumPendingCalls,
|
|
116
116
|
});
|
|
117
117
|
} catch (error) {
|
|
118
|
-
if (error instanceof
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
43
|
-
|
|
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(
|
|
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
|
+
}
|