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.
@@ -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.2.4";
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 = 32 * 1024 * 1024;
8
- const MAX_BODY_BYTES = 64 * 1024 * 1024;
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 AuthenticatedClient {
54
- clientId: string;
55
- scope: string;
56
- resource: string;
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
- streamId?: string;
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; absolute paths and parent-directory paths are allowed. Sensitive files are not hidden by default.",
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: "Write a UTF-8 file. Relative paths use the daemon workspace; absolute paths and parent-directory paths are allowed. Enabled by default.",
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 in the local workspace.",
181
- inputSchema: { type: "object", additionalProperties: true },
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; absolute paths and parent-directory paths are allowed by default.",
214
- "Writes and shell execution are enabled by default in this bridge for ease of use.",
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 readonly pendingClientRequests = new Map<string, PendingCall>();
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
- try {
232
- if (!validateOrigin(request, base, this.env.MBM_ALLOWED_ORIGINS)) {
233
- return json({ error: "origin_not_allowed" }, 403);
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
- if (url.pathname === "/" && request.method === "GET") {
237
- return json({ ok: true, server: SERVER_NAME, version: SERVER_VERSION, mcp: `${base}/mcp`, daemon: this.daemonStatus(false), mcp_clients: this.mcpClientStatus(false) });
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
- return json({ ok: true, server: SERVER_NAME, version: SERVER_VERSION, daemon: this.daemonStatus(false), mcp_clients: this.mcpClientStatus(false) });
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" && request.method === "POST") return this.registerClient(request);
257
- if (url.pathname === "/oauth/authorize" && request.method === "GET") return this.authorizePage(request, base);
258
- if (url.pathname === "/oauth/authorize" && request.method === "POST") return this.authorizeSubmit(request, base);
259
- if (url.pathname === "/oauth/token" && request.method === "POST") return this.exchangeToken(request, base);
260
- if (url.pathname === "/daemon/ws") return this.acceptDaemonWebSocket(request);
261
- if (url.pathname === "/mcp") return this.handleMcp(request, base);
262
- if (url.pathname === "/api/daemon/status") return json(this.daemonStatus(false));
263
- if (url.pathname === "/api/mcp/sampling") return this.handleSamplingApi(request);
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 text = typeof message === "string" ? message : new TextDecoder().decode(message);
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 attachment = this.daemonAttachment(ws);
284
- if (attachment) {
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
- ...attachment,
287
- workspaceHash: stringOrUndefined(body.workspace_hash) ?? attachment.workspaceHash,
288
- workspaceName: stringOrUndefined(body.workspace_name) ?? attachment.workspaceName,
289
- policy: asObject(body.policy),
290
- tools: Array.isArray(body.tools) ? body.tools.filter((item): item is string => typeof item === "string") : attachment.tools,
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
- ws.send(JSON.stringify({ type: "hello_ack", server: SERVER_NAME, version: SERVER_VERSION }));
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?.role !== "daemon") return;
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 === "DELETE") return new Response(null, { status: 405 });
327
- if (request.method !== "POST" && request.method !== "GET") return json({ error: "mcp endpoint expects POST JSON-RPC or GET SSE" }, 405);
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 auth = await this.verifyAccessToken(bearerToken(request), base);
330
- if (!auth) {
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: { "WWW-Authenticate": `Bearer resource_metadata="${base}/.well-known/oauth-protected-resource/mcp"` },
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, auth);
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, auth: AuthenticatedClient): Promise<Record<string, unknown> | null> {
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
- socket.send(JSON.stringify({ type: "tool_call", id, tool: name, arguments: args, timeout_ms: timeoutMs }));
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.daemonSockets()) {
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: "daemon",
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
- const attachment = socket ? this.daemonAttachment(socket) : undefined;
619
- if (!attachment?.tools?.length) return null;
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 attachment = this.daemonAttachment(socket);
626
- return attachment?.role === "daemon" && socket.readyState === WebSocket.OPEN;
561
+ const role = this.socketAttachment(socket)?.role;
562
+ return role !== "daemon" && socket.readyState === WebSocket.OPEN;
627
563
  });
628
564
  }
629
565
 
