machine-bridge-mcp 0.2.4 → 0.3.3
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 +60 -0
- package/README.md +148 -176
- package/SECURITY.md +84 -0
- package/docs/ARCHITECTURE.md +102 -0
- package/package.json +20 -9
- package/src/local/cli.mjs +289 -298
- package/src/local/daemon.mjs +376 -84
- package/src/local/log.mjs +33 -11
- package/src/local/self-test.mjs +173 -186
- package/src/local/service.mjs +43 -5
- package/src/local/shell.mjs +68 -14
- package/src/local/state.mjs +240 -51
- package/src/worker/index.ts +664 -424
- package/wrangler.jsonc +2 -2
- package/src/local/api-server.mjs +0 -368
package/src/worker/index.ts
CHANGED
|
@@ -1,12 +1,29 @@
|
|
|
1
1
|
import { DurableObject } from "cloudflare:workers";
|
|
2
2
|
|
|
3
3
|
const SERVER_NAME = "machine-bridge-mcp";
|
|
4
|
-
const SERVER_VERSION = "0.
|
|
4
|
+
const SERVER_VERSION = "0.3.3";
|
|
5
5
|
const MCP_PROTOCOL_VERSION = "2025-06-18";
|
|
6
6
|
const JSONRPC_VERSION = "2.0";
|
|
7
|
-
const DEFAULT_MAX_BODY_BYTES =
|
|
8
|
-
const MAX_BODY_BYTES =
|
|
7
|
+
const DEFAULT_MAX_BODY_BYTES = 8 * 1024 * 1024;
|
|
8
|
+
const MAX_BODY_BYTES = 16 * 1024 * 1024;
|
|
9
9
|
const TOKEN_TTL_SECONDS = 60 * 60 * 24 * 30;
|
|
10
|
+
const OAUTH_BODY_LIMIT_BYTES = 64 * 1024;
|
|
11
|
+
const MAX_PENDING_CALLS = 32;
|
|
12
|
+
const MAX_DAEMON_MESSAGE_BYTES = 8 * 1024 * 1024;
|
|
13
|
+
const DAEMON_HELLO_TIMEOUT_MS = 10_000;
|
|
14
|
+
const OAUTH_UNUSED_CLIENT_TTL_SECONDS = 60 * 60;
|
|
15
|
+
const MAX_OAUTH_CLIENTS = 50;
|
|
16
|
+
const MAX_OAUTH_CLIENTS_PER_IDENTITY = 5;
|
|
17
|
+
const OAUTH_CLIENT_IDLE_TTL_SECONDS = 60 * 60 * 24 * 90;
|
|
18
|
+
const MAX_TOKENS_PER_CLIENT = 20;
|
|
19
|
+
const MAX_CODES_PER_CLIENT = 10;
|
|
20
|
+
const MAX_OAUTH_CODES = 200;
|
|
21
|
+
const MAX_OAUTH_TOKENS = 500;
|
|
22
|
+
const MAX_AUTH_FAILURE_IDENTITIES = 200;
|
|
23
|
+
const AUTH_FAILURE_WINDOW_SECONDS = 10 * 60;
|
|
24
|
+
const AUTH_BLOCK_SECONDS = 15 * 60;
|
|
25
|
+
const AUTH_FAILURE_LIMIT = 10;
|
|
26
|
+
const AUTHORIZATION_FIELDS = new Set(["response_type", "client_id", "redirect_uri", "code_challenge", "code_challenge_method", "scope", "resource", "state"]);
|
|
10
27
|
|
|
11
28
|
interface BridgeEnv {
|
|
12
29
|
BRIDGE: DurableObjectNamespace<BridgeRoom>;
|
|
@@ -31,6 +48,8 @@ interface OAuthClient {
|
|
|
31
48
|
client_name: string;
|
|
32
49
|
redirect_uris: string[];
|
|
33
50
|
created_at: number;
|
|
51
|
+
last_used_at: number;
|
|
52
|
+
registration_identity?: string;
|
|
34
53
|
}
|
|
35
54
|
|
|
36
55
|
interface OAuthCode {
|
|
@@ -50,24 +69,33 @@ interface OAuthToken {
|
|
|
50
69
|
expires_at: number;
|
|
51
70
|
}
|
|
52
71
|
|
|
53
|
-
interface
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
72
|
+
interface OAuthFailure {
|
|
73
|
+
count: number;
|
|
74
|
+
window_started: number;
|
|
75
|
+
blocked_until: number;
|
|
76
|
+
last_attempt: number;
|
|
57
77
|
}
|
|
58
78
|
|
|
59
79
|
interface OAuthStore {
|
|
60
80
|
clients: Record<string, OAuthClient>;
|
|
61
81
|
codes: Record<string, OAuthCode>;
|
|
62
82
|
tokens: Record<string, OAuthToken>;
|
|
83
|
+
auth_failures: Record<string, OAuthFailure>;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface ValidatedAuthorization {
|
|
87
|
+
client: OAuthClient;
|
|
88
|
+
clientId: string;
|
|
89
|
+
redirectUri: string;
|
|
90
|
+
codeChallenge: string;
|
|
91
|
+
requestedResource: string;
|
|
92
|
+
scope: string;
|
|
93
|
+
state: string;
|
|
63
94
|
}
|
|
64
95
|
|
|
65
96
|
interface DaemonAttachment {
|
|
66
|
-
role: "daemon";
|
|
97
|
+
role: "candidate" | "expired" | "daemon";
|
|
67
98
|
connectedAt: string;
|
|
68
|
-
daemonId: string;
|
|
69
|
-
workspaceHash?: string;
|
|
70
|
-
workspaceName?: string;
|
|
71
99
|
policy?: Record<string, unknown>;
|
|
72
100
|
tools?: string[];
|
|
73
101
|
}
|
|
@@ -76,22 +104,7 @@ interface PendingCall {
|
|
|
76
104
|
resolve: (value: unknown) => void;
|
|
77
105
|
reject: (error: Error) => void;
|
|
78
106
|
timeout: ReturnType<typeof setTimeout>;
|
|
79
|
-
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
interface McpClientState {
|
|
83
|
-
clientId: string;
|
|
84
|
-
initializedAt: string;
|
|
85
|
-
capabilities: Record<string, unknown>;
|
|
86
|
-
clientInfo?: Record<string, unknown>;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
interface McpClientStream {
|
|
90
|
-
id: string;
|
|
91
|
-
clientId: string;
|
|
92
|
-
connectedAt: number;
|
|
93
|
-
writer: WritableStreamDefaultWriter<Uint8Array>;
|
|
94
|
-
heartbeat: ReturnType<typeof setInterval>;
|
|
107
|
+
socket: WebSocket;
|
|
95
108
|
}
|
|
96
109
|
|
|
97
110
|
const serverInfoTool = {
|
|
@@ -134,7 +147,7 @@ const workspaceTools = [
|
|
|
134
147
|
},
|
|
135
148
|
{
|
|
136
149
|
name: "read_file",
|
|
137
|
-
description: "Read a UTF-8 file. Relative paths use the daemon workspace;
|
|
150
|
+
description: "Read a UTF-8 file. Relative paths use the daemon workspace; paths outside the workspace require the daemon to be started with --unrestricted-paths.",
|
|
138
151
|
inputSchema: {
|
|
139
152
|
type: "object",
|
|
140
153
|
properties: {
|
|
@@ -147,7 +160,7 @@ const workspaceTools = [
|
|
|
147
160
|
},
|
|
148
161
|
{
|
|
149
162
|
name: "write_file",
|
|
150
|
-
description: "
|
|
163
|
+
description: "Atomically write a UTF-8 file up to 5 MiB. Paths outside the workspace require --unrestricted-paths; symbolic-link destinations are rejected. Enabled by default.",
|
|
151
164
|
inputSchema: {
|
|
152
165
|
type: "object",
|
|
153
166
|
properties: {
|
|
@@ -177,8 +190,12 @@ const workspaceTools = [
|
|
|
177
190
|
},
|
|
178
191
|
{
|
|
179
192
|
name: "git_status",
|
|
180
|
-
description: "Run git status --short
|
|
181
|
-
inputSchema: {
|
|
193
|
+
description: "Run git status --short for the repository containing a workspace path.",
|
|
194
|
+
inputSchema: {
|
|
195
|
+
type: "object",
|
|
196
|
+
properties: { path: { type: "string", default: "." } },
|
|
197
|
+
additionalProperties: true,
|
|
198
|
+
},
|
|
182
199
|
},
|
|
183
200
|
{
|
|
184
201
|
name: "git_diff",
|
|
@@ -210,36 +227,43 @@ const workspaceTools = [
|
|
|
210
227
|
const MCP_INSTRUCTIONS = [
|
|
211
228
|
"You are connected to a local workspace through machine-bridge-mcp.",
|
|
212
229
|
"The Worker is only a relay. File and command operations run on the user's local daemon.",
|
|
213
|
-
"Relative paths use the configured workspace as cwd;
|
|
214
|
-
"Writes and shell execution are enabled by default
|
|
230
|
+
"Relative paths use the configured workspace as cwd; filesystem access is workspace-scoped unless the daemon was started with --unrestricted-paths.",
|
|
231
|
+
"Writes and shell execution are enabled by default; use --no-write and --no-exec for read-only sessions.",
|
|
215
232
|
"Prefer inspecting files before editing, make minimal changes, and report commands you ran.",
|
|
216
233
|
].join("\n");
|
|
217
234
|
|
|
218
235
|
export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
219
236
|
private readonly pending = new Map<string, PendingCall>();
|
|
220
|
-
private
|
|
221
|
-
private readonly mcpClients = new Map<string, McpClientState>();
|
|
222
|
-
private readonly mcpClientStreams = new Map<string, McpClientStream>();
|
|
237
|
+
private oauthQueue: Promise<void> = Promise.resolve();
|
|
223
238
|
|
|
224
239
|
constructor(ctx: DurableObjectState, env: BridgeEnv) {
|
|
225
240
|
super(ctx, env);
|
|
226
241
|
}
|
|
227
242
|
|
|
228
243
|
async fetch(request: Request): Promise<Response> {
|
|
229
|
-
const url = new URL(request.url);
|
|
230
244
|
const base = baseUrl(request);
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
245
|
+
const configuredOrigins = this.env.MBM_ALLOWED_ORIGINS ?? "";
|
|
246
|
+
if (!validateOrigin(request, base, configuredOrigins)) return json({ error: "origin_not_allowed" }, 403);
|
|
247
|
+
if (request.method === "OPTIONS" && request.headers.has("Origin")) {
|
|
248
|
+
return corsPreflight(request, base, configuredOrigins);
|
|
249
|
+
}
|
|
250
|
+
const response = await this.handleRequest(request, base);
|
|
251
|
+
return applyCors(response, request, base, configuredOrigins);
|
|
252
|
+
}
|
|
235
253
|
|
|
236
|
-
|
|
237
|
-
|
|
254
|
+
private async handleRequest(request: Request, base: string): Promise<Response> {
|
|
255
|
+
const url = new URL(request.url);
|
|
256
|
+
try {
|
|
257
|
+
if (url.pathname === "/") {
|
|
258
|
+
if (request.method !== "GET") return methodNotAllowed("GET");
|
|
259
|
+
return json({ ok: true, server: SERVER_NAME, version: SERVER_VERSION, mcp: `${base}/mcp` });
|
|
238
260
|
}
|
|
239
261
|
if (url.pathname === "/healthz") {
|
|
240
|
-
|
|
262
|
+
if (request.method !== "GET") return methodNotAllowed("GET");
|
|
263
|
+
return json({ ok: true, server: SERVER_NAME, version: SERVER_VERSION });
|
|
241
264
|
}
|
|
242
265
|
if (url.pathname === "/.well-known/mcp.json") {
|
|
266
|
+
if (request.method !== "GET") return methodNotAllowed("GET");
|
|
243
267
|
return json(this.mcpMetadata(base));
|
|
244
268
|
}
|
|
245
269
|
if (
|
|
@@ -248,19 +272,31 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
248
272
|
url.pathname === "/.well-known/openid-configuration" ||
|
|
249
273
|
url.pathname === "/.well-known/openid-configuration/mcp"
|
|
250
274
|
) {
|
|
275
|
+
if (request.method !== "GET") return methodNotAllowed("GET");
|
|
251
276
|
return json(this.authorizationServerMetadata(base));
|
|
252
277
|
}
|
|
253
278
|
if (url.pathname === "/.well-known/oauth-protected-resource" || url.pathname === "/.well-known/oauth-protected-resource/mcp") {
|
|
279
|
+
if (request.method !== "GET") return methodNotAllowed("GET");
|
|
254
280
|
return json(this.protectedResourceMetadata(base));
|
|
255
281
|
}
|
|
256
|
-
if (url.pathname === "/oauth/register"
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
if (url.pathname === "/
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
282
|
+
if (url.pathname === "/oauth/register") {
|
|
283
|
+
if (request.method !== "POST") return methodNotAllowed("POST");
|
|
284
|
+
return await this.registerClient(request);
|
|
285
|
+
}
|
|
286
|
+
if (url.pathname === "/oauth/authorize") {
|
|
287
|
+
if (request.method === "GET") return await this.authorizeGet(request, base);
|
|
288
|
+
if (request.method === "POST") return await this.authorizeSubmit(request, base);
|
|
289
|
+
return methodNotAllowed("GET, POST");
|
|
290
|
+
}
|
|
291
|
+
if (url.pathname === "/oauth/token") {
|
|
292
|
+
if (request.method !== "POST") return methodNotAllowed("POST");
|
|
293
|
+
return await this.exchangeToken(request, base);
|
|
294
|
+
}
|
|
295
|
+
if (url.pathname === "/daemon/ws") {
|
|
296
|
+
if (request.method !== "GET") return methodNotAllowed("GET");
|
|
297
|
+
return await this.acceptDaemonWebSocket(request);
|
|
298
|
+
}
|
|
299
|
+
if (url.pathname === "/mcp") return await this.handleMcp(request, base);
|
|
264
300
|
return json({ error: "not_found" }, 404);
|
|
265
301
|
} catch (error) {
|
|
266
302
|
if (error instanceof HttpError) return json({ error: error.code, message: error.message }, error.status);
|
|
@@ -270,7 +306,18 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
270
306
|
}
|
|
271
307
|
|
|
272
308
|
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
|
|
273
|
-
const
|
|
309
|
+
const size = typeof message === "string" ? new TextEncoder().encode(message).byteLength : message.byteLength;
|
|
310
|
+
if (size > MAX_DAEMON_MESSAGE_BYTES) {
|
|
311
|
+
try { ws.close(1009, "message too large"); } catch {}
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
let text: string;
|
|
315
|
+
try {
|
|
316
|
+
text = typeof message === "string" ? message : new TextDecoder("utf-8", { fatal: true }).decode(message);
|
|
317
|
+
} catch {
|
|
318
|
+
try { ws.close(1007, "invalid UTF-8"); } catch {}
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
274
321
|
let body: Record<string, unknown>;
|
|
275
322
|
try {
|
|
276
323
|
body = JSON.parse(text) as Record<string, unknown>;
|
|
@@ -279,18 +326,51 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
279
326
|
return;
|
|
280
327
|
}
|
|
281
328
|
|
|
329
|
+
const socketAttachment = this.socketAttachment(ws);
|
|
330
|
+
if (!socketAttachment) {
|
|
331
|
+
try { ws.close(1008, "missing daemon attachment"); } catch {}
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (socketAttachment.role === "expired") {
|
|
336
|
+
try { ws.close(1008, "expired daemon candidate"); } catch {}
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
|
|
282
340
|
if (body.type === "hello") {
|
|
283
|
-
const
|
|
284
|
-
if (
|
|
341
|
+
const previousDaemons = this.daemonSockets().filter((socket) => socket !== ws);
|
|
342
|
+
if (socketAttachment.role === "candidate") {
|
|
343
|
+
if (!isFreshDaemonCandidate(socketAttachment.connectedAt)) {
|
|
344
|
+
try { ws.close(1008, "stale daemon candidate"); } catch {}
|
|
345
|
+
await this.scheduleCandidateAlarm();
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
ws.serializeAttachment({
|
|
350
|
+
role: "daemon",
|
|
351
|
+
connectedAt: new Date().toISOString(),
|
|
352
|
+
policy: sanitizeDaemonPolicy(body.policy),
|
|
353
|
+
tools: sanitizeDaemonTools(body.tools),
|
|
354
|
+
} satisfies DaemonAttachment);
|
|
355
|
+
await this.scheduleCandidateAlarm();
|
|
356
|
+
try {
|
|
357
|
+
ws.send(JSON.stringify({ type: "hello_ack", server: SERVER_NAME, version: SERVER_VERSION }));
|
|
358
|
+
} catch {
|
|
285
359
|
ws.serializeAttachment({
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
360
|
+
role: "expired",
|
|
361
|
+
connectedAt: socketAttachment.connectedAt,
|
|
362
|
+
} satisfies DaemonAttachment);
|
|
363
|
+
try { ws.close(1011, "daemon hello acknowledgement failed"); } catch {}
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
for (const socket of previousDaemons) {
|
|
367
|
+
try { socket.close(1012, "replaced by authenticated daemon"); } catch {}
|
|
292
368
|
}
|
|
293
|
-
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (socketAttachment.role !== "daemon") {
|
|
373
|
+
try { ws.close(1008, "daemon hello required"); } catch {}
|
|
294
374
|
return;
|
|
295
375
|
}
|
|
296
376
|
|
|
@@ -305,7 +385,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
305
385
|
}
|
|
306
386
|
|
|
307
387
|
const pending = this.pending.get(body.id);
|
|
308
|
-
if (!pending) return;
|
|
388
|
+
if (!pending || pending.socket !== ws) return;
|
|
309
389
|
clearTimeout(pending.timeout);
|
|
310
390
|
this.pending.delete(body.id);
|
|
311
391
|
if (body.ok === false) pending.reject(new Error(errorMessage(body.error)));
|
|
@@ -313,9 +393,11 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
313
393
|
}
|
|
314
394
|
|
|
315
395
|
async webSocketClose(ws: WebSocket): Promise<void> {
|
|
396
|
+
await this.scheduleCandidateAlarm();
|
|
316
397
|
const attachment = this.daemonAttachment(ws);
|
|
317
|
-
if (attachment
|
|
398
|
+
if (!attachment) return;
|
|
318
399
|
for (const [id, pending] of this.pending) {
|
|
400
|
+
if (pending.socket !== ws) continue;
|
|
319
401
|
clearTimeout(pending.timeout);
|
|
320
402
|
pending.reject(new Error("daemon disconnected"));
|
|
321
403
|
this.pending.delete(id);
|
|
@@ -323,33 +405,36 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
323
405
|
}
|
|
324
406
|
|
|
325
407
|
private async handleMcp(request: Request, base: string): Promise<Response> {
|
|
326
|
-
if (request.method
|
|
327
|
-
|
|
408
|
+
if (request.method !== "POST") {
|
|
409
|
+
return new Response(request.method === "HEAD" ? null : JSON.stringify({ error: "mcp endpoint expects POST JSON-RPC" }), {
|
|
410
|
+
status: 405,
|
|
411
|
+
headers: { "content-type": "application/json; charset=utf-8", "allow": "POST", "cache-control": "no-store" },
|
|
412
|
+
});
|
|
413
|
+
}
|
|
328
414
|
|
|
329
|
-
const
|
|
330
|
-
if (!
|
|
415
|
+
const authorized = await this.verifyAccessToken(bearerToken(request), base);
|
|
416
|
+
if (!authorized) {
|
|
331
417
|
return new Response("OAuth bearer token required", {
|
|
332
418
|
status: 401,
|
|
333
|
-
headers: {
|
|
419
|
+
headers: {
|
|
420
|
+
"WWW-Authenticate": `Bearer resource_metadata="${base}/.well-known/oauth-protected-resource/mcp"`,
|
|
421
|
+
"cache-control": "no-store",
|
|
422
|
+
"content-type": "text/plain; charset=utf-8",
|
|
423
|
+
"x-content-type-options": "nosniff",
|
|
424
|
+
},
|
|
334
425
|
});
|
|
335
426
|
}
|
|
336
427
|
|
|
337
|
-
if (request.method === "GET") return this.openMcpSseStream(request, auth);
|
|
338
|
-
|
|
339
428
|
const body = await parseJsonRequest(request, this.bodyLimitBytes());
|
|
340
|
-
if (isJsonRpcResponse(body)) {
|
|
341
|
-
this.handleClientJsonRpcResponse(body);
|
|
342
|
-
return new Response(null, { status: 202 });
|
|
343
|
-
}
|
|
429
|
+
if (isJsonRpcResponse(body)) return new Response(null, { status: 202 });
|
|
344
430
|
if (!isJsonRpcRequest(body)) return json(rpcError(null, -32600, "Invalid JSON-RPC request"), 400);
|
|
345
|
-
const response = await this.dispatchJsonRpc(body, base
|
|
431
|
+
const response = await this.dispatchJsonRpc(body, base);
|
|
346
432
|
if (response === null) return new Response(null, { status: 202 });
|
|
347
433
|
return json(response);
|
|
348
434
|
}
|
|
349
435
|
|
|
350
|
-
private async dispatchJsonRpc(request: JsonRpcRequest, base: string
|
|
436
|
+
private async dispatchJsonRpc(request: JsonRpcRequest, base: string): Promise<Record<string, unknown> | null> {
|
|
351
437
|
if (request.method === "initialize") {
|
|
352
|
-
this.recordClientInitialize(auth, request.params);
|
|
353
438
|
return rpcResult(request.id, {
|
|
354
439
|
protocolVersion: MCP_PROTOCOL_VERSION,
|
|
355
440
|
capabilities: { tools: { listChanged: false } },
|
|
@@ -382,7 +467,6 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
382
467
|
mcp_url: `${base}/mcp`,
|
|
383
468
|
oauth: this.authorizationServerMetadata(base),
|
|
384
469
|
daemon: this.daemonStatus(true),
|
|
385
|
-
mcp_clients: this.mcpClientStatus(true),
|
|
386
470
|
tools: this.allTools().map((tool) => tool.name),
|
|
387
471
|
};
|
|
388
472
|
}
|
|
@@ -396,6 +480,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
396
480
|
private async callDaemonTool(name: string, args: Record<string, unknown>): Promise<unknown> {
|
|
397
481
|
const socket = this.daemonSockets()[0];
|
|
398
482
|
if (!socket) throw new Error("local daemon is not connected; keep the CLI start command running");
|
|
483
|
+
if (this.pending.size >= MAX_PENDING_CALLS) throw new Error("too many concurrent daemon tool calls");
|
|
399
484
|
const id = randomToken("call");
|
|
400
485
|
const timeoutMs = daemonToolTimeoutMs(name, args);
|
|
401
486
|
return await new Promise((resolve, reject) => {
|
|
@@ -403,8 +488,14 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
403
488
|
this.pending.delete(id);
|
|
404
489
|
reject(new Error(`daemon tool timed out: ${name}`));
|
|
405
490
|
}, timeoutMs);
|
|
406
|
-
this.pending.set(id, { resolve, reject, timeout });
|
|
407
|
-
|
|
491
|
+
this.pending.set(id, { resolve, reject, timeout, socket });
|
|
492
|
+
try {
|
|
493
|
+
socket.send(JSON.stringify({ type: "tool_call", id, tool: name, arguments: args, timeout_ms: timeoutMs }));
|
|
494
|
+
} catch (error) {
|
|
495
|
+
clearTimeout(timeout);
|
|
496
|
+
this.pending.delete(id);
|
|
497
|
+
reject(new Error(`failed to send daemon tool call: ${errorMessage(error)}`));
|
|
498
|
+
}
|
|
408
499
|
});
|
|
409
500
|
}
|
|
410
501
|
|
|
@@ -414,192 +505,22 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
414
505
|
const supplied = request.headers.get("X-Bridge-Token") ?? "";
|
|
415
506
|
if (!expected || !(await safeEqual(supplied, expected))) return new Response("Unauthorized daemon", { status: 401 });
|
|
416
507
|
|
|
417
|
-
for (const socket of this.
|
|
418
|
-
try {
|
|
419
|
-
socket.close(1012, "replaced by newer daemon");
|
|
420
|
-
} catch {
|
|
421
|
-
// Ignore stale sockets.
|
|
422
|
-
}
|
|
508
|
+
for (const socket of this.nonDaemonSockets()) {
|
|
509
|
+
try { socket.close(1012, "replaced by newer daemon candidate"); } catch {}
|
|
423
510
|
}
|
|
424
511
|
|
|
425
512
|
const pair = new WebSocketPair();
|
|
426
513
|
const [client, server] = Object.values(pair) as [WebSocket, WebSocket];
|
|
427
514
|
this.ctx.acceptWebSocket(server);
|
|
428
515
|
server.serializeAttachment({
|
|
429
|
-
role: "
|
|
516
|
+
role: "candidate",
|
|
430
517
|
connectedAt: new Date().toISOString(),
|
|
431
|
-
daemonId: request.headers.get("X-Daemon-Id") || randomToken("daemon"),
|
|
432
518
|
} satisfies DaemonAttachment);
|
|
519
|
+
await this.ctx.storage.setAlarm(Date.now() + DAEMON_HELLO_TIMEOUT_MS);
|
|
433
520
|
server.send(JSON.stringify({ type: "welcome", server: SERVER_NAME, version: SERVER_VERSION }));
|
|
434
521
|
return new Response(null, { status: 101, webSocket: client });
|
|
435
522
|
}
|
|
436
523
|
|
|
437
|
-
private async handleSamplingApi(request: Request): Promise<Response> {
|
|
438
|
-
if (request.method !== "POST") return json({ error: "method_not_allowed", message: "POST required" }, 405);
|
|
439
|
-
const expected = this.env.DAEMON_SHARED_SECRET ?? "";
|
|
440
|
-
const supplied = request.headers.get("X-Bridge-Token") ?? "";
|
|
441
|
-
if (!expected || !(await safeEqual(supplied, expected))) return json({ error: "unauthorized", message: "Unauthorized local API bridge request" }, 401);
|
|
442
|
-
|
|
443
|
-
const body = await parseRequestBody(request, this.bodyLimitBytes());
|
|
444
|
-
const timeoutMs = clampNumber(body.timeout_ms ?? body.timeoutMs, 180_000, 1_000, 600_000);
|
|
445
|
-
const params = samplingParamsFromApiBody(body);
|
|
446
|
-
const result = await this.requestClientSampling(params, timeoutMs);
|
|
447
|
-
return json({ ok: true, result });
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
private openMcpSseStream(request: Request, auth: AuthenticatedClient): Response {
|
|
451
|
-
const { readable, writable } = new TransformStream<Uint8Array, Uint8Array>();
|
|
452
|
-
const writer = writable.getWriter();
|
|
453
|
-
const streamId = randomToken("mcp_stream");
|
|
454
|
-
const stream: McpClientStream = {
|
|
455
|
-
id: streamId,
|
|
456
|
-
clientId: auth.clientId,
|
|
457
|
-
connectedAt: Date.now(),
|
|
458
|
-
writer,
|
|
459
|
-
heartbeat: setInterval(() => {
|
|
460
|
-
void writeSseComment(writer, `keepalive ${Date.now()}`).catch(() => this.closeMcpClientStream(streamId));
|
|
461
|
-
}, 25_000),
|
|
462
|
-
};
|
|
463
|
-
this.mcpClientStreams.set(streamId, stream);
|
|
464
|
-
request.signal.addEventListener("abort", () => this.closeMcpClientStream(streamId), { once: true });
|
|
465
|
-
void writeSseComment(writer, `${SERVER_NAME} connected`).catch(() => this.closeMcpClientStream(streamId));
|
|
466
|
-
return new Response(readable, {
|
|
467
|
-
status: 200,
|
|
468
|
-
headers: {
|
|
469
|
-
"content-type": "text/event-stream; charset=utf-8",
|
|
470
|
-
"cache-control": "no-cache, no-transform",
|
|
471
|
-
},
|
|
472
|
-
});
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
private closeMcpClientStream(streamId: string): void {
|
|
476
|
-
const stream = this.mcpClientStreams.get(streamId);
|
|
477
|
-
if (!stream) return;
|
|
478
|
-
this.mcpClientStreams.delete(streamId);
|
|
479
|
-
clearInterval(stream.heartbeat);
|
|
480
|
-
for (const [id, pending] of this.pendingClientRequests) {
|
|
481
|
-
if (pending.streamId !== streamId) continue;
|
|
482
|
-
clearTimeout(pending.timeout);
|
|
483
|
-
this.pendingClientRequests.delete(id);
|
|
484
|
-
pending.reject(new HttpError(
|
|
485
|
-
409,
|
|
486
|
-
"mcp_client_stream_closed",
|
|
487
|
-
"The MCP client server-to-client stream closed before it answered sampling/createMessage. Reconnect ChatGPT to the MCP Server URL and retry."
|
|
488
|
-
));
|
|
489
|
-
}
|
|
490
|
-
try {
|
|
491
|
-
void stream.writer.close();
|
|
492
|
-
} catch {
|
|
493
|
-
// Ignore already-closed streams.
|
|
494
|
-
}
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
private async requestClientSampling(params: Record<string, unknown>, timeoutMs: number): Promise<unknown> {
|
|
498
|
-
const streams = [...this.mcpClientStreams.values()].sort((left, right) => right.connectedAt - left.connectedAt);
|
|
499
|
-
if (!streams.length) {
|
|
500
|
-
throw new HttpError(
|
|
501
|
-
409,
|
|
502
|
-
"mcp_client_stream_missing",
|
|
503
|
-
"No MCP client has an open server-to-client stream. Connect ChatGPT to the printed MCP Server URL and keep a client stream open so this bridge can send sampling/createMessage requests."
|
|
504
|
-
);
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
const capableStreams = streams.filter((stream) => this.clientSupportsSampling(stream.clientId));
|
|
508
|
-
if (!capableStreams.length) {
|
|
509
|
-
throw new HttpError(
|
|
510
|
-
501,
|
|
511
|
-
"mcp_sampling_not_supported",
|
|
512
|
-
"A ChatGPT MCP client stream is connected, but the client did not advertise the MCP sampling capability. This local /v1 API requires a client that can receive sampling/createMessage requests."
|
|
513
|
-
);
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
const request: JsonRpcRequest = {
|
|
517
|
-
jsonrpc: JSONRPC_VERSION,
|
|
518
|
-
id: randomToken("sampling"),
|
|
519
|
-
method: "sampling/createMessage",
|
|
520
|
-
params,
|
|
521
|
-
};
|
|
522
|
-
return this.sendClientRequest(capableStreams[0], request, timeoutMs);
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
private async sendClientRequest(stream: McpClientStream, request: JsonRpcRequest, timeoutMs: number): Promise<unknown> {
|
|
526
|
-
const id = String(request.id);
|
|
527
|
-
return await new Promise((resolve, reject) => {
|
|
528
|
-
const timeout = setTimeout(() => {
|
|
529
|
-
this.pendingClientRequests.delete(id);
|
|
530
|
-
reject(new HttpError(
|
|
531
|
-
504,
|
|
532
|
-
"mcp_sampling_timeout",
|
|
533
|
-
"Timed out waiting for the MCP client to answer sampling/createMessage. Check that ChatGPT is still connected and that the sampling request was approved."
|
|
534
|
-
));
|
|
535
|
-
}, timeoutMs);
|
|
536
|
-
this.pendingClientRequests.set(id, { resolve, reject, timeout, streamId: stream.id });
|
|
537
|
-
void writeSseJson(stream.writer, request, id).catch((error) => {
|
|
538
|
-
clearTimeout(timeout);
|
|
539
|
-
this.pendingClientRequests.delete(id);
|
|
540
|
-
this.closeMcpClientStream(stream.id);
|
|
541
|
-
reject(new HttpError(409, "mcp_client_stream_unavailable", `MCP client stream is not writable: ${errorMessage(error)}`));
|
|
542
|
-
});
|
|
543
|
-
});
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
private handleClientJsonRpcResponse(response: unknown): void {
|
|
547
|
-
const candidate = response as Record<string, unknown>;
|
|
548
|
-
const id = String(candidate.id ?? "");
|
|
549
|
-
if (!id) return;
|
|
550
|
-
const pending = this.pendingClientRequests.get(id);
|
|
551
|
-
if (!pending) return;
|
|
552
|
-
clearTimeout(pending.timeout);
|
|
553
|
-
this.pendingClientRequests.delete(id);
|
|
554
|
-
if ("error" in candidate) {
|
|
555
|
-
pending.reject(new HttpError(
|
|
556
|
-
502,
|
|
557
|
-
"mcp_sampling_client_error",
|
|
558
|
-
`MCP client returned an error for sampling/createMessage: ${jsonRpcErrorMessage(candidate.error)}`
|
|
559
|
-
));
|
|
560
|
-
}
|
|
561
|
-
else pending.resolve(candidate.result);
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
private recordClientInitialize(auth: AuthenticatedClient, params: unknown): void {
|
|
565
|
-
const body = asObject(params);
|
|
566
|
-
const meta = asObject(body._meta);
|
|
567
|
-
const directCapabilities = asObject(body.capabilities);
|
|
568
|
-
const metaCapabilities = asObject(meta["io.modelcontextprotocol/clientCapabilities"]);
|
|
569
|
-
const capabilities = Object.keys(directCapabilities).length ? directCapabilities : metaCapabilities;
|
|
570
|
-
this.mcpClients.set(auth.clientId, {
|
|
571
|
-
clientId: auth.clientId,
|
|
572
|
-
initializedAt: new Date().toISOString(),
|
|
573
|
-
capabilities,
|
|
574
|
-
clientInfo: asObject(body.clientInfo),
|
|
575
|
-
});
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
private clientSupportsSampling(clientId: string): boolean {
|
|
579
|
-
const capabilities = this.mcpClients.get(clientId)?.capabilities;
|
|
580
|
-
return Boolean(capabilities && Object.prototype.hasOwnProperty.call(capabilities, "sampling"));
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
private mcpClientStatus(detail: boolean): Record<string, unknown> {
|
|
584
|
-
const streams = [...this.mcpClientStreams.values()];
|
|
585
|
-
const samplingCapableClientIds = new Set([...this.mcpClients.values()].filter((client) => Object.prototype.hasOwnProperty.call(client.capabilities, "sampling")).map((client) => client.clientId));
|
|
586
|
-
const base = {
|
|
587
|
-
stream_count: streams.length,
|
|
588
|
-
initialized_count: this.mcpClients.size,
|
|
589
|
-
sampling_capable_count: samplingCapableClientIds.size,
|
|
590
|
-
};
|
|
591
|
-
if (!detail) return base;
|
|
592
|
-
return {
|
|
593
|
-
...base,
|
|
594
|
-
streams: streams.map((stream) => ({
|
|
595
|
-
id: stream.id,
|
|
596
|
-
client_id: stream.clientId,
|
|
597
|
-
connected_at: new Date(stream.connectedAt).toISOString(),
|
|
598
|
-
sampling_capable: samplingCapableClientIds.has(stream.clientId),
|
|
599
|
-
})),
|
|
600
|
-
};
|
|
601
|
-
}
|
|
602
|
-
|
|
603
524
|
private allTools(): Array<Record<string, unknown>> {
|
|
604
525
|
const advertised = this.daemonAdvertisedTools();
|
|
605
526
|
const localTools = advertised
|
|
@@ -615,23 +536,86 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
615
536
|
|
|
616
537
|
private daemonAdvertisedTools(): Set<string> | null {
|
|
617
538
|
const socket = this.daemonSockets()[0];
|
|
618
|
-
|
|
619
|
-
|
|
539
|
+
if (!socket) return null;
|
|
540
|
+
const attachment = this.daemonAttachment(socket);
|
|
541
|
+
if (!attachment?.tools) return new Set();
|
|
620
542
|
return new Set(attachment.tools);
|
|
621
543
|
}
|
|
622
544
|
|
|
623
545
|
private daemonSockets(): WebSocket[] {
|
|
546
|
+
return this.ctx.getWebSockets()
|
|
547
|
+
.filter((socket) => this.daemonAttachment(socket) && socket.readyState === WebSocket.OPEN)
|
|
548
|
+
.sort((left, right) => {
|
|
549
|
+
const leftTime = Date.parse(this.daemonAttachment(left)?.connectedAt ?? "") || 0;
|
|
550
|
+
const rightTime = Date.parse(this.daemonAttachment(right)?.connectedAt ?? "") || 0;
|
|
551
|
+
return rightTime - leftTime;
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
private candidateSockets(): WebSocket[] {
|
|
556
|
+
return this.ctx.getWebSockets().filter((socket) => this.socketAttachment(socket)?.role === "candidate" && socket.readyState === WebSocket.OPEN);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
private nonDaemonSockets(): WebSocket[] {
|
|
624
560
|
return this.ctx.getWebSockets().filter((socket) => {
|
|
625
|
-
const
|
|
626
|
-
return
|
|
561
|
+
const role = this.socketAttachment(socket)?.role;
|
|
562
|
+
return role !== "daemon" && socket.readyState === WebSocket.OPEN;
|
|
627
563
|
});
|
|
628
564
|
}
|
|
629
565
|
|
|
630
|
-
private
|
|
566
|
+
private socketAttachment(socket: WebSocket): DaemonAttachment | undefined {
|
|
631
567
|
const raw = socket.deserializeAttachment();
|
|
632
568
|
if (!raw || typeof raw !== "object") return undefined;
|
|
633
569
|
const candidate = raw as Partial<DaemonAttachment>;
|
|
634
|
-
|
|
570
|
+
if (candidate.role !== "candidate" && candidate.role !== "expired" && candidate.role !== "daemon") return undefined;
|
|
571
|
+
return {
|
|
572
|
+
role: candidate.role,
|
|
573
|
+
connectedAt: sanitizeMetadataText(candidate.connectedAt, 64) ?? "",
|
|
574
|
+
policy: sanitizeDaemonPolicy(candidate.policy),
|
|
575
|
+
tools: sanitizeDaemonTools(candidate.tools),
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
private daemonAttachment(socket: WebSocket): DaemonAttachment | undefined {
|
|
580
|
+
const attachment = this.socketAttachment(socket);
|
|
581
|
+
return attachment?.role === "daemon" ? attachment : undefined;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
async alarm(): Promise<void> {
|
|
585
|
+
const now = Date.now();
|
|
586
|
+
let nextDeadline = Number.POSITIVE_INFINITY;
|
|
587
|
+
for (const socket of this.candidateSockets()) {
|
|
588
|
+
const attachment = this.socketAttachment(socket);
|
|
589
|
+
const connectedAt = Date.parse(attachment?.connectedAt ?? "");
|
|
590
|
+
const deadline = connectedAt + DAEMON_HELLO_TIMEOUT_MS;
|
|
591
|
+
if (!Number.isFinite(connectedAt) || deadline <= now) {
|
|
592
|
+
socket.serializeAttachment({
|
|
593
|
+
role: "expired",
|
|
594
|
+
connectedAt: attachment?.connectedAt ?? new Date(0).toISOString(),
|
|
595
|
+
} satisfies DaemonAttachment);
|
|
596
|
+
try { socket.send(JSON.stringify({ type: "error", error: "daemon_hello_timeout" })); } catch {}
|
|
597
|
+
try { socket.close(1008, "daemon hello timeout"); } catch {}
|
|
598
|
+
continue;
|
|
599
|
+
}
|
|
600
|
+
nextDeadline = Math.min(nextDeadline, deadline);
|
|
601
|
+
}
|
|
602
|
+
if (Number.isFinite(nextDeadline)) await this.ctx.storage.setAlarm(nextDeadline);
|
|
603
|
+
else await this.ctx.storage.deleteAlarm();
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
private async scheduleCandidateAlarm(): Promise<void> {
|
|
607
|
+
let nextDeadline = Number.POSITIVE_INFINITY;
|
|
608
|
+
for (const socket of this.candidateSockets()) {
|
|
609
|
+
const attachment = this.socketAttachment(socket);
|
|
610
|
+
const connectedAt = Date.parse(attachment?.connectedAt ?? "");
|
|
611
|
+
if (!Number.isFinite(connectedAt)) {
|
|
612
|
+
try { socket.close(1008, "invalid daemon candidate timestamp"); } catch {}
|
|
613
|
+
continue;
|
|
614
|
+
}
|
|
615
|
+
nextDeadline = Math.min(nextDeadline, connectedAt + DAEMON_HELLO_TIMEOUT_MS);
|
|
616
|
+
}
|
|
617
|
+
if (Number.isFinite(nextDeadline)) await this.ctx.storage.setAlarm(Math.max(Date.now(), nextDeadline));
|
|
618
|
+
else await this.ctx.storage.deleteAlarm();
|
|
635
619
|
}
|
|
636
620
|
|
|
637
621
|
private daemonStatus(detail: boolean): Record<string, unknown> {
|
|
@@ -647,8 +631,6 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
647
631
|
if (!detail) return base;
|
|
648
632
|
return {
|
|
649
633
|
...base,
|
|
650
|
-
workspace_hash: attachment?.workspaceHash ?? null,
|
|
651
|
-
workspace_name: attachment?.workspaceName ?? null,
|
|
652
634
|
policy: attachment?.policy ?? null,
|
|
653
635
|
tools,
|
|
654
636
|
};
|
|
@@ -661,7 +643,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
661
643
|
protocolVersion: MCP_PROTOCOL_VERSION,
|
|
662
644
|
transport: { type: "streamable-http", url: `${base}/mcp` },
|
|
663
645
|
auth: { type: "oauth" },
|
|
664
|
-
tools:
|
|
646
|
+
tools: [serverInfoTool, ...workspaceTools].map((tool) => tool.name),
|
|
665
647
|
instructions: MCP_INSTRUCTIONS,
|
|
666
648
|
};
|
|
667
649
|
}
|
|
@@ -691,7 +673,11 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
691
673
|
}
|
|
692
674
|
|
|
693
675
|
private async oauthStore(): Promise<OAuthStore> {
|
|
694
|
-
const store = (await this.ctx.storage.get<OAuthStore>("oauth")) ?? { clients: {}, codes: {}, tokens: {} };
|
|
676
|
+
const store = (await this.ctx.storage.get<OAuthStore>("oauth")) ?? { clients: {}, codes: {}, tokens: {}, auth_failures: {} };
|
|
677
|
+
store.clients ||= {};
|
|
678
|
+
store.codes ||= {};
|
|
679
|
+
store.tokens ||= {};
|
|
680
|
+
store.auth_failures ||= {};
|
|
695
681
|
const now = Math.floor(Date.now() / 1000);
|
|
696
682
|
let changed = false;
|
|
697
683
|
for (const [code, value] of Object.entries(store.codes)) {
|
|
@@ -706,166 +692,259 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
706
692
|
changed = true;
|
|
707
693
|
}
|
|
708
694
|
}
|
|
695
|
+
for (const [identity, value] of Object.entries(store.auth_failures)) {
|
|
696
|
+
if (!identity.startsWith("hmac-sha256:") || value.last_attempt + AUTH_BLOCK_SECONDS <= now) {
|
|
697
|
+
delete store.auth_failures[identity];
|
|
698
|
+
changed = true;
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
const activeClientIds = new Set([
|
|
702
|
+
...Object.values(store.codes).map((value) => value.client_id),
|
|
703
|
+
...Object.values(store.tokens).map((value) => value.client_id),
|
|
704
|
+
]);
|
|
705
|
+
for (const [clientId, client] of Object.entries(store.clients)) {
|
|
706
|
+
if (client.registration_identity && !client.registration_identity.startsWith("hmac-sha256:")) {
|
|
707
|
+
delete client.registration_identity;
|
|
708
|
+
changed = true;
|
|
709
|
+
}
|
|
710
|
+
if (!client.last_used_at) {
|
|
711
|
+
client.last_used_at = client.created_at;
|
|
712
|
+
changed = true;
|
|
713
|
+
}
|
|
714
|
+
const ttl = client.last_used_at === client.created_at ? OAUTH_UNUSED_CLIENT_TTL_SECONDS : OAUTH_CLIENT_IDLE_TTL_SECONDS;
|
|
715
|
+
if (!activeClientIds.has(clientId) && client.last_used_at + ttl <= now) {
|
|
716
|
+
delete store.clients[clientId];
|
|
717
|
+
changed = true;
|
|
718
|
+
}
|
|
719
|
+
}
|
|
709
720
|
if (changed) await this.ctx.storage.put("oauth", store);
|
|
710
721
|
return store;
|
|
711
722
|
}
|
|
712
723
|
|
|
713
724
|
private async registerClient(request: Request): Promise<Response> {
|
|
714
|
-
const body = await parseRequestBody(request,
|
|
725
|
+
const body = await parseRequestBody(request, OAUTH_BODY_LIMIT_BYTES);
|
|
715
726
|
const redirectUris = body.redirect_uris;
|
|
716
727
|
if (!Array.isArray(redirectUris) || redirectUris.length === 0) {
|
|
717
728
|
return json({ error: "invalid_client_metadata", error_description: "redirect_uris must be a non-empty array" }, 400);
|
|
718
729
|
}
|
|
719
|
-
if (redirectUris.length >
|
|
720
|
-
return json({ error: "invalid_client_metadata", error_description: "redirect_uris must contain at most
|
|
730
|
+
if (redirectUris.length > 5) {
|
|
731
|
+
return json({ error: "invalid_client_metadata", error_description: "redirect_uris must contain at most 5 entries" }, 400);
|
|
721
732
|
}
|
|
722
|
-
const normalized = redirectUris.map((item) => String(item));
|
|
723
|
-
if (normalized.some((item) => item.length >
|
|
733
|
+
const normalized = [...new Set(redirectUris.map((item) => String(item)))];
|
|
734
|
+
if (normalized.some((item) => item.length > 1024)) {
|
|
724
735
|
return json({ error: "invalid_client_metadata", error_description: "redirect_uri is too long" }, 400);
|
|
725
736
|
}
|
|
726
737
|
if (!normalized.every(isAllowedRedirectUri)) {
|
|
727
738
|
return json({ error: "invalid_client_metadata", error_description: "redirect_uris must be https or local http" }, 400);
|
|
728
739
|
}
|
|
729
740
|
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
741
|
+
return this.withOAuthLock(async () => {
|
|
742
|
+
const store = await this.oauthStore();
|
|
743
|
+
const registrationIdentity = await authorizationIdentity(request, this.identityKey());
|
|
744
|
+
const identityClientCount = Object.values(store.clients).filter((client) => client.registration_identity === registrationIdentity).length;
|
|
745
|
+
if (identityClientCount >= MAX_OAUTH_CLIENTS_PER_IDENTITY) {
|
|
746
|
+
return json({ error: "too_many_requests", error_description: "client registration limit reached for this source" }, 429);
|
|
747
|
+
}
|
|
748
|
+
if (Object.keys(store.clients).length >= MAX_OAUTH_CLIENTS) {
|
|
749
|
+
return json({ error: "temporarily_unavailable", error_description: "client registry is full; remove stale state or retry after inactive clients expire" }, 503);
|
|
750
|
+
}
|
|
751
|
+
const now = Math.floor(Date.now() / 1000);
|
|
752
|
+
const client: OAuthClient = {
|
|
753
|
+
client_id: randomToken("mcp_client"),
|
|
754
|
+
client_name: normalizeDisplayText(stringOrUndefined(body.client_name) ?? "MCP Client", 128),
|
|
755
|
+
redirect_uris: normalized,
|
|
756
|
+
created_at: now,
|
|
757
|
+
last_used_at: now,
|
|
758
|
+
registration_identity: registrationIdentity,
|
|
759
|
+
};
|
|
760
|
+
store.clients[client.client_id] = client;
|
|
761
|
+
await this.ctx.storage.put("oauth", store);
|
|
762
|
+
return json({
|
|
763
|
+
client_id: client.client_id,
|
|
764
|
+
client_name: client.client_name,
|
|
765
|
+
redirect_uris: client.redirect_uris,
|
|
766
|
+
grant_types: ["authorization_code"],
|
|
767
|
+
response_types: ["code"],
|
|
768
|
+
token_endpoint_auth_method: "none",
|
|
769
|
+
client_id_issued_at: client.created_at,
|
|
770
|
+
});
|
|
748
771
|
});
|
|
749
772
|
}
|
|
750
773
|
|
|
751
|
-
private
|
|
774
|
+
private async authorizeGet(request: Request, base: string): Promise<Response> {
|
|
775
|
+
const body = searchParamsObject(new URL(request.url).searchParams);
|
|
776
|
+
return this.withOAuthLock(async () => {
|
|
777
|
+
const store = await this.oauthStore();
|
|
778
|
+
const validation = validateAuthorizationRequest(body, base, store);
|
|
779
|
+
if ("error" in validation) {
|
|
780
|
+
return this.authorizePage(request, base, validation.error, body, validation.status, undefined, false);
|
|
781
|
+
}
|
|
782
|
+
return this.authorizePage(request, base, "", body, 200, validation.value, true);
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
private authorizePage(
|
|
787
|
+
request: Request,
|
|
788
|
+
base: string,
|
|
789
|
+
error = "",
|
|
790
|
+
submitted?: Record<string, unknown>,
|
|
791
|
+
status = 200,
|
|
792
|
+
authorization?: ValidatedAuthorization,
|
|
793
|
+
allowSubmit = true,
|
|
794
|
+
): Response {
|
|
752
795
|
const url = new URL(request.url);
|
|
753
796
|
const sourceEntries = submitted ? Object.entries(submitted) : searchParamsEntries(url.searchParams);
|
|
754
797
|
const hidden = sourceEntries
|
|
755
|
-
.filter(([key]) => key
|
|
798
|
+
.filter(([key]) => AUTHORIZATION_FIELDS.has(key))
|
|
756
799
|
.map(([key, value]) => `<input type="hidden" name="${escapeHtml(key)}" value="${escapeHtml(String(value))}">`)
|
|
757
800
|
.join("\n");
|
|
758
|
-
const resource = String(submitted?.resource ?? url.searchParams.get("resource") ?? `${base}/mcp`);
|
|
759
|
-
const
|
|
801
|
+
const resource = authorization?.requestedResource ?? String(submitted?.resource ?? url.searchParams.get("resource") ?? `${base}/mcp`);
|
|
802
|
+
const clientBlock = authorization
|
|
803
|
+
? `<p><strong>Client:</strong> ${escapeHtml(authorization.client.client_name)}</p>
|
|
804
|
+
<p><strong>Redirect URI:</strong> <code>${escapeHtml(authorization.redirectUri)}</code></p>`
|
|
805
|
+
: "";
|
|
806
|
+
const errorBlock = error ? `<p role="alert" style="color:#b91c1c">${escapeHtml(error)}</p>` : "";
|
|
807
|
+
const form = allowSubmit
|
|
808
|
+
? `<form method="post" action="/oauth/authorize">
|
|
809
|
+
${hidden}
|
|
810
|
+
<label>Connection password<br><input name="login_token" type="password" autocomplete="current-password" autofocus required style="width: 100%; box-sizing: border-box; padding: 8px;"></label>
|
|
811
|
+
<p><button type="submit">Authorize</button></p>
|
|
812
|
+
</form>`
|
|
813
|
+
: "<p>Authorization cannot continue. Return to the MCP client and start the connection again.</p>";
|
|
760
814
|
return html(`<!doctype html>
|
|
761
815
|
<html>
|
|
762
|
-
<head><meta charset="utf-8"><title>Authorize ${SERVER_NAME}</title></head>
|
|
816
|
+
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Authorize ${SERVER_NAME}</title></head>
|
|
763
817
|
<body style="font-family: system-ui, sans-serif; max-width: 640px; margin: 48px auto; line-height: 1.5; padding: 0 16px;">
|
|
764
818
|
<h1>Authorize ${SERVER_NAME}</h1>
|
|
765
|
-
<p>
|
|
819
|
+
<p>Only continue if you initiated this MCP connection and recognize the client and redirect URI below.</p>
|
|
820
|
+
${clientBlock}
|
|
766
821
|
<p><strong>Resource:</strong> <code>${escapeHtml(resource)}</code></p>
|
|
767
822
|
${errorBlock}
|
|
768
|
-
|
|
769
|
-
${hidden}
|
|
770
|
-
<label>Connection password<br><input name="login_token" type="password" autofocus style="width: 100%; box-sizing: border-box; padding: 8px;"></label>
|
|
771
|
-
<p><button type="submit">Authorize</button></p>
|
|
772
|
-
</form>
|
|
823
|
+
${form}
|
|
773
824
|
</body>
|
|
774
|
-
</html
|
|
825
|
+
</html>`, status);
|
|
775
826
|
}
|
|
776
827
|
|
|
777
828
|
private async authorizeSubmit(request: Request, base: string): Promise<Response> {
|
|
778
|
-
const body = await parseRequestBody(request,
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
829
|
+
const body = await parseRequestBody(request, OAUTH_BODY_LIMIT_BYTES);
|
|
830
|
+
return this.withOAuthLock(async () => {
|
|
831
|
+
const store = await this.oauthStore();
|
|
832
|
+
const validation = validateAuthorizationRequest(body, base, store);
|
|
833
|
+
if ("error" in validation) {
|
|
834
|
+
return this.authorizePage(request, base, validation.error, body, validation.status, undefined, false);
|
|
835
|
+
}
|
|
836
|
+
const { client, clientId, redirectUri, codeChallenge, requestedResource, scope, state } = validation.value;
|
|
837
|
+
const now = Math.floor(Date.now() / 1000);
|
|
838
|
+
const identity = await authorizationIdentity(request, this.identityKey());
|
|
839
|
+
const failure = store.auth_failures[identity];
|
|
840
|
+
if (failure?.blocked_until > now) {
|
|
841
|
+
return this.authorizePage(request, base, "Too many failed attempts. Try again later.", body, 429, validation.value);
|
|
842
|
+
}
|
|
783
843
|
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
expires_at: Math.floor(Date.now() / 1000) + 300,
|
|
808
|
-
};
|
|
809
|
-
await this.ctx.storage.put("oauth", store);
|
|
844
|
+
const expectedLogin = this.env.MCP_OAUTH_PASSWORD ?? "";
|
|
845
|
+
if (!expectedLogin || !(await safeEqual(String(body.login_token ?? ""), expectedLogin))) {
|
|
846
|
+
recordAuthorizationFailure(store, identity, now);
|
|
847
|
+
pruneAuthFailures(store, MAX_AUTH_FAILURE_IDENTITIES);
|
|
848
|
+
await this.ctx.storage.put("oauth", store);
|
|
849
|
+
const status = store.auth_failures[identity]?.blocked_until > now ? 429 : 401;
|
|
850
|
+
return this.authorizePage(request, base, "Invalid connection password.", body, status, validation.value);
|
|
851
|
+
}
|
|
852
|
+
delete store.auth_failures[identity];
|
|
853
|
+
client.last_used_at = now;
|
|
854
|
+
|
|
855
|
+
const code = randomToken("mcp_code");
|
|
856
|
+
store.codes[code] = {
|
|
857
|
+
client_id: clientId,
|
|
858
|
+
redirect_uri: redirectUri,
|
|
859
|
+
code_challenge: codeChallenge,
|
|
860
|
+
scope,
|
|
861
|
+
resource: requestedResource,
|
|
862
|
+
expires_at: now + 300,
|
|
863
|
+
};
|
|
864
|
+
pruneClientRecordByExpiry(store.codes, clientId, MAX_CODES_PER_CLIENT);
|
|
865
|
+
pruneRecordByExpiry(store.codes, MAX_OAUTH_CODES);
|
|
866
|
+
await this.ctx.storage.put("oauth", store);
|
|
810
867
|
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
868
|
+
const params = new URLSearchParams({ code });
|
|
869
|
+
if (state) params.set("state", state);
|
|
870
|
+
return oauthRedirect(`${redirectUri}${redirectUri.includes("?") ? "&" : "?"}${params.toString()}`);
|
|
871
|
+
});
|
|
814
872
|
}
|
|
815
873
|
|
|
816
874
|
private async exchangeToken(request: Request, base: string): Promise<Response> {
|
|
817
|
-
const body = await parseRequestBody(request,
|
|
875
|
+
const body = await parseRequestBody(request, OAUTH_BODY_LIMIT_BYTES);
|
|
818
876
|
if (String(body.grant_type ?? "") !== "authorization_code") return json({ error: "unsupported_grant_type" }, 400);
|
|
819
877
|
|
|
820
878
|
const code = String(body.code ?? "");
|
|
821
879
|
const verifier = String(body.code_verifier ?? "");
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
880
|
+
if (!/^[A-Za-z0-9._~-]{43,128}$/.test(verifier)) return json({ error: "invalid_grant", error_description: "invalid code_verifier" }, 400);
|
|
881
|
+
return this.withOAuthLock(async () => {
|
|
882
|
+
const store = await this.oauthStore();
|
|
883
|
+
const record = store.codes[code];
|
|
884
|
+
if (!record) return json({ error: "invalid_grant" }, 400);
|
|
885
|
+
if (String(body.client_id ?? "") !== record.client_id || String(body.redirect_uri ?? "") !== record.redirect_uri) {
|
|
886
|
+
return json({ error: "invalid_grant", error_description: "client or redirect mismatch" }, 400);
|
|
887
|
+
}
|
|
888
|
+
if (String(body.resource ?? record.resource) !== record.resource) {
|
|
889
|
+
return json({ error: "invalid_target", error_description: "resource mismatch" }, 400);
|
|
890
|
+
}
|
|
891
|
+
if (!(await safeEqual(await pkceS256(verifier), record.code_challenge))) {
|
|
892
|
+
return json({ error: "invalid_grant", error_description: "invalid code_verifier" }, 400);
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
const tokenVersion = this.env.OAUTH_TOKEN_VERSION ?? "";
|
|
896
|
+
if (!tokenVersion) return json({ error: "server_error", error_description: "OAuth token version is not configured" }, 503);
|
|
897
|
+
delete store.codes[code];
|
|
898
|
+
const accessToken = randomToken("mcp_at");
|
|
899
|
+
store.tokens[`sha256:${await sha256Hex(accessToken)}`] = {
|
|
900
|
+
client_id: record.client_id,
|
|
901
|
+
scope: record.scope,
|
|
902
|
+
resource: `${base}/mcp`,
|
|
903
|
+
version: tokenVersion,
|
|
904
|
+
expires_at: Math.floor(Date.now() / 1000) + TOKEN_TTL_SECONDS,
|
|
905
|
+
};
|
|
906
|
+
pruneClientRecordByExpiry(store.tokens, record.client_id, MAX_TOKENS_PER_CLIENT);
|
|
907
|
+
pruneRecordByExpiry(store.tokens, MAX_OAUTH_TOKENS);
|
|
838
908
|
await this.ctx.storage.put("oauth", store);
|
|
839
|
-
return json({
|
|
840
|
-
}
|
|
909
|
+
return json({ access_token: accessToken, token_type: "Bearer", expires_in: TOKEN_TTL_SECONDS, scope: record.scope });
|
|
910
|
+
});
|
|
911
|
+
}
|
|
841
912
|
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
913
|
+
private async verifyAccessToken(token: string, base: string): Promise<boolean> {
|
|
914
|
+
return this.withOAuthLock(async () => {
|
|
915
|
+
if (!token) return false;
|
|
916
|
+
const store = await this.oauthStore();
|
|
917
|
+
const key = `sha256:${await sha256Hex(token)}`;
|
|
918
|
+
const record = store.tokens[key];
|
|
919
|
+
if (!record) return false;
|
|
920
|
+
if (record.expires_at <= Math.floor(Date.now() / 1000)) {
|
|
921
|
+
delete store.tokens[key];
|
|
922
|
+
await this.ctx.storage.put("oauth", store);
|
|
923
|
+
return false;
|
|
924
|
+
}
|
|
925
|
+
const currentVersion = this.env.OAUTH_TOKEN_VERSION ?? "";
|
|
926
|
+
if (!record.version || !currentVersion || !(await safeEqual(record.version, currentVersion))) return false;
|
|
927
|
+
if (record.resource !== `${base}/mcp`) return false;
|
|
928
|
+
return true;
|
|
929
|
+
});
|
|
852
930
|
}
|
|
853
931
|
|
|
854
|
-
private async
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
return null;
|
|
932
|
+
private async withOAuthLock<T>(callback: () => Promise<T>): Promise<T> {
|
|
933
|
+
const previous = this.oauthQueue;
|
|
934
|
+
let release = () => {};
|
|
935
|
+
this.oauthQueue = new Promise<void>((resolve) => { release = resolve; });
|
|
936
|
+
await previous;
|
|
937
|
+
try {
|
|
938
|
+
return await callback();
|
|
939
|
+
} finally {
|
|
940
|
+
release();
|
|
864
941
|
}
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
private identityKey(): string {
|
|
945
|
+
const key = this.env.OAUTH_TOKEN_VERSION || this.env.DAEMON_SHARED_SECRET || this.env.MCP_OAUTH_PASSWORD;
|
|
946
|
+
if (!key) throw new HttpError(503, "server_not_configured", "OAuth identity key is not configured");
|
|
947
|
+
return key;
|
|
869
948
|
}
|
|
870
949
|
|
|
871
950
|
private bodyLimitBytes(): number {
|
|
@@ -885,14 +964,20 @@ export default {
|
|
|
885
964
|
function isJsonRpcRequest(value: unknown): value is JsonRpcRequest {
|
|
886
965
|
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
887
966
|
const candidate = value as Record<string, unknown>;
|
|
888
|
-
|
|
967
|
+
if (candidate.jsonrpc !== JSONRPC_VERSION || typeof candidate.method !== "string" || !candidate.method.trim() || candidate.method.length > 256) return false;
|
|
968
|
+
if ("id" in candidate && !isJsonRpcId(candidate.id)) return false;
|
|
969
|
+
return true;
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
function isJsonRpcId(value: unknown): value is JsonRpcId {
|
|
973
|
+
return value === null || typeof value === "string" || (typeof value === "number" && Number.isFinite(value));
|
|
889
974
|
}
|
|
890
975
|
|
|
891
976
|
function isJsonRpcResponse(value: unknown): boolean {
|
|
892
977
|
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
893
978
|
const candidate = value as Record<string, unknown>;
|
|
894
|
-
if (candidate.jsonrpc !== JSONRPC_VERSION || !("id" in candidate) || typeof candidate.method === "string") return false;
|
|
895
|
-
return "result" in candidate
|
|
979
|
+
if (candidate.jsonrpc !== JSONRPC_VERSION || !("id" in candidate) || !isJsonRpcId(candidate.id) || typeof candidate.method === "string") return false;
|
|
980
|
+
return ("result" in candidate) !== ("error" in candidate);
|
|
896
981
|
}
|
|
897
982
|
|
|
898
983
|
function rpcResult(id: JsonRpcId | undefined, result: unknown): Record<string, unknown> | null {
|
|
@@ -908,51 +993,83 @@ function textToolResult(value: unknown, isError = false): Record<string, unknown
|
|
|
908
993
|
return { content: [{ type: "text", text: typeof value === "string" ? value : JSON.stringify(value, null, 2) }], isError };
|
|
909
994
|
}
|
|
910
995
|
|
|
911
|
-
function
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
delete params.timeout_ms;
|
|
922
|
-
delete params.timeoutMs;
|
|
923
|
-
return params;
|
|
996
|
+
function methodNotAllowed(allow: string): Response {
|
|
997
|
+
return new Response(JSON.stringify({ error: "method_not_allowed" }), {
|
|
998
|
+
status: 405,
|
|
999
|
+
headers: {
|
|
1000
|
+
allow,
|
|
1001
|
+
"cache-control": "no-store",
|
|
1002
|
+
"content-type": "application/json; charset=utf-8",
|
|
1003
|
+
"x-content-type-options": "nosniff",
|
|
1004
|
+
},
|
|
1005
|
+
});
|
|
924
1006
|
}
|
|
925
1007
|
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
1008
|
+
function oauthRedirect(location: string): Response {
|
|
1009
|
+
return new Response(null, {
|
|
1010
|
+
status: 302,
|
|
1011
|
+
headers: {
|
|
1012
|
+
location,
|
|
1013
|
+
"cache-control": "no-store",
|
|
1014
|
+
"referrer-policy": "no-referrer",
|
|
1015
|
+
"x-content-type-options": "nosniff",
|
|
1016
|
+
},
|
|
1017
|
+
});
|
|
933
1018
|
}
|
|
934
1019
|
|
|
935
|
-
|
|
936
|
-
|
|
1020
|
+
function isFreshDaemonCandidate(connectedAt: string): boolean {
|
|
1021
|
+
const timestamp = Date.parse(connectedAt);
|
|
1022
|
+
if (!Number.isFinite(timestamp)) return false;
|
|
1023
|
+
const age = Date.now() - timestamp;
|
|
1024
|
+
return age >= 0 && age <= DAEMON_HELLO_TIMEOUT_MS;
|
|
937
1025
|
}
|
|
938
1026
|
|
|
939
|
-
function
|
|
940
|
-
|
|
1027
|
+
function sanitizeMetadataText(value: unknown, maxLength: number): string | undefined {
|
|
1028
|
+
if (typeof value !== "string") return undefined;
|
|
1029
|
+
const normalized = value.replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim();
|
|
1030
|
+
return normalized ? normalized.slice(0, maxLength) : undefined;
|
|
941
1031
|
}
|
|
942
1032
|
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
const
|
|
947
|
-
return
|
|
1033
|
+
|
|
1034
|
+
function sanitizeDaemonTools(value: unknown): string[] {
|
|
1035
|
+
if (!Array.isArray(value)) return [];
|
|
1036
|
+
const known = new Set<string>(workspaceTools.map((tool) => tool.name));
|
|
1037
|
+
return [...new Set(value.filter((item): item is string => typeof item === "string" && known.has(item)))];
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
function sanitizeDaemonPolicy(value: unknown): Record<string, unknown> {
|
|
1041
|
+
const policy = asObject(value);
|
|
1042
|
+
return {
|
|
1043
|
+
allowWrite: policy.allowWrite === true,
|
|
1044
|
+
allowExec: policy.allowExec === true,
|
|
1045
|
+
unrestrictedPaths: policy.unrestrictedPaths === true,
|
|
1046
|
+
minimalEnv: policy.minimalEnv !== false,
|
|
1047
|
+
};
|
|
948
1048
|
}
|
|
949
1049
|
|
|
950
1050
|
function json(value: unknown, status = 200): Response {
|
|
951
|
-
return new Response(JSON.stringify(value), {
|
|
1051
|
+
return new Response(JSON.stringify(value), {
|
|
1052
|
+
status,
|
|
1053
|
+
headers: {
|
|
1054
|
+
"content-type": "application/json; charset=utf-8",
|
|
1055
|
+
"cache-control": "no-store",
|
|
1056
|
+
"x-content-type-options": "nosniff",
|
|
1057
|
+
},
|
|
1058
|
+
});
|
|
952
1059
|
}
|
|
953
1060
|
|
|
954
1061
|
function html(value: string, status = 200): Response {
|
|
955
|
-
return new Response(value, {
|
|
1062
|
+
return new Response(value, {
|
|
1063
|
+
status,
|
|
1064
|
+
headers: {
|
|
1065
|
+
"content-type": "text/html; charset=utf-8",
|
|
1066
|
+
"cache-control": "no-store",
|
|
1067
|
+
"content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'",
|
|
1068
|
+
"referrer-policy": "no-referrer",
|
|
1069
|
+
"x-content-type-options": "nosniff",
|
|
1070
|
+
"x-frame-options": "DENY",
|
|
1071
|
+
},
|
|
1072
|
+
});
|
|
956
1073
|
}
|
|
957
1074
|
|
|
958
1075
|
function baseUrl(request: Request): string {
|
|
@@ -989,6 +1106,7 @@ async function parseRequestBody(request: Request, limit: number): Promise<Record
|
|
|
989
1106
|
return searchParamsObject(new URLSearchParams(text));
|
|
990
1107
|
}
|
|
991
1108
|
|
|
1109
|
+
|
|
992
1110
|
async function readBoundedText(request: Request, limit: number): Promise<string> {
|
|
993
1111
|
const length = Number(request.headers.get("content-length") ?? "0");
|
|
994
1112
|
if (Number.isFinite(length) && length > limit) throw new HttpError(413, "request_body_too_large", `request body exceeds ${limit} bytes`);
|
|
@@ -1017,7 +1135,11 @@ async function readBoundedText(request: Request, limit: number): Promise<string>
|
|
|
1017
1135
|
combined.set(chunk, offset);
|
|
1018
1136
|
offset += chunk.byteLength;
|
|
1019
1137
|
}
|
|
1020
|
-
|
|
1138
|
+
try {
|
|
1139
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(combined);
|
|
1140
|
+
} catch {
|
|
1141
|
+
throw new HttpError(400, "invalid_encoding", "Request body must be valid UTF-8");
|
|
1142
|
+
}
|
|
1021
1143
|
}
|
|
1022
1144
|
|
|
1023
1145
|
function asObject(value: unknown): Record<string, unknown> {
|
|
@@ -1047,10 +1169,85 @@ function daemonToolTimeoutMs(name: string, args: Record<string, unknown>): numbe
|
|
|
1047
1169
|
return Math.min((seconds + 5) * 1000, 610_000);
|
|
1048
1170
|
}
|
|
1049
1171
|
|
|
1050
|
-
function
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1172
|
+
function validateAuthorizationRequest(
|
|
1173
|
+
body: Record<string, unknown>,
|
|
1174
|
+
base: string,
|
|
1175
|
+
store: OAuthStore,
|
|
1176
|
+
): { value: ValidatedAuthorization } | { error: string; status: number } {
|
|
1177
|
+
const responseType = String(body.response_type ?? "");
|
|
1178
|
+
const clientId = String(body.client_id ?? "");
|
|
1179
|
+
const redirectUri = String(body.redirect_uri ?? "");
|
|
1180
|
+
const codeChallenge = String(body.code_challenge ?? "");
|
|
1181
|
+
const codeChallengeMethod = String(body.code_challenge_method ?? "");
|
|
1182
|
+
const requestedResource = String(body.resource ?? `${base}/mcp`);
|
|
1183
|
+
const scope = String(body.scope ?? SERVER_NAME).trim();
|
|
1184
|
+
const state = body.state === undefined ? "" : typeof body.state === "string" ? body.state : "";
|
|
1185
|
+
|
|
1186
|
+
if (responseType !== "code") return { error: "response_type must be code.", status: 400 };
|
|
1187
|
+
if (requestedResource !== `${base}/mcp`) return { error: "resource mismatch.", status: 400 };
|
|
1188
|
+
if (scope !== SERVER_NAME) return { error: "unsupported scope.", status: 400 };
|
|
1189
|
+
if (body.state !== undefined && typeof body.state !== "string") return { error: "state must be a string.", status: 400 };
|
|
1190
|
+
if (state.length > 1024) return { error: "state is too long.", status: 400 };
|
|
1191
|
+
if (codeChallengeMethod !== "S256" || !/^[A-Za-z0-9_-]{43}$/.test(codeChallenge)) {
|
|
1192
|
+
return { error: "A valid PKCE S256 challenge is required.", status: 400 };
|
|
1193
|
+
}
|
|
1194
|
+
const client = store.clients[clientId];
|
|
1195
|
+
if (!client) return { error: "Unknown OAuth client.", status: 400 };
|
|
1196
|
+
if (!client.redirect_uris.includes(redirectUri)) return { error: "redirect_uri is not registered.", status: 400 };
|
|
1197
|
+
return { value: { client, clientId, redirectUri, codeChallenge, requestedResource, scope, state } };
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
function pruneClientRecordByExpiry<T extends { client_id: string; expires_at: number }>(record: Record<string, T>, clientId: string, keep: number): void {
|
|
1201
|
+
const allowed = new Set(Object.entries(record)
|
|
1202
|
+
.filter(([, value]) => value.client_id === clientId)
|
|
1203
|
+
.sort((left, right) => right[1].expires_at - left[1].expires_at)
|
|
1204
|
+
.slice(0, keep)
|
|
1205
|
+
.map(([key]) => key));
|
|
1206
|
+
for (const [key, value] of Object.entries(record)) {
|
|
1207
|
+
if (value.client_id === clientId && !allowed.has(key)) delete record[key];
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
function pruneRecordByExpiry<T extends { expires_at: number }>(record: Record<string, T>, keep: number): void {
|
|
1212
|
+
const allowed = new Set(Object.entries(record)
|
|
1213
|
+
.sort((left, right) => right[1].expires_at - left[1].expires_at)
|
|
1214
|
+
.slice(0, keep)
|
|
1215
|
+
.map(([key]) => key));
|
|
1216
|
+
for (const key of Object.keys(record)) if (!allowed.has(key)) delete record[key];
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
async function authorizationIdentity(request: Request, keyMaterial: string): Promise<string> {
|
|
1220
|
+
const source = (request.headers.get("CF-Connecting-IP") || "unknown").slice(0, 128);
|
|
1221
|
+
const encoder = new TextEncoder();
|
|
1222
|
+
const key = await crypto.subtle.importKey(
|
|
1223
|
+
"raw",
|
|
1224
|
+
encoder.encode(keyMaterial),
|
|
1225
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
1226
|
+
false,
|
|
1227
|
+
["sign"],
|
|
1228
|
+
);
|
|
1229
|
+
const digest = await crypto.subtle.sign("HMAC", key, encoder.encode(source));
|
|
1230
|
+
return `hmac-sha256:${[...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("")}`;
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
function recordAuthorizationFailure(store: OAuthStore, identity: string, now: number): void {
|
|
1234
|
+
const current = store.auth_failures[identity];
|
|
1235
|
+
const activeWindow = current && current.window_started + AUTH_FAILURE_WINDOW_SECONDS > now;
|
|
1236
|
+
const count = activeWindow ? current.count + 1 : 1;
|
|
1237
|
+
store.auth_failures[identity] = {
|
|
1238
|
+
count,
|
|
1239
|
+
window_started: activeWindow ? current.window_started : now,
|
|
1240
|
+
blocked_until: count >= AUTH_FAILURE_LIMIT ? now + AUTH_BLOCK_SECONDS : 0,
|
|
1241
|
+
last_attempt: now,
|
|
1242
|
+
};
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
function pruneAuthFailures(store: OAuthStore, keep: number): void {
|
|
1246
|
+
const allowed = new Set(Object.entries(store.auth_failures)
|
|
1247
|
+
.sort((left, right) => right[1].last_attempt - left[1].last_attempt)
|
|
1248
|
+
.slice(0, keep)
|
|
1249
|
+
.map(([key]) => key));
|
|
1250
|
+
for (const key of Object.keys(store.auth_failures)) if (!allowed.has(key)) delete store.auth_failures[key];
|
|
1054
1251
|
}
|
|
1055
1252
|
|
|
1056
1253
|
function randomToken(prefix: string): string {
|
|
@@ -1089,7 +1286,8 @@ async function pkceS256(verifier: string): Promise<string> {
|
|
|
1089
1286
|
function isAllowedRedirectUri(value: string): boolean {
|
|
1090
1287
|
try {
|
|
1091
1288
|
const url = new URL(value);
|
|
1092
|
-
if (url.
|
|
1289
|
+
if (url.username || url.password || url.hash) return false;
|
|
1290
|
+
if (url.protocol === "https:" && url.hostname) return true;
|
|
1093
1291
|
if (url.protocol === "http:" && isLoopbackHost(url.hostname)) return true;
|
|
1094
1292
|
return false;
|
|
1095
1293
|
} catch {
|
|
@@ -1097,19 +1295,56 @@ function isAllowedRedirectUri(value: string): boolean {
|
|
|
1097
1295
|
}
|
|
1098
1296
|
}
|
|
1099
1297
|
|
|
1100
|
-
function
|
|
1101
|
-
const origin = request.headers.get("Origin");
|
|
1102
|
-
if (!origin) return
|
|
1298
|
+
function corsPreflight(request: Request, base: string, configured: string): Response {
|
|
1299
|
+
const origin = request.headers.get("Origin") ?? "";
|
|
1300
|
+
if (!isConfiguredOrSameOrigin(origin, base, configured)) return json({ error: "origin_not_allowed" }, 403);
|
|
1301
|
+
const requestedMethod = (request.headers.get("Access-Control-Request-Method") ?? "").toUpperCase();
|
|
1302
|
+
if (requestedMethod && !["GET", "POST"].includes(requestedMethod)) return methodNotAllowed("GET, POST, OPTIONS");
|
|
1303
|
+
return new Response(null, {
|
|
1304
|
+
status: 204,
|
|
1305
|
+
headers: {
|
|
1306
|
+
"access-control-allow-origin": origin,
|
|
1307
|
+
"access-control-allow-methods": "GET, POST, OPTIONS",
|
|
1308
|
+
"access-control-allow-headers": "authorization, content-type, mcp-protocol-version, mcp-session-id",
|
|
1309
|
+
"access-control-max-age": "600",
|
|
1310
|
+
"cache-control": "no-store",
|
|
1311
|
+
"vary": "Origin, Access-Control-Request-Method, Access-Control-Request-Headers",
|
|
1312
|
+
},
|
|
1313
|
+
});
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
function applyCors(response: Response, request: Request, base: string, configured: string): Response {
|
|
1317
|
+
if (response.status === 101) return response;
|
|
1318
|
+
const origin = request.headers.get("Origin") ?? "";
|
|
1319
|
+
if (!origin || !isConfiguredOrSameOrigin(origin, base, configured)) return response;
|
|
1320
|
+
const headers = new Headers(response.headers);
|
|
1321
|
+
headers.set("access-control-allow-origin", origin);
|
|
1322
|
+
headers.set("access-control-expose-headers", "www-authenticate, mcp-session-id");
|
|
1323
|
+
appendVary(headers, "Origin");
|
|
1324
|
+
return new Response(response.body, { status: response.status, statusText: response.statusText, headers });
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
function appendVary(headers: Headers, value: string): void {
|
|
1328
|
+
const existing = headers.get("vary");
|
|
1329
|
+
const values = new Set((existing ?? "").split(",").map((item) => item.trim()).filter(Boolean));
|
|
1330
|
+
values.add(value);
|
|
1331
|
+
headers.set("vary", [...values].join(", "));
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
function isConfiguredOrSameOrigin(origin: string, base: string, configured: string): boolean {
|
|
1103
1335
|
if (isDefaultAllowedOrigin(origin, base)) return true;
|
|
1104
|
-
const allowed = configured.split(",").map((item) => item.trim()).filter(
|
|
1336
|
+
const allowed = configured.split(",").map((item) => item.trim()).filter((item) => item && item !== "null");
|
|
1105
1337
|
return allowed.includes(origin);
|
|
1106
1338
|
}
|
|
1107
1339
|
|
|
1340
|
+
function validateOrigin(request: Request, base: string, configured = ""): boolean {
|
|
1341
|
+
const origin = request.headers.get("Origin");
|
|
1342
|
+
return !origin || isConfiguredOrSameOrigin(origin, base, configured);
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1108
1345
|
function isDefaultAllowedOrigin(origin: string, base: string): boolean {
|
|
1109
1346
|
try {
|
|
1110
|
-
|
|
1111
|
-
if (origin === base) return true;
|
|
1112
|
-
return parsed.protocol === "http:" && isLoopbackHost(parsed.hostname);
|
|
1347
|
+
return new URL(origin).origin === new URL(base).origin;
|
|
1113
1348
|
} catch {
|
|
1114
1349
|
return false;
|
|
1115
1350
|
}
|
|
@@ -1135,6 +1370,11 @@ function searchParamsObject(params: URLSearchParams): Record<string, unknown> {
|
|
|
1135
1370
|
return out;
|
|
1136
1371
|
}
|
|
1137
1372
|
|
|
1373
|
+
function normalizeDisplayText(value: string, maxLength: number): string {
|
|
1374
|
+
const normalized = value.replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim();
|
|
1375
|
+
return (normalized || "MCP Client").slice(0, maxLength);
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1138
1378
|
function escapeHtml(value: string): string {
|
|
1139
1379
|
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
|
1140
1380
|
}
|