630
- private daemonAttachment(socket: WebSocket): DaemonAttachment | undefined {
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
- return candidate.role === "daemon" ? (candidate as DaemonAttachment) : undefined;
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: this.allTools().map((tool) => tool.name),
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, this.bodyLimitBytes());
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 > 20) {
720
- return json({ error: "invalid_client_metadata", error_description: "redirect_uris must contain at most 20 entries" }, 400);
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 > 2048)) {
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
- const store = await this.oauthStore();
731
- const client: OAuthClient = {
732
- client_id: randomToken("mcp_client"),
733
- client_name: (stringOrUndefined(body.client_name) ?? "MCP Client").slice(0, 128),
734
- redirect_uris: normalized,
735
- created_at: Math.floor(Date.now() / 1000),
736
- };
737
- store.clients[client.client_id] = client;
738
- pruneOAuthClients(store, 100);
739
- await this.ctx.storage.put("oauth", store);
740
- return json({
741
- client_id: client.client_id,
742
- client_name: client.client_name,
743
- redirect_uris: client.redirect_uris,
744
- grant_types: ["authorization_code"],
745
- response_types: ["code"],
746
- token_endpoint_auth_method: "none",
747
- client_id_issued_at: client.created_at,
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 authorizePage(request: Request, base: string, error = "", submitted?: Record<string, unknown>): Response {
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 !== "login_token")
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 errorBlock = error ? `<p style="color:#b91c1c">${escapeHtml(error)}</p>` : "";
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>Enter the MCP connection password printed by <code>machine-bridge-mcp start</code>.</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
- <form method="post" action="/oauth/authorize">
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, this.bodyLimitBytes());
779
- const expectedLogin = this.env.MCP_OAUTH_PASSWORD ?? "";
780
- if (!expectedLogin || !(await safeEqual(String(body.login_token ?? ""), expectedLogin))) {
781
- return this.authorizePage(request, base, "Invalid connection password.", body);
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
- const responseType = String(body.response_type ?? "");
785
- const clientId = String(body.client_id ?? "");
786
- const redirectUri = String(body.redirect_uri ?? "");
787
- const codeChallenge = String(body.code_challenge ?? "");
788
- const codeChallengeMethod = String(body.code_challenge_method ?? "");
789
- const requestedResource = String(body.resource ?? `${base}/mcp`);
790
-
791
- if (responseType !== "code") return this.authorizePage(request, base, "response_type must be code.", body);
792
- if (requestedResource !== `${base}/mcp`) return this.authorizePage(request, base, "resource mismatch.", body);
793
- if (codeChallengeMethod !== "S256" || !codeChallenge) return this.authorizePage(request, base, "PKCE S256 is required.", body);
794
-
795
- const store = await this.oauthStore();
796
- const client = store.clients[clientId];
797
- if (!client) return this.authorizePage(request, base, "Unknown OAuth client.", body);
798
- if (!client.redirect_uris.includes(redirectUri)) return this.authorizePage(request, base, "redirect_uri is not registered.", body);
799
-
800
- const code = randomToken("mcp_code");
801
- store.codes[code] = {
802
- client_id: clientId,
803
- redirect_uri: redirectUri,
804
- code_challenge: codeChallenge,
805
- scope: String(body.scope ?? SERVER_NAME),
806
- resource: requestedResource,
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
- const params = new URLSearchParams({ code });
812
- if (typeof body.state === "string" && body.state) params.set("state", body.state);
813
- return Response.redirect(`${redirectUri}${redirectUri.includes("?") ? "&" : "?"}${params.toString()}`, 302);
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, this.bodyLimitBytes());
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
- const store = await this.oauthStore();
823
- const record = store.codes[code];
824
- delete store.codes[code];
825
- if (!record) {
826
- await this.ctx.storage.put("oauth", store);
827
- return json({ error: "invalid_grant" }, 400);
828
- }
829
- if (String(body.client_id ?? "") !== record.client_id || String(body.redirect_uri ?? "") !== record.redirect_uri) {
830
- await this.ctx.storage.put("oauth", store);
831
- return json({ error: "invalid_grant", error_description: "client or redirect mismatch" }, 400);
832
- }
833
- if (String(body.resource ?? record.resource) !== record.resource) {
834
- await this.ctx.storage.put("oauth", store);
835
- return json({ error: "invalid_target", error_description: "resource mismatch" }, 400);
836
- }
837
- if (!(await safeEqual(await pkceS256(verifier), record.code_challenge))) {
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({ error: "invalid_grant", error_description: "invalid code_verifier" }, 400);
840
- }
909
+ return json({ access_token: accessToken, token_type: "Bearer", expires_in: TOKEN_TTL_SECONDS, scope: record.scope });
910
+ });
911
+ }
841
912
 
842
- const accessToken = randomToken("mcp_at");
843
- store.tokens[`sha256:${await sha256Hex(accessToken)}`] = {
844
- client_id: record.client_id,
845
- scope: record.scope,
846
- resource: `${base}/mcp`,
847
- version: this.env.OAUTH_TOKEN_VERSION ?? "",
848
- expires_at: Math.floor(Date.now() / 1000) + TOKEN_TTL_SECONDS,
849
- };
850
- await this.ctx.storage.put("oauth", store);
851
- return json({ access_token: accessToken, token_type: "Bearer", expires_in: TOKEN_TTL_SECONDS, scope: record.scope });
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 verifyAccessToken(token: string, base: string): Promise<AuthenticatedClient | null> {
855
- if (!token) return null;
856
- const store = await this.oauthStore();
857
- const key = `sha256:${await sha256Hex(token)}`;
858
- const record = store.tokens[key];
859
- if (!record) return null;
860
- if (record.expires_at <= Math.floor(Date.now() / 1000)) {
861
- delete store.tokens[key];
862
- await this.ctx.storage.put("oauth", store);
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
- const currentVersion = this.env.OAUTH_TOKEN_VERSION ?? "";
866
- if (!record.version || !currentVersion || !(await safeEqual(record.version, currentVersion))) return null;
867
- if (record.resource !== `${base}/mcp`) return null;
868
- return { clientId: record.client_id, scope: record.scope, resource: record.resource };
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
- return candidate.jsonrpc === JSONRPC_VERSION && typeof candidate.method === "string";
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 || "error" 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 samplingParamsFromApiBody(body: Record<string, unknown>): Record<string, unknown> {
912
- const messages = body.messages;
913
- if (!Array.isArray(messages) || messages.length === 0) {
914
- throw new HttpError(400, "invalid_sampling_request", "sampling/createMessage requires a non-empty messages array");
915
- }
916
- const maxTokens = Number(body.maxTokens);
917
- if (!Number.isFinite(maxTokens) || maxTokens <= 0) {
918
- throw new HttpError(400, "invalid_sampling_request", "sampling/createMessage requires maxTokens");
919
- }
920
- const params = { ...body };
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
- async function writeSseJson(writer: WritableStreamDefaultWriter<Uint8Array>, value: unknown, id?: string): Promise<void> {
927
- const lines = [];
928
- if (id) lines.push(`id: ${sseLine(id)}`);
929
- lines.push("event: message");
930
- for (const line of JSON.stringify(value).split(/\r?\n/)) lines.push(`data: ${line}`);
931
- lines.push("", "");
932
- await writer.write(new TextEncoder().encode(lines.join("\n")));
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
- async function writeSseComment(writer: WritableStreamDefaultWriter<Uint8Array>, value: string): Promise<void> {
936
- await writer.write(new TextEncoder().encode(`: ${sseLine(value)}\n\n`));
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 sseLine(value: string): string {
940
- return value.replaceAll("\r", " ").replaceAll("\n", " ");
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
- function jsonRpcErrorMessage(error: unknown): string {
944
- const value = asObject(error);
945
- const message = typeof value.message === "string" && value.message ? value.message : "MCP client returned an error";
946
- const code = value.code === undefined ? "" : ` (${String(value.code)})`;
947
- return `${message}${code}`;
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), { status, headers: { "content-type": "application/json; charset=utf-8" } });
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, { status, headers: { "content-type": "text/html; charset=utf-8" } });
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
- return new TextDecoder().decode(combined);
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 pruneOAuthClients(store: OAuthStore, keep: number): void {
1051
- const clients = Object.values(store.clients).sort((a, b) => b.created_at - a.created_at);
1052
- const allowed = new Set(clients.slice(0, keep).map((client) => client.client_id));
1053
- for (const clientId of Object.keys(store.clients)) if (!allowed.has(clientId)) delete store.clients[clientId];
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.protocol === "https:") return true;
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 validateOrigin(request: Request, base: string, configured = ""): boolean {
1101
- const origin = request.headers.get("Origin");
1102
- if (!origin) return true;
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(Boolean);
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
- const parsed = new URL(origin);
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("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
1140
1380
  }