machine-bridge-mcp 0.2.5 → 0.4.0

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,31 @@
1
1
  import { DurableObject } from "cloudflare:workers";
2
+ import toolCatalog from "../shared/tool-catalog.json";
2
3
 
3
4
  const SERVER_NAME = "machine-bridge-mcp";
4
- const SERVER_VERSION = "0.2.5";
5
- const MCP_PROTOCOL_VERSION = "2025-06-18";
5
+ const SERVER_VERSION = "0.4.0";
6
+ const MCP_PROTOCOL_VERSION = "2025-11-25";
7
+ const MCP_SUPPORTED_PROTOCOL_VERSIONS = ["2025-11-25", "2025-06-18", "2025-03-26"] as const;
6
8
  const JSONRPC_VERSION = "2.0";
7
- const DEFAULT_MAX_BODY_BYTES = 32 * 1024 * 1024;
8
- const MAX_BODY_BYTES = 64 * 1024 * 1024;
9
+ const DEFAULT_MAX_BODY_BYTES = 8 * 1024 * 1024;
10
+ const MAX_BODY_BYTES = 16 * 1024 * 1024;
9
11
  const TOKEN_TTL_SECONDS = 60 * 60 * 24 * 30;
12
+ const OAUTH_BODY_LIMIT_BYTES = 64 * 1024;
13
+ const MAX_PENDING_CALLS = 32;
14
+ const MAX_DAEMON_MESSAGE_BYTES = 8 * 1024 * 1024;
15
+ const DAEMON_HELLO_TIMEOUT_MS = 10_000;
16
+ const OAUTH_UNUSED_CLIENT_TTL_SECONDS = 60 * 60;
17
+ const MAX_OAUTH_CLIENTS = 50;
18
+ const MAX_OAUTH_CLIENTS_PER_IDENTITY = 5;
19
+ const OAUTH_CLIENT_IDLE_TTL_SECONDS = 60 * 60 * 24 * 90;
20
+ const MAX_TOKENS_PER_CLIENT = 20;
21
+ const MAX_CODES_PER_CLIENT = 10;
22
+ const MAX_OAUTH_CODES = 200;
23
+ const MAX_OAUTH_TOKENS = 500;
24
+ const MAX_AUTH_FAILURE_IDENTITIES = 200;
25
+ const AUTH_FAILURE_WINDOW_SECONDS = 10 * 60;
26
+ const AUTH_BLOCK_SECONDS = 15 * 60;
27
+ const AUTH_FAILURE_LIMIT = 10;
28
+ const AUTHORIZATION_FIELDS = new Set(["response_type", "client_id", "redirect_uri", "code_challenge", "code_challenge_method", "scope", "resource", "state"]);
10
29
 
11
30
  interface BridgeEnv {
12
31
  BRIDGE: DurableObjectNamespace<BridgeRoom>;
@@ -31,6 +50,8 @@ interface OAuthClient {
31
50
  client_name: string;
32
51
  redirect_uris: string[];
33
52
  created_at: number;
53
+ last_used_at: number;
54
+ registration_identity?: string;
34
55
  }
35
56
 
36
57
  interface OAuthCode {
@@ -50,24 +71,33 @@ interface OAuthToken {
50
71
  expires_at: number;
51
72
  }
52
73
 
53
- interface AuthenticatedClient {
54
- clientId: string;
55
- scope: string;
56
- resource: string;
74
+ interface OAuthFailure {
75
+ count: number;
76
+ window_started: number;
77
+ blocked_until: number;
78
+ last_attempt: number;
57
79
  }
58
80
 
59
81
  interface OAuthStore {
60
82
  clients: Record<string, OAuthClient>;
61
83
  codes: Record<string, OAuthCode>;
62
84
  tokens: Record<string, OAuthToken>;
85
+ auth_failures: Record<string, OAuthFailure>;
86
+ }
87
+
88
+ interface ValidatedAuthorization {
89
+ client: OAuthClient;
90
+ clientId: string;
91
+ redirectUri: string;
92
+ codeChallenge: string;
93
+ requestedResource: string;
94
+ scope: string;
95
+ state: string;
63
96
  }
64
97
 
65
98
  interface DaemonAttachment {
66
- role: "daemon";
99
+ role: "candidate" | "expired" | "daemon";
67
100
  connectedAt: string;
68
- daemonId: string;
69
- workspaceHash?: string;
70
- workspaceName?: string;
71
101
  policy?: Record<string, unknown>;
72
102
  tools?: string[];
73
103
  }
@@ -76,170 +106,66 @@ interface PendingCall {
76
106
  resolve: (value: unknown) => void;
77
107
  reject: (error: Error) => void;
78
108
  timeout: ReturnType<typeof setTimeout>;
79
- streamId?: string;
109
+ socket: WebSocket;
110
+ clientRequestKey?: string;
80
111
  }
81
112
 
82
- interface McpClientState {
113
+ interface AuthorizedToken {
114
+ tokenKey: string;
83
115
  clientId: string;
84
- initializedAt: string;
85
- capabilities: Record<string, unknown>;
86
- clientInfo?: Record<string, unknown>;
87
116
  }
88
117
 
89
- interface McpClientStream {
90
- id: string;
91
- clientId: string;
92
- connectedAt: number;
93
- writer: WritableStreamDefaultWriter<Uint8Array>;
94
- heartbeat: ReturnType<typeof setInterval>;
95
- }
96
-
97
- const serverInfoTool = {
98
- name: "server_info",
99
- description: "Return bridge metadata, OAuth endpoint details, daemon connection status, and available tools.",
100
- inputSchema: { type: "object", additionalProperties: true },
101
- } as const;
102
-
103
- const workspaceTools = [
104
- {
105
- name: "project_overview",
106
- description: "Summarize the connected local workspace and daemon policy.",
107
- inputSchema: { type: "object", additionalProperties: true },
108
- },
109
- {
110
- name: "list_roots",
111
- description: "List workspace roots exposed by the local daemon.",
112
- inputSchema: { type: "object", additionalProperties: true },
113
- },
114
- {
115
- name: "list_dir",
116
- description: "List direct children of a workspace directory.",
117
- inputSchema: {
118
- type: "object",
119
- properties: { path: { type: "string", default: "." } },
120
- additionalProperties: true,
121
- },
122
- },
123
- {
124
- name: "list_files",
125
- description: "Recursively list files under a workspace path.",
126
- inputSchema: {
127
- type: "object",
128
- properties: {
129
- path: { type: "string", default: "." },
130
- max_files: { type: "integer", minimum: 1, maximum: 10000, default: 1000 },
131
- },
132
- additionalProperties: true,
133
- },
134
- },
135
- {
136
- 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.",
138
- inputSchema: {
139
- type: "object",
140
- properties: {
141
- path: { type: "string" },
142
- max_bytes: { type: "integer", minimum: 1, maximum: 5242880, default: 1048576 },
143
- },
144
- required: ["path"],
145
- additionalProperties: true,
146
- },
147
- },
148
- {
149
- 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.",
151
- inputSchema: {
152
- type: "object",
153
- properties: {
154
- path: { type: "string" },
155
- content: { type: "string" },
156
- create_only: { type: "boolean", default: false },
157
- expected_sha256: { type: "string" },
158
- },
159
- required: ["path", "content"],
160
- additionalProperties: true,
161
- },
162
- },
163
- {
164
- name: "search_text",
165
- description: "Search plain text files under a workspace path for a substring.",
166
- inputSchema: {
167
- type: "object",
168
- properties: {
169
- query: { type: "string" },
170
- path: { type: "string", default: "." },
171
- max_matches: { type: "integer", minimum: 1, maximum: 1000, default: 100 },
172
- max_files: { type: "integer", minimum: 1, maximum: 100000, default: 10000 },
173
- },
174
- required: ["query"],
175
- additionalProperties: true,
176
- },
177
- },
178
- {
179
- name: "git_status",
180
- description: "Run git status --short in the local workspace.",
181
- inputSchema: { type: "object", additionalProperties: true },
182
- },
183
- {
184
- name: "git_diff",
185
- description: "Run git diff for the local workspace and return bounded output.",
186
- inputSchema: {
187
- type: "object",
188
- properties: {
189
- path: { type: "string", default: "." },
190
- max_bytes: { type: "integer", minimum: 1, maximum: 5242880, default: 1048576 },
191
- },
192
- additionalProperties: true,
193
- },
194
- },
195
- {
196
- name: "exec_command",
197
- description: "Execute a shell command with cwd set to the daemon workspace. Enabled by default; environment is intentionally minimal unless the daemon is started with full env.",
198
- inputSchema: {
199
- type: "object",
200
- properties: {
201
- command: { type: "string" },
202
- timeout_seconds: { type: "integer", minimum: 1, maximum: 600, default: 120 },
203
- },
204
- required: ["command"],
205
- additionalProperties: true,
206
- },
207
- },
208
- ] as const;
118
+ type ToolDefinition = Record<string, unknown> & { name: string; availability?: string };
119
+
120
+ const allCatalogTools = toolCatalog as ToolDefinition[];
121
+ const serverInfoTool = publicTool(allCatalogTools.find((tool) => tool.name === "server_info")!);
122
+ const workspaceTools = allCatalogTools.filter((tool) => tool.name !== "server_info").map(publicTool);
209
123
 
210
124
  const MCP_INSTRUCTIONS = [
211
125
  "You are connected to a local workspace through machine-bridge-mcp.",
212
- "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.",
215
- "Prefer inspecting files before editing, make minimal changes, and report commands you ran.",
126
+ "The Cloudflare Worker authenticates and relays calls; file and command operations execute on the user's local runtime.",
127
+ "Relative paths use the configured workspace. Direct filesystem tools are workspace-scoped unless unrestricted paths are explicitly enabled.",
128
+ "run_process avoids shell parsing but is not an OS sandbox. exec_command is only exposed in shell mode and has the local user's authority.",
129
+ "Prefer read_file line ranges, edit_file, or apply_patch over whole-file replacement, and report commands that were run.",
216
130
  ].join("\n");
217
131
 
132
+ function publicTool(tool: ToolDefinition): ToolDefinition {
133
+ const { availability: _availability, ...definition } = tool;
134
+ return structuredClone(definition) as ToolDefinition;
135
+ }
136
+
218
137
  export class BridgeRoom extends DurableObject<BridgeEnv> {
219
138
  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>();
139
+ private oauthQueue: Promise<void> = Promise.resolve();
223
140
 
224
141
  constructor(ctx: DurableObjectState, env: BridgeEnv) {
225
142
  super(ctx, env);
226
143
  }
227
144
 
228
145
  async fetch(request: Request): Promise<Response> {
229
- const url = new URL(request.url);
230
146
  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
- }
147
+ const configuredOrigins = this.env.MBM_ALLOWED_ORIGINS ?? "";
148
+ if (!validateOrigin(request, base, configuredOrigins)) return json({ error: "origin_not_allowed" }, 403);
149
+ if (request.method === "OPTIONS" && request.headers.has("Origin")) {
150
+ return corsPreflight(request, base, configuredOrigins);
151
+ }
152
+ const response = await this.handleRequest(request, base);
153
+ return applyCors(response, request, base, configuredOrigins);
154
+ }
235
155
 
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) });
156
+ private async handleRequest(request: Request, base: string): Promise<Response> {
157
+ const url = new URL(request.url);
158
+ try {
159
+ if (url.pathname === "/") {
160
+ if (request.method !== "GET") return methodNotAllowed("GET");
161
+ return json({ ok: true, server: SERVER_NAME, version: SERVER_VERSION, mcp: `${base}/mcp` });
238
162
  }
239
163
  if (url.pathname === "/healthz") {
240
- return json({ ok: true, server: SERVER_NAME, version: SERVER_VERSION, daemon: this.daemonStatus(false), mcp_clients: this.mcpClientStatus(false) });
164
+ if (request.method !== "GET") return methodNotAllowed("GET");
165
+ return json({ ok: true, server: SERVER_NAME, version: SERVER_VERSION });
241
166
  }
242
167
  if (url.pathname === "/.well-known/mcp.json") {
168
+ if (request.method !== "GET") return methodNotAllowed("GET");
243
169
  return json(this.mcpMetadata(base));
244
170
  }
245
171
  if (
@@ -248,29 +174,52 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
248
174
  url.pathname === "/.well-known/openid-configuration" ||
249
175
  url.pathname === "/.well-known/openid-configuration/mcp"
250
176
  ) {
177
+ if (request.method !== "GET") return methodNotAllowed("GET");
251
178
  return json(this.authorizationServerMetadata(base));
252
179
  }
253
180
  if (url.pathname === "/.well-known/oauth-protected-resource" || url.pathname === "/.well-known/oauth-protected-resource/mcp") {
181
+ if (request.method !== "GET") return methodNotAllowed("GET");
254
182
  return json(this.protectedResourceMetadata(base));
255
183
  }
256
- if (url.pathname === "/oauth/register" && request.method === "POST") return await 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 await this.authorizeSubmit(request, base);
259
- if (url.pathname === "/oauth/token" && request.method === "POST") return await this.exchangeToken(request, base);
260
- if (url.pathname === "/daemon/ws") return await this.acceptDaemonWebSocket(request);
184
+ if (url.pathname === "/oauth/register") {
185
+ if (request.method !== "POST") return methodNotAllowed("POST");
186
+ return await this.registerClient(request);
187
+ }
188
+ if (url.pathname === "/oauth/authorize") {
189
+ if (request.method === "GET") return await this.authorizeGet(request, base);
190
+ if (request.method === "POST") return await this.authorizeSubmit(request, base);
191
+ return methodNotAllowed("GET, POST");
192
+ }
193
+ if (url.pathname === "/oauth/token") {
194
+ if (request.method !== "POST") return methodNotAllowed("POST");
195
+ return await this.exchangeToken(request, base);
196
+ }
197
+ if (url.pathname === "/daemon/ws") {
198
+ if (request.method !== "GET") return methodNotAllowed("GET");
199
+ return await this.acceptDaemonWebSocket(request);
200
+ }
261
201
  if (url.pathname === "/mcp") return await this.handleMcp(request, base);
262
- if (url.pathname === "/api/daemon/status") return json(this.daemonStatus(false));
263
- if (url.pathname === "/api/mcp/sampling") return await this.handleSamplingApi(request);
264
202
  return json({ error: "not_found" }, 404);
265
203
  } catch (error) {
266
204
  if (error instanceof HttpError) return json({ error: error.code, message: error.message }, error.status);
267
- console.error(JSON.stringify({ level: "error", message: "request_failed", path: url.pathname, error: errorMessage(error) }));
205
+ console.error(JSON.stringify({ level: "error", message: "request_failed", path: url.pathname, error_class: workerErrorClass(error) }));
268
206
  return json({ error: "internal_server_error" }, 500);
269
207
  }
270
208
  }
271
209
 
272
210
  async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
273
- const text = typeof message === "string" ? message : new TextDecoder().decode(message);
211
+ const size = typeof message === "string" ? new TextEncoder().encode(message).byteLength : message.byteLength;
212
+ if (size > MAX_DAEMON_MESSAGE_BYTES) {
213
+ try { ws.close(1009, "message too large"); } catch {}
214
+ return;
215
+ }
216
+ let text: string;
217
+ try {
218
+ text = typeof message === "string" ? message : new TextDecoder("utf-8", { fatal: true }).decode(message);
219
+ } catch {
220
+ try { ws.close(1007, "invalid UTF-8"); } catch {}
221
+ return;
222
+ }
274
223
  let body: Record<string, unknown>;
275
224
  try {
276
225
  body = JSON.parse(text) as Record<string, unknown>;
@@ -279,18 +228,52 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
279
228
  return;
280
229
  }
281
230
 
231
+ const socketAttachment = this.socketAttachment(ws);
232
+ if (!socketAttachment) {
233
+ try { ws.close(1008, "missing daemon attachment"); } catch {}
234
+ return;
235
+ }
236
+
237
+ if (socketAttachment.role === "expired") {
238
+ try { ws.close(1008, "expired daemon candidate"); } catch {}
239
+ return;
240
+ }
241
+
282
242
  if (body.type === "hello") {
283
- const attachment = this.daemonAttachment(ws);
284
- if (attachment) {
243
+ const previousDaemons = this.daemonSockets().filter((socket) => socket !== ws);
244
+ if (socketAttachment.role === "candidate") {
245
+ if (!isFreshDaemonCandidate(socketAttachment.connectedAt)) {
246
+ try { ws.close(1008, "stale daemon candidate"); } catch {}
247
+ await this.scheduleCandidateAlarm();
248
+ return;
249
+ }
250
+ }
251
+ const daemonPolicy = sanitizeDaemonPolicy(body.policy);
252
+ ws.serializeAttachment({
253
+ role: "daemon",
254
+ connectedAt: new Date().toISOString(),
255
+ policy: daemonPolicy,
256
+ tools: sanitizeDaemonTools(body.tools, daemonPolicy),
257
+ } satisfies DaemonAttachment);
258
+ await this.scheduleCandidateAlarm();
259
+ try {
260
+ ws.send(JSON.stringify({ type: "hello_ack", server: SERVER_NAME, version: SERVER_VERSION }));
261
+ } catch {
285
262
  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
- });
263
+ role: "expired",
264
+ connectedAt: socketAttachment.connectedAt,
265
+ } satisfies DaemonAttachment);
266
+ try { ws.close(1011, "daemon hello acknowledgement failed"); } catch {}
267
+ return;
292
268
  }
293
- ws.send(JSON.stringify({ type: "hello_ack", server: SERVER_NAME, version: SERVER_VERSION }));
269
+ for (const socket of previousDaemons) {
270
+ try { socket.close(1012, "replaced by authenticated daemon"); } catch {}
271
+ }
272
+ return;
273
+ }
274
+
275
+ if (socketAttachment.role !== "daemon") {
276
+ try { ws.close(1008, "daemon hello required"); } catch {}
294
277
  return;
295
278
  }
296
279
 
@@ -305,7 +288,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
305
288
  }
306
289
 
307
290
  const pending = this.pending.get(body.id);
308
- if (!pending) return;
291
+ if (!pending || pending.socket !== ws) return;
309
292
  clearTimeout(pending.timeout);
310
293
  this.pending.delete(body.id);
311
294
  if (body.ok === false) pending.reject(new Error(errorMessage(body.error)));
@@ -313,9 +296,11 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
313
296
  }
314
297
 
315
298
  async webSocketClose(ws: WebSocket): Promise<void> {
299
+ await this.scheduleCandidateAlarm();
316
300
  const attachment = this.daemonAttachment(ws);
317
- if (attachment?.role !== "daemon") return;
301
+ if (!attachment) return;
318
302
  for (const [id, pending] of this.pending) {
303
+ if (pending.socket !== ws) continue;
319
304
  clearTimeout(pending.timeout);
320
305
  pending.reject(new Error("daemon disconnected"));
321
306
  this.pending.delete(id);
@@ -323,41 +308,60 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
323
308
  }
324
309
 
325
310
  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);
311
+ if (request.method !== "POST") {
312
+ return new Response(request.method === "HEAD" ? null : JSON.stringify({ error: "mcp endpoint expects POST JSON-RPC" }), {
313
+ status: 405,
314
+ headers: { "content-type": "application/json; charset=utf-8", "allow": "POST", "cache-control": "no-store" },
315
+ });
316
+ }
328
317
 
329
- const auth = await this.verifyAccessToken(bearerToken(request), base);
330
- if (!auth) {
318
+ const authorized = await this.verifyAccessToken(bearerToken(request), base);
319
+ if (!authorized) {
331
320
  return new Response("OAuth bearer token required", {
332
321
  status: 401,
333
- headers: { "WWW-Authenticate": `Bearer resource_metadata="${base}/.well-known/oauth-protected-resource/mcp"` },
322
+ headers: {
323
+ "WWW-Authenticate": `Bearer resource_metadata="${base}/.well-known/oauth-protected-resource/mcp"`,
324
+ "cache-control": "no-store",
325
+ "content-type": "text/plain; charset=utf-8",
326
+ "x-content-type-options": "nosniff",
327
+ },
334
328
  });
335
329
  }
336
330
 
337
- if (request.method === "GET") return this.openMcpSseStream(request, auth);
338
-
339
331
  const body = await parseJsonRequest(request, this.bodyLimitBytes());
340
- if (isJsonRpcResponse(body)) {
341
- this.handleClientJsonRpcResponse(body);
342
- return new Response(null, { status: 202 });
343
- }
332
+ if (isJsonRpcResponse(body)) return new Response(null, { status: 202 });
344
333
  if (!isJsonRpcRequest(body)) return json(rpcError(null, -32600, "Invalid JSON-RPC request"), 400);
345
- const response = await this.dispatchJsonRpc(body, base, auth);
334
+ const protocolError = validateProtocolVersionHeader(request, body);
335
+ if (protocolError) return json(protocolError, 400);
336
+ const response = await this.dispatchJsonRpc(body, base, authorized);
346
337
  if (response === null) return new Response(null, { status: 202 });
347
338
  return json(response);
348
339
  }
349
340
 
350
- private async dispatchJsonRpc(request: JsonRpcRequest, base: string, auth: AuthenticatedClient): Promise<Record<string, unknown> | null> {
341
+ private async dispatchJsonRpc(request: JsonRpcRequest, base: string, authorized: AuthorizedToken): Promise<Record<string, unknown> | null> {
351
342
  if (request.method === "initialize") {
352
- this.recordClientInitialize(auth, request.params);
343
+ const requested = asObject(request.params).protocolVersion;
344
+ const protocolVersion = typeof requested === "string" && MCP_SUPPORTED_PROTOCOL_VERSIONS.includes(requested as typeof MCP_SUPPORTED_PROTOCOL_VERSIONS[number])
345
+ ? requested
346
+ : MCP_PROTOCOL_VERSION;
353
347
  return rpcResult(request.id, {
354
- protocolVersion: MCP_PROTOCOL_VERSION,
355
- capabilities: { tools: { listChanged: false } },
356
- serverInfo: { name: SERVER_NAME, version: SERVER_VERSION },
348
+ protocolVersion,
349
+ capabilities: { tools: { listChanged: false }, logging: {} },
350
+ serverInfo: {
351
+ name: SERVER_NAME,
352
+ title: "Machine Bridge MCP",
353
+ version: SERVER_VERSION,
354
+ description: "Workspace-scoped local coding tools over authenticated remote relay.",
355
+ },
357
356
  instructions: MCP_INSTRUCTIONS,
358
357
  });
359
358
  }
360
359
  if (request.method === "notifications/initialized") return null;
360
+ if (request.method === "notifications/cancelled") {
361
+ this.cancelClientRequest(clientRequestKey(authorized, asObject(request.params).requestId));
362
+ return null;
363
+ }
364
+ if (request.method === "logging/setLevel") return rpcResult(request.id, {});
361
365
  if (request.method === "ping") return rpcResult(request.id, {});
362
366
  if (request.method === "tools/list") return rpcResult(request.id, { tools: this.allTools() });
363
367
  if (request.method === "tools/call") {
@@ -365,7 +369,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
365
369
  const name = requiredString(params, "name");
366
370
  const args = asObject(params.arguments);
367
371
  try {
368
- const result = await this.callTool(name, args, base);
372
+ const result = await this.callTool(name, args, base, clientRequestKey(authorized, request.id));
369
373
  return rpcResult(request.id, textToolResult(result));
370
374
  } catch (error) {
371
375
  return rpcResult(request.id, textToolResult({ error: errorMessage(error) }, true));
@@ -374,7 +378,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
374
378
  return rpcError(request.id, -32601, `Method not found: ${request.method}`);
375
379
  }
376
380
 
377
- private async callTool(name: string, args: Record<string, unknown>, base: string): Promise<unknown> {
381
+ private async callTool(name: string, args: Record<string, unknown>, base: string, requestKey?: string): Promise<unknown> {
378
382
  if (name === "server_info") {
379
383
  return {
380
384
  name: SERVER_NAME,
@@ -382,256 +386,168 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
382
386
  mcp_url: `${base}/mcp`,
383
387
  oauth: this.authorizationServerMetadata(base),
384
388
  daemon: this.daemonStatus(true),
385
- mcp_clients: this.mcpClientStatus(true),
386
389
  tools: this.allTools().map((tool) => tool.name),
387
390
  };
388
391
  }
389
392
  if (workspaceTools.some((tool) => tool.name === name)) {
390
393
  if (!this.daemonToolEnabled(name)) throw new Error(`tool disabled by local daemon policy: ${name}`);
391
- return this.callDaemonTool(name, args);
394
+ return this.callDaemonTool(name, args, requestKey);
392
395
  }
393
396
  throw new Error(`unknown tool: ${name}`);
394
397
  }
395
398
 
396
- private async callDaemonTool(name: string, args: Record<string, unknown>): Promise<unknown> {
399
+ private async callDaemonTool(name: string, args: Record<string, unknown>, requestKey?: string): Promise<unknown> {
397
400
  const socket = this.daemonSockets()[0];
398
401
  if (!socket) throw new Error("local daemon is not connected; keep the CLI start command running");
402
+ if (this.pending.size >= MAX_PENDING_CALLS) throw new Error("too many concurrent daemon tool calls");
403
+ if (requestKey && [...this.pending.values()].some((pending) => pending.clientRequestKey === requestKey)) {
404
+ throw new Error("duplicate in-flight JSON-RPC request id for this access token");
405
+ }
399
406
  const id = randomToken("call");
400
407
  const timeoutMs = daemonToolTimeoutMs(name, args);
401
408
  return await new Promise((resolve, reject) => {
402
409
  const timeout = setTimeout(() => {
403
410
  this.pending.delete(id);
411
+ try { socket.send(JSON.stringify({ type: "cancel_call", id })); } catch {}
404
412
  reject(new Error(`daemon tool timed out: ${name}`));
405
413
  }, 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 }));
414
+ this.pending.set(id, { resolve, reject, timeout, socket, clientRequestKey: requestKey });
415
+ try {
416
+ socket.send(JSON.stringify({ type: "tool_call", id, tool: name, arguments: args, timeout_ms: timeoutMs }));
417
+ } catch (error) {
418
+ clearTimeout(timeout);
419
+ this.pending.delete(id);
420
+ reject(new Error(`failed to send daemon tool call: ${errorMessage(error)}`));
421
+ }
408
422
  });
409
423
  }
410
424
 
425
+ private cancelClientRequest(requestKey?: string): void {
426
+ if (!requestKey) return;
427
+ for (const [id, pending] of this.pending) {
428
+ if (pending.clientRequestKey !== requestKey) continue;
429
+ clearTimeout(pending.timeout);
430
+ this.pending.delete(id);
431
+ try { pending.socket.send(JSON.stringify({ type: "cancel_call", id })); } catch {}
432
+ pending.reject(new Error("tool call cancelled by client"));
433
+ }
434
+ }
435
+
411
436
  private async acceptDaemonWebSocket(request: Request): Promise<Response> {
412
437
  if (request.headers.get("Upgrade")?.toLowerCase() !== "websocket") return new Response("Expected Upgrade: websocket", { status: 426 });
413
438
  const expected = this.env.DAEMON_SHARED_SECRET ?? "";
414
439
  const supplied = request.headers.get("X-Bridge-Token") ?? "";
415
440
  if (!expected || !(await safeEqual(supplied, expected))) return new Response("Unauthorized daemon", { status: 401 });
416
441
 
417
- for (const socket of this.daemonSockets()) {
418
- try {
419
- socket.close(1012, "replaced by newer daemon");
420
- } catch {
421
- // Ignore stale sockets.
422
- }
442
+ for (const socket of this.nonDaemonSockets()) {
443
+ try { socket.close(1012, "replaced by newer daemon candidate"); } catch {}
423
444
  }
424
445
 
425
446
  const pair = new WebSocketPair();
426
447
  const [client, server] = Object.values(pair) as [WebSocket, WebSocket];
427
448
  this.ctx.acceptWebSocket(server);
428
449
  server.serializeAttachment({
429
- role: "daemon",
450
+ role: "candidate",
430
451
  connectedAt: new Date().toISOString(),
431
- daemonId: request.headers.get("X-Daemon-Id") || randomToken("daemon"),
432
452
  } satisfies DaemonAttachment);
453
+ await this.ctx.storage.setAlarm(Date.now() + DAEMON_HELLO_TIMEOUT_MS);
433
454
  server.send(JSON.stringify({ type: "welcome", server: SERVER_NAME, version: SERVER_VERSION }));
434
455
  return new Response(null, { status: 101, webSocket: client });
435
456
  }
436
457
 
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
- });
458
+ private allTools(): Array<Record<string, unknown>> {
459
+ const advertised = this.daemonAdvertisedTools();
460
+ const localTools = workspaceTools.filter((tool) => advertised.has(tool.name));
461
+ return [serverInfoTool, ...localTools].map((tool) => structuredClone(tool));
473
462
  }
474
463
 
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
- }
464
+ private daemonToolEnabled(name: string): boolean {
465
+ return this.daemonAdvertisedTools().has(name);
495
466
  }
496
467
 
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);
468
+ private daemonAdvertisedTools(): Set<string> {
469
+ const socket = this.daemonSockets()[0];
470
+ if (!socket) return new Set();
471
+ const attachment = this.daemonAttachment(socket);
472
+ if (!attachment?.tools) return new Set();
473
+ return new Set(attachment.tools);
523
474
  }
524
475
 
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)}`));
476
+ private daemonSockets(): WebSocket[] {
477
+ return this.ctx.getWebSockets()
478
+ .filter((socket) => this.daemonAttachment(socket) && socket.readyState === WebSocket.OPEN)
479
+ .sort((left, right) => {
480
+ const leftTime = Date.parse(this.daemonAttachment(left)?.connectedAt ?? "") || 0;
481
+ const rightTime = Date.parse(this.daemonAttachment(right)?.connectedAt ?? "") || 0;
482
+ return rightTime - leftTime;
542
483
  });
543
- });
544
484
  }
545
485
 
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);
486
+ private candidateSockets(): WebSocket[] {
487
+ return this.ctx.getWebSockets().filter((socket) => this.socketAttachment(socket)?.role === "candidate" && socket.readyState === WebSocket.OPEN);
562
488
  }
563
489
 
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),
490
+ private nonDaemonSockets(): WebSocket[] {
491
+ return this.ctx.getWebSockets().filter((socket) => {
492
+ const role = this.socketAttachment(socket)?.role;
493
+ return role !== "daemon" && socket.readyState === WebSocket.OPEN;
575
494
  });
576
495
  }
577
496
 
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;
497
+ private socketAttachment(socket: WebSocket): DaemonAttachment | undefined {
498
+ const raw = socket.deserializeAttachment();
499
+ if (!raw || typeof raw !== "object") return undefined;
500
+ const candidate = raw as Partial<DaemonAttachment>;
501
+ if (candidate.role !== "candidate" && candidate.role !== "expired" && candidate.role !== "daemon") return undefined;
502
+ const policy = sanitizeDaemonPolicy(candidate.policy);
592
503
  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
- })),
504
+ role: candidate.role,
505
+ connectedAt: sanitizeMetadataText(candidate.connectedAt, 64) ?? "",
506
+ policy,
507
+ tools: sanitizeDaemonTools(candidate.tools, policy),
600
508
  };
601
509
  }
602
510
 
603
- private allTools(): Array<Record<string, unknown>> {
604
- const advertised = this.daemonAdvertisedTools();
605
- const localTools = advertised
606
- ? workspaceTools.filter((tool) => advertised.has(tool.name))
607
- : workspaceTools;
608
- return [serverInfoTool, ...localTools].map((tool) => ({ ...tool }));
609
- }
610
-
611
- private daemonToolEnabled(name: string): boolean {
612
- const advertised = this.daemonAdvertisedTools();
613
- return !advertised || advertised.has(name);
614
- }
615
-
616
- private daemonAdvertisedTools(): Set<string> | null {
617
- const socket = this.daemonSockets()[0];
618
- const attachment = socket ? this.daemonAttachment(socket) : undefined;
619
- if (!attachment?.tools?.length) return null;
620
- return new Set(attachment.tools);
511
+ private daemonAttachment(socket: WebSocket): DaemonAttachment | undefined {
512
+ const attachment = this.socketAttachment(socket);
513
+ return attachment?.role === "daemon" ? attachment : undefined;
621
514
  }
622
515
 
623
- private daemonSockets(): WebSocket[] {
624
- return this.ctx.getWebSockets().filter((socket) => {
625
- const attachment = this.daemonAttachment(socket);
626
- return attachment?.role === "daemon" && socket.readyState === WebSocket.OPEN;
627
- });
516
+ async alarm(): Promise<void> {
517
+ const now = Date.now();
518
+ let nextDeadline = Number.POSITIVE_INFINITY;
519
+ for (const socket of this.candidateSockets()) {
520
+ const attachment = this.socketAttachment(socket);
521
+ const connectedAt = Date.parse(attachment?.connectedAt ?? "");
522
+ const deadline = connectedAt + DAEMON_HELLO_TIMEOUT_MS;
523
+ if (!Number.isFinite(connectedAt) || deadline <= now) {
524
+ socket.serializeAttachment({
525
+ role: "expired",
526
+ connectedAt: attachment?.connectedAt ?? new Date(0).toISOString(),
527
+ } satisfies DaemonAttachment);
528
+ try { socket.send(JSON.stringify({ type: "error", error: "daemon_hello_timeout" })); } catch {}
529
+ try { socket.close(1008, "daemon hello timeout"); } catch {}
530
+ continue;
531
+ }
532
+ nextDeadline = Math.min(nextDeadline, deadline);
533
+ }
534
+ if (Number.isFinite(nextDeadline)) await this.ctx.storage.setAlarm(nextDeadline);
535
+ else await this.ctx.storage.deleteAlarm();
628
536
  }
629
537
 
630
- private daemonAttachment(socket: WebSocket): DaemonAttachment | undefined {
631
- const raw = socket.deserializeAttachment();
632
- if (!raw || typeof raw !== "object") return undefined;
633
- const candidate = raw as Partial<DaemonAttachment>;
634
- return candidate.role === "daemon" ? (candidate as DaemonAttachment) : undefined;
538
+ private async scheduleCandidateAlarm(): Promise<void> {
539
+ let nextDeadline = Number.POSITIVE_INFINITY;
540
+ for (const socket of this.candidateSockets()) {
541
+ const attachment = this.socketAttachment(socket);
542
+ const connectedAt = Date.parse(attachment?.connectedAt ?? "");
543
+ if (!Number.isFinite(connectedAt)) {
544
+ try { socket.close(1008, "invalid daemon candidate timestamp"); } catch {}
545
+ continue;
546
+ }
547
+ nextDeadline = Math.min(nextDeadline, connectedAt + DAEMON_HELLO_TIMEOUT_MS);
548
+ }
549
+ if (Number.isFinite(nextDeadline)) await this.ctx.storage.setAlarm(Math.max(Date.now(), nextDeadline));
550
+ else await this.ctx.storage.deleteAlarm();
635
551
  }
636
552
 
637
553
  private daemonStatus(detail: boolean): Record<string, unknown> {
@@ -647,8 +563,6 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
647
563
  if (!detail) return base;
648
564
  return {
649
565
  ...base,
650
- workspace_hash: attachment?.workspaceHash ?? null,
651
- workspace_name: attachment?.workspaceName ?? null,
652
566
  policy: attachment?.policy ?? null,
653
567
  tools,
654
568
  };
@@ -659,10 +573,9 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
659
573
  name: SERVER_NAME,
660
574
  version: SERVER_VERSION,
661
575
  protocolVersion: MCP_PROTOCOL_VERSION,
576
+ protocolVersions: [...MCP_SUPPORTED_PROTOCOL_VERSIONS],
662
577
  transport: { type: "streamable-http", url: `${base}/mcp` },
663
- auth: { type: "oauth" },
664
- tools: this.allTools().map((tool) => tool.name),
665
- instructions: MCP_INSTRUCTIONS,
578
+ auth: { type: "oauth", authorization_servers: [base] },
666
579
  };
667
580
  }
668
581
 
@@ -691,7 +604,11 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
691
604
  }
692
605
 
693
606
  private async oauthStore(): Promise<OAuthStore> {
694
- const store = (await this.ctx.storage.get<OAuthStore>("oauth")) ?? { clients: {}, codes: {}, tokens: {} };
607
+ const store = (await this.ctx.storage.get<OAuthStore>("oauth")) ?? { clients: {}, codes: {}, tokens: {}, auth_failures: {} };
608
+ store.clients ||= {};
609
+ store.codes ||= {};
610
+ store.tokens ||= {};
611
+ store.auth_failures ||= {};
695
612
  const now = Math.floor(Date.now() / 1000);
696
613
  let changed = false;
697
614
  for (const [code, value] of Object.entries(store.codes)) {
@@ -706,166 +623,259 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
706
623
  changed = true;
707
624
  }
708
625
  }
626
+ for (const [identity, value] of Object.entries(store.auth_failures)) {
627
+ if (!identity.startsWith("hmac-sha256:") || value.last_attempt + AUTH_BLOCK_SECONDS <= now) {
628
+ delete store.auth_failures[identity];
629
+ changed = true;
630
+ }
631
+ }
632
+ const activeClientIds = new Set([
633
+ ...Object.values(store.codes).map((value) => value.client_id),
634
+ ...Object.values(store.tokens).map((value) => value.client_id),
635
+ ]);
636
+ for (const [clientId, client] of Object.entries(store.clients)) {
637
+ if (client.registration_identity && !client.registration_identity.startsWith("hmac-sha256:")) {
638
+ delete client.registration_identity;
639
+ changed = true;
640
+ }
641
+ if (!client.last_used_at) {
642
+ client.last_used_at = client.created_at;
643
+ changed = true;
644
+ }
645
+ const ttl = client.last_used_at === client.created_at ? OAUTH_UNUSED_CLIENT_TTL_SECONDS : OAUTH_CLIENT_IDLE_TTL_SECONDS;
646
+ if (!activeClientIds.has(clientId) && client.last_used_at + ttl <= now) {
647
+ delete store.clients[clientId];
648
+ changed = true;
649
+ }
650
+ }
709
651
  if (changed) await this.ctx.storage.put("oauth", store);
710
652
  return store;
711
653
  }
712
654
 
713
655
  private async registerClient(request: Request): Promise<Response> {
714
- const body = await parseRequestBody(request, this.bodyLimitBytes());
656
+ const body = await parseRequestBody(request, OAUTH_BODY_LIMIT_BYTES);
715
657
  const redirectUris = body.redirect_uris;
716
658
  if (!Array.isArray(redirectUris) || redirectUris.length === 0) {
717
659
  return json({ error: "invalid_client_metadata", error_description: "redirect_uris must be a non-empty array" }, 400);
718
660
  }
719
- if (redirectUris.length > 20) {
720
- return json({ error: "invalid_client_metadata", error_description: "redirect_uris must contain at most 20 entries" }, 400);
661
+ if (redirectUris.length > 5) {
662
+ return json({ error: "invalid_client_metadata", error_description: "redirect_uris must contain at most 5 entries" }, 400);
721
663
  }
722
- const normalized = redirectUris.map((item) => String(item));
723
- if (normalized.some((item) => item.length > 2048)) {
664
+ const normalized = [...new Set(redirectUris.map((item) => String(item)))];
665
+ if (normalized.some((item) => item.length > 1024)) {
724
666
  return json({ error: "invalid_client_metadata", error_description: "redirect_uri is too long" }, 400);
725
667
  }
726
668
  if (!normalized.every(isAllowedRedirectUri)) {
727
669
  return json({ error: "invalid_client_metadata", error_description: "redirect_uris must be https or local http" }, 400);
728
670
  }
729
671
 
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,
672
+ return this.withOAuthLock(async () => {
673
+ const store = await this.oauthStore();
674
+ const registrationIdentity = await authorizationIdentity(request, this.identityKey());
675
+ const identityClientCount = Object.values(store.clients).filter((client) => client.registration_identity === registrationIdentity).length;
676
+ if (identityClientCount >= MAX_OAUTH_CLIENTS_PER_IDENTITY) {
677
+ return json({ error: "too_many_requests", error_description: "client registration limit reached for this source" }, 429);
678
+ }
679
+ if (Object.keys(store.clients).length >= MAX_OAUTH_CLIENTS) {
680
+ return json({ error: "temporarily_unavailable", error_description: "client registry is full; remove stale state or retry after inactive clients expire" }, 503);
681
+ }
682
+ const now = Math.floor(Date.now() / 1000);
683
+ const client: OAuthClient = {
684
+ client_id: randomToken("mcp_client"),
685
+ client_name: normalizeDisplayText(stringOrUndefined(body.client_name) ?? "MCP Client", 128),
686
+ redirect_uris: normalized,
687
+ created_at: now,
688
+ last_used_at: now,
689
+ registration_identity: registrationIdentity,
690
+ };
691
+ store.clients[client.client_id] = client;
692
+ await this.ctx.storage.put("oauth", store);
693
+ return json({
694
+ client_id: client.client_id,
695
+ client_name: client.client_name,
696
+ redirect_uris: client.redirect_uris,
697
+ grant_types: ["authorization_code"],
698
+ response_types: ["code"],
699
+ token_endpoint_auth_method: "none",
700
+ client_id_issued_at: client.created_at,
701
+ });
702
+ });
703
+ }
704
+
705
+ private async authorizeGet(request: Request, base: string): Promise<Response> {
706
+ const body = searchParamsObject(new URL(request.url).searchParams);
707
+ return this.withOAuthLock(async () => {
708
+ const store = await this.oauthStore();
709
+ const validation = validateAuthorizationRequest(body, base, store);
710
+ if ("error" in validation) {
711
+ return this.authorizePage(request, base, validation.error, body, validation.status, undefined, false);
712
+ }
713
+ return this.authorizePage(request, base, "", body, 200, validation.value, true);
748
714
  });
749
715
  }
750
716
 
751
- private authorizePage(request: Request, base: string, error = "", submitted?: Record<string, unknown>): Response {
717
+ private authorizePage(
718
+ request: Request,
719
+ base: string,
720
+ error = "",
721
+ submitted?: Record<string, unknown>,
722
+ status = 200,
723
+ authorization?: ValidatedAuthorization,
724
+ allowSubmit = true,
725
+ ): Response {
752
726
  const url = new URL(request.url);
753
727
  const sourceEntries = submitted ? Object.entries(submitted) : searchParamsEntries(url.searchParams);
754
728
  const hidden = sourceEntries
755
- .filter(([key]) => key !== "login_token")
729
+ .filter(([key]) => AUTHORIZATION_FIELDS.has(key))
756
730
  .map(([key, value]) => `<input type="hidden" name="${escapeHtml(key)}" value="${escapeHtml(String(value))}">`)
757
731
  .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>` : "";
732
+ const resource = authorization?.requestedResource ?? String(submitted?.resource ?? url.searchParams.get("resource") ?? `${base}/mcp`);
733
+ const clientBlock = authorization
734
+ ? `<p><strong>Client:</strong> ${escapeHtml(authorization.client.client_name)}</p>
735
+ <p><strong>Redirect URI:</strong> <code>${escapeHtml(authorization.redirectUri)}</code></p>`
736
+ : "";
737
+ const errorBlock = error ? `<p role="alert" style="color:#b91c1c">${escapeHtml(error)}</p>` : "";
738
+ const form = allowSubmit
739
+ ? `<form method="post" action="/oauth/authorize">
740
+ ${hidden}
741
+ <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>
742
+ <p><button type="submit">Authorize</button></p>
743
+ </form>`
744
+ : "<p>Authorization cannot continue. Return to the MCP client and start the connection again.</p>";
760
745
  return html(`<!doctype html>
761
746
  <html>
762
- <head><meta charset="utf-8"><title>Authorize ${SERVER_NAME}</title></head>
747
+ <head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Authorize ${SERVER_NAME}</title></head>
763
748
  <body style="font-family: system-ui, sans-serif; max-width: 640px; margin: 48px auto; line-height: 1.5; padding: 0 16px;">
764
749
  <h1>Authorize ${SERVER_NAME}</h1>
765
- <p>Enter the MCP connection password printed by <code>machine-bridge-mcp start</code>.</p>
750
+ <p>Only continue if you initiated this MCP connection and recognize the client and redirect URI below.</p>
751
+ ${clientBlock}
766
752
  <p><strong>Resource:</strong> <code>${escapeHtml(resource)}</code></p>
767
753
  ${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>
754
+ ${form}
773
755
  </body>
774
- </html>`);
756
+ </html>`, status);
775
757
  }
776
758
 
777
759
  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
- }
760
+ const body = await parseRequestBody(request, OAUTH_BODY_LIMIT_BYTES);
761
+ return this.withOAuthLock(async () => {
762
+ const store = await this.oauthStore();
763
+ const validation = validateAuthorizationRequest(body, base, store);
764
+ if ("error" in validation) {
765
+ return this.authorizePage(request, base, validation.error, body, validation.status, undefined, false);
766
+ }
767
+ const { client, clientId, redirectUri, codeChallenge, requestedResource, scope, state } = validation.value;
768
+ const now = Math.floor(Date.now() / 1000);
769
+ const identity = await authorizationIdentity(request, this.identityKey());
770
+ const failure = store.auth_failures[identity];
771
+ if (failure?.blocked_until > now) {
772
+ return this.authorizePage(request, base, "Too many failed attempts. Try again later.", body, 429, validation.value);
773
+ }
783
774
 
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);
775
+ const expectedLogin = this.env.MCP_OAUTH_PASSWORD ?? "";
776
+ if (!expectedLogin || !(await safeEqual(String(body.login_token ?? ""), expectedLogin))) {
777
+ recordAuthorizationFailure(store, identity, now);
778
+ pruneAuthFailures(store, MAX_AUTH_FAILURE_IDENTITIES);
779
+ await this.ctx.storage.put("oauth", store);
780
+ const status = store.auth_failures[identity]?.blocked_until > now ? 429 : 401;
781
+ return this.authorizePage(request, base, "Invalid connection password.", body, status, validation.value);
782
+ }
783
+ delete store.auth_failures[identity];
784
+ client.last_used_at = now;
785
+
786
+ const code = randomToken("mcp_code");
787
+ store.codes[code] = {
788
+ client_id: clientId,
789
+ redirect_uri: redirectUri,
790
+ code_challenge: codeChallenge,
791
+ scope,
792
+ resource: requestedResource,
793
+ expires_at: now + 300,
794
+ };
795
+ pruneClientRecordByExpiry(store.codes, clientId, MAX_CODES_PER_CLIENT);
796
+ pruneRecordByExpiry(store.codes, MAX_OAUTH_CODES);
797
+ await this.ctx.storage.put("oauth", store);
810
798
 
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);
799
+ const params = new URLSearchParams({ code });
800
+ if (state) params.set("state", state);
801
+ return oauthRedirect(`${redirectUri}${redirectUri.includes("?") ? "&" : "?"}${params.toString()}`);
802
+ });
814
803
  }
815
804
 
816
805
  private async exchangeToken(request: Request, base: string): Promise<Response> {
817
- const body = await parseRequestBody(request, this.bodyLimitBytes());
806
+ const body = await parseRequestBody(request, OAUTH_BODY_LIMIT_BYTES);
818
807
  if (String(body.grant_type ?? "") !== "authorization_code") return json({ error: "unsupported_grant_type" }, 400);
819
808
 
820
809
  const code = String(body.code ?? "");
821
810
  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))) {
811
+ if (!/^[A-Za-z0-9._~-]{43,128}$/.test(verifier)) return json({ error: "invalid_grant", error_description: "invalid code_verifier" }, 400);
812
+ return this.withOAuthLock(async () => {
813
+ const store = await this.oauthStore();
814
+ const record = store.codes[code];
815
+ if (!record) return json({ error: "invalid_grant" }, 400);
816
+ if (String(body.client_id ?? "") !== record.client_id || String(body.redirect_uri ?? "") !== record.redirect_uri) {
817
+ return json({ error: "invalid_grant", error_description: "client or redirect mismatch" }, 400);
818
+ }
819
+ if (String(body.resource ?? record.resource) !== record.resource) {
820
+ return json({ error: "invalid_target", error_description: "resource mismatch" }, 400);
821
+ }
822
+ if (!(await safeEqual(await pkceS256(verifier), record.code_challenge))) {
823
+ return json({ error: "invalid_grant", error_description: "invalid code_verifier" }, 400);
824
+ }
825
+
826
+ const tokenVersion = this.env.OAUTH_TOKEN_VERSION ?? "";
827
+ if (!tokenVersion) return json({ error: "server_error", error_description: "OAuth token version is not configured" }, 503);
828
+ delete store.codes[code];
829
+ const accessToken = randomToken("mcp_at");
830
+ store.tokens[`sha256:${await sha256Hex(accessToken)}`] = {
831
+ client_id: record.client_id,
832
+ scope: record.scope,
833
+ resource: `${base}/mcp`,
834
+ version: tokenVersion,
835
+ expires_at: Math.floor(Date.now() / 1000) + TOKEN_TTL_SECONDS,
836
+ };
837
+ pruneClientRecordByExpiry(store.tokens, record.client_id, MAX_TOKENS_PER_CLIENT);
838
+ pruneRecordByExpiry(store.tokens, MAX_OAUTH_TOKENS);
838
839
  await this.ctx.storage.put("oauth", store);
839
- return json({ error: "invalid_grant", error_description: "invalid code_verifier" }, 400);
840
- }
840
+ return json({ access_token: accessToken, token_type: "Bearer", expires_in: TOKEN_TTL_SECONDS, scope: record.scope });
841
+ });
842
+ }
841
843
 
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 });
844
+ private async verifyAccessToken(token: string, base: string): Promise<AuthorizedToken | null> {
845
+ return this.withOAuthLock(async () => {
846
+ if (!token) return null;
847
+ const store = await this.oauthStore();
848
+ const key = `sha256:${await sha256Hex(token)}`;
849
+ const record = store.tokens[key];
850
+ if (!record) return null;
851
+ if (record.expires_at <= Math.floor(Date.now() / 1000)) {
852
+ delete store.tokens[key];
853
+ await this.ctx.storage.put("oauth", store);
854
+ return null;
855
+ }
856
+ const currentVersion = this.env.OAUTH_TOKEN_VERSION ?? "";
857
+ if (!record.version || !currentVersion || !(await safeEqual(record.version, currentVersion))) return null;
858
+ if (record.resource !== `${base}/mcp`) return null;
859
+ return { tokenKey: key, clientId: record.client_id };
860
+ });
852
861
  }
853
862
 
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;
863
+ private async withOAuthLock<T>(callback: () => Promise<T>): Promise<T> {
864
+ const previous = this.oauthQueue;
865
+ let release = () => {};
866
+ this.oauthQueue = new Promise<void>((resolve) => { release = resolve; });
867
+ await previous;
868
+ try {
869
+ return await callback();
870
+ } finally {
871
+ release();
864
872
  }
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 };
873
+ }
874
+
875
+ private identityKey(): string {
876
+ const key = this.env.OAUTH_TOKEN_VERSION || this.env.DAEMON_SHARED_SECRET || this.env.MCP_OAUTH_PASSWORD;
877
+ if (!key) throw new HttpError(503, "server_not_configured", "OAuth identity key is not configured");
878
+ return key;
869
879
  }
870
880
 
871
881
  private bodyLimitBytes(): number {
@@ -885,14 +895,20 @@ export default {
885
895
  function isJsonRpcRequest(value: unknown): value is JsonRpcRequest {
886
896
  if (!value || typeof value !== "object" || Array.isArray(value)) return false;
887
897
  const candidate = value as Record<string, unknown>;
888
- return candidate.jsonrpc === JSONRPC_VERSION && typeof candidate.method === "string";
898
+ if (candidate.jsonrpc !== JSONRPC_VERSION || typeof candidate.method !== "string" || !candidate.method.trim() || candidate.method.length > 256) return false;
899
+ if ("id" in candidate && !isJsonRpcId(candidate.id)) return false;
900
+ return true;
901
+ }
902
+
903
+ function isJsonRpcId(value: unknown): value is JsonRpcId {
904
+ return value === null || typeof value === "string" || (typeof value === "number" && Number.isFinite(value));
889
905
  }
890
906
 
891
907
  function isJsonRpcResponse(value: unknown): boolean {
892
908
  if (!value || typeof value !== "object" || Array.isArray(value)) return false;
893
909
  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;
910
+ if (candidate.jsonrpc !== JSONRPC_VERSION || !("id" in candidate) || !isJsonRpcId(candidate.id) || typeof candidate.method === "string") return false;
911
+ return ("result" in candidate) !== ("error" in candidate);
896
912
  }
897
913
 
898
914
  function rpcResult(id: JsonRpcId | undefined, result: unknown): Record<string, unknown> | null {
@@ -900,59 +916,125 @@ function rpcResult(id: JsonRpcId | undefined, result: unknown): Record<string, u
900
916
  return { jsonrpc: JSONRPC_VERSION, id, result };
901
917
  }
902
918
 
903
- function rpcError(id: JsonRpcId | undefined, code: number, message: string): Record<string, unknown> {
904
- return { jsonrpc: JSONRPC_VERSION, id: id ?? null, error: { code, message } };
919
+ function rpcError(id: JsonRpcId | undefined, code: number, message: string, data?: unknown): Record<string, unknown> {
920
+ const error: Record<string, unknown> = { code, message };
921
+ if (data !== undefined) error.data = data;
922
+ return { jsonrpc: JSONRPC_VERSION, id: id ?? null, error };
905
923
  }
906
924
 
907
925
  function textToolResult(value: unknown, isError = false): Record<string, unknown> {
908
- return { content: [{ type: "text", text: typeof value === "string" ? value : JSON.stringify(value, null, 2) }], isError };
926
+ const special = asObject(value).$mcp;
927
+ if (special && typeof special === "object" && !Array.isArray(special)) {
928
+ const specialObject = special as Record<string, unknown>;
929
+ if (Array.isArray(specialObject.content)) {
930
+ const result: Record<string, unknown> = { content: specialObject.content, isError };
931
+ if (specialObject.structuredContent && typeof specialObject.structuredContent === "object" && !Array.isArray(specialObject.structuredContent)) {
932
+ result.structuredContent = specialObject.structuredContent;
933
+ }
934
+ return result;
935
+ }
936
+ }
937
+ const result: Record<string, unknown> = {
938
+ content: [{ type: "text", text: typeof value === "string" ? value : JSON.stringify(value, null, 2) }],
939
+ isError,
940
+ };
941
+ if (value && typeof value === "object" && !Array.isArray(value)) result.structuredContent = value;
942
+ return result;
909
943
  }
910
944
 
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;
945
+ function methodNotAllowed(allow: string): Response {
946
+ return new Response(JSON.stringify({ error: "method_not_allowed" }), {
947
+ status: 405,
948
+ headers: {
949
+ allow,
950
+ "cache-control": "no-store",
951
+ "content-type": "application/json; charset=utf-8",
952
+ "x-content-type-options": "nosniff",
953
+ },
954
+ });
955
+ }
956
+
957
+ function oauthRedirect(location: string): Response {
958
+ return new Response(null, {
959
+ status: 302,
960
+ headers: {
961
+ location,
962
+ "cache-control": "no-store",
963
+ "referrer-policy": "no-referrer",
964
+ "x-content-type-options": "nosniff",
965
+ },
966
+ });
967
+ }
968
+
969
+ function isFreshDaemonCandidate(connectedAt: string): boolean {
970
+ const timestamp = Date.parse(connectedAt);
971
+ if (!Number.isFinite(timestamp)) return false;
972
+ const age = Date.now() - timestamp;
973
+ return age >= 0 && age <= DAEMON_HELLO_TIMEOUT_MS;
924
974
  }
925
975
 
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")));
976
+ function sanitizeMetadataText(value: unknown, maxLength: number): string | undefined {
977
+ if (typeof value !== "string") return undefined;
978
+ const normalized = value.replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim();
979
+ return normalized ? normalized.slice(0, maxLength) : undefined;
933
980
  }
934
981
 
935
- async function writeSseComment(writer: WritableStreamDefaultWriter<Uint8Array>, value: string): Promise<void> {
936
- await writer.write(new TextEncoder().encode(`: ${sseLine(value)}\n\n`));
982
+
983
+ function sanitizeDaemonTools(value: unknown, policy: Record<string, unknown>): string[] {
984
+ if (!Array.isArray(value)) return [];
985
+ const definitions = new Map(allCatalogTools.map((tool) => [tool.name, tool]));
986
+ return [...new Set(value.filter((item): item is string => {
987
+ if (typeof item !== "string" || item === "server_info") return false;
988
+ const definition = definitions.get(item);
989
+ return Boolean(definition && daemonPolicyAllows(definition.availability, policy));
990
+ }))];
937
991
  }
938
992
 
939
- function sseLine(value: string): string {
940
- return value.replaceAll("\r", " ").replaceAll("\n", " ");
993
+ function daemonPolicyAllows(availability: unknown, policy: Record<string, unknown>): boolean {
994
+ if (availability === "always") return true;
995
+ if (availability === "write") return policy.allowWrite === true;
996
+ if (availability === "direct-exec") return policy.execMode === "direct" || policy.execMode === "shell";
997
+ if (availability === "shell-exec") return policy.execMode === "shell";
998
+ return false;
941
999
  }
942
1000
 
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}`;
1001
+ function sanitizeDaemonPolicy(value: unknown): Record<string, unknown> {
1002
+ const policy = asObject(value);
1003
+ const execMode = policy.execMode === "shell" || policy.execMode === "direct" ? policy.execMode : "off";
1004
+ return {
1005
+ profile: sanitizeMetadataText(policy.profile, 32) ?? "custom",
1006
+ allowWrite: policy.allowWrite === true,
1007
+ allowExec: execMode !== "off",
1008
+ execMode,
1009
+ unrestrictedPaths: policy.unrestrictedPaths === true,
1010
+ minimalEnv: policy.minimalEnv !== false,
1011
+ exposeAbsolutePaths: policy.exposeAbsolutePaths === true,
1012
+ };
948
1013
  }
949
1014
 
950
1015
  function json(value: unknown, status = 200): Response {
951
- return new Response(JSON.stringify(value), { status, headers: { "content-type": "application/json; charset=utf-8" } });
1016
+ return new Response(JSON.stringify(value), {
1017
+ status,
1018
+ headers: {
1019
+ "content-type": "application/json; charset=utf-8",
1020
+ "cache-control": "no-store",
1021
+ "x-content-type-options": "nosniff",
1022
+ },
1023
+ });
952
1024
  }
953
1025
 
954
1026
  function html(value: string, status = 200): Response {
955
- return new Response(value, { status, headers: { "content-type": "text/html; charset=utf-8" } });
1027
+ return new Response(value, {
1028
+ status,
1029
+ headers: {
1030
+ "content-type": "text/html; charset=utf-8",
1031
+ "cache-control": "no-store",
1032
+ "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'",
1033
+ "referrer-policy": "no-referrer",
1034
+ "x-content-type-options": "nosniff",
1035
+ "x-frame-options": "DENY",
1036
+ },
1037
+ });
956
1038
  }
957
1039
 
958
1040
  function baseUrl(request: Request): string {
@@ -989,6 +1071,7 @@ async function parseRequestBody(request: Request, limit: number): Promise<Record
989
1071
  return searchParamsObject(new URLSearchParams(text));
990
1072
  }
991
1073
 
1074
+
992
1075
  async function readBoundedText(request: Request, limit: number): Promise<string> {
993
1076
  const length = Number(request.headers.get("content-length") ?? "0");
994
1077
  if (Number.isFinite(length) && length > limit) throw new HttpError(413, "request_body_too_large", `request body exceeds ${limit} bytes`);
@@ -1017,7 +1100,11 @@ async function readBoundedText(request: Request, limit: number): Promise<string>
1017
1100
  combined.set(chunk, offset);
1018
1101
  offset += chunk.byteLength;
1019
1102
  }
1020
- return new TextDecoder().decode(combined);
1103
+ try {
1104
+ return new TextDecoder("utf-8", { fatal: true }).decode(combined);
1105
+ } catch {
1106
+ throw new HttpError(400, "invalid_encoding", "Request body must be valid UTF-8");
1107
+ }
1021
1108
  }
1022
1109
 
1023
1110
  function asObject(value: unknown): Record<string, unknown> {
@@ -1042,15 +1129,106 @@ function clampNumber(value: unknown, fallback: number, min: number, max: number)
1042
1129
  }
1043
1130
 
1044
1131
  function daemonToolTimeoutMs(name: string, args: Record<string, unknown>): number {
1045
- if (name !== "exec_command") return 60_000;
1132
+ if (name !== "exec_command" && name !== "run_process") return 60_000;
1046
1133
  const seconds = clampNumber(args.timeout_seconds, 120, 1, 600);
1047
1134
  return Math.min((seconds + 5) * 1000, 610_000);
1048
1135
  }
1049
1136
 
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];
1137
+ function validateProtocolVersionHeader(request: Request, body: JsonRpcRequest): Record<string, unknown> | null {
1138
+ if (body.method === "initialize") return null;
1139
+ const version = request.headers.get("MCP-Protocol-Version");
1140
+ if (!version) return null;
1141
+ if (MCP_SUPPORTED_PROTOCOL_VERSIONS.includes(version as typeof MCP_SUPPORTED_PROTOCOL_VERSIONS[number])) return null;
1142
+ return rpcError(body.id, -32602, "Unsupported MCP protocol version", {
1143
+ requested: version,
1144
+ supported: [...MCP_SUPPORTED_PROTOCOL_VERSIONS],
1145
+ });
1146
+ }
1147
+
1148
+ function clientRequestKey(authorized: AuthorizedToken, requestId: unknown): string | undefined {
1149
+ if (requestId === null || (typeof requestId !== "string" && typeof requestId !== "number")) return undefined;
1150
+ return `${authorized.tokenKey}:${typeof requestId}:${String(requestId)}`;
1151
+ }
1152
+
1153
+ function validateAuthorizationRequest(
1154
+ body: Record<string, unknown>,
1155
+ base: string,
1156
+ store: OAuthStore,
1157
+ ): { value: ValidatedAuthorization } | { error: string; status: number } {
1158
+ const responseType = String(body.response_type ?? "");
1159
+ const clientId = String(body.client_id ?? "");
1160
+ const redirectUri = String(body.redirect_uri ?? "");
1161
+ const codeChallenge = String(body.code_challenge ?? "");
1162
+ const codeChallengeMethod = String(body.code_challenge_method ?? "");
1163
+ const requestedResource = String(body.resource ?? `${base}/mcp`);
1164
+ const scope = String(body.scope ?? SERVER_NAME).trim();
1165
+ const state = body.state === undefined ? "" : typeof body.state === "string" ? body.state : "";
1166
+
1167
+ if (responseType !== "code") return { error: "response_type must be code.", status: 400 };
1168
+ if (requestedResource !== `${base}/mcp`) return { error: "resource mismatch.", status: 400 };
1169
+ if (scope !== SERVER_NAME) return { error: "unsupported scope.", status: 400 };
1170
+ if (body.state !== undefined && typeof body.state !== "string") return { error: "state must be a string.", status: 400 };
1171
+ if (state.length > 1024) return { error: "state is too long.", status: 400 };
1172
+ if (codeChallengeMethod !== "S256" || !/^[A-Za-z0-9_-]{43}$/.test(codeChallenge)) {
1173
+ return { error: "A valid PKCE S256 challenge is required.", status: 400 };
1174
+ }
1175
+ const client = store.clients[clientId];
1176
+ if (!client) return { error: "Unknown OAuth client.", status: 400 };
1177
+ if (!client.redirect_uris.includes(redirectUri)) return { error: "redirect_uri is not registered.", status: 400 };
1178
+ return { value: { client, clientId, redirectUri, codeChallenge, requestedResource, scope, state } };
1179
+ }
1180
+
1181
+ function pruneClientRecordByExpiry<T extends { client_id: string; expires_at: number }>(record: Record<string, T>, clientId: string, keep: number): void {
1182
+ const allowed = new Set(Object.entries(record)
1183
+ .filter(([, value]) => value.client_id === clientId)
1184
+ .sort((left, right) => right[1].expires_at - left[1].expires_at)
1185
+ .slice(0, keep)
1186
+ .map(([key]) => key));
1187
+ for (const [key, value] of Object.entries(record)) {
1188
+ if (value.client_id === clientId && !allowed.has(key)) delete record[key];
1189
+ }
1190
+ }
1191
+
1192
+ function pruneRecordByExpiry<T extends { expires_at: number }>(record: Record<string, T>, keep: number): void {
1193
+ const allowed = new Set(Object.entries(record)
1194
+ .sort((left, right) => right[1].expires_at - left[1].expires_at)
1195
+ .slice(0, keep)
1196
+ .map(([key]) => key));
1197
+ for (const key of Object.keys(record)) if (!allowed.has(key)) delete record[key];
1198
+ }
1199
+
1200
+ async function authorizationIdentity(request: Request, keyMaterial: string): Promise<string> {
1201
+ const source = (request.headers.get("CF-Connecting-IP") || "unknown").slice(0, 128);
1202
+ const encoder = new TextEncoder();
1203
+ const key = await crypto.subtle.importKey(
1204
+ "raw",
1205
+ encoder.encode(keyMaterial),
1206
+ { name: "HMAC", hash: "SHA-256" },
1207
+ false,
1208
+ ["sign"],
1209
+ );
1210
+ const digest = await crypto.subtle.sign("HMAC", key, encoder.encode(source));
1211
+ return `hmac-sha256:${[...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("")}`;
1212
+ }
1213
+
1214
+ function recordAuthorizationFailure(store: OAuthStore, identity: string, now: number): void {
1215
+ const current = store.auth_failures[identity];
1216
+ const activeWindow = current && current.window_started + AUTH_FAILURE_WINDOW_SECONDS > now;
1217
+ const count = activeWindow ? current.count + 1 : 1;
1218
+ store.auth_failures[identity] = {
1219
+ count,
1220
+ window_started: activeWindow ? current.window_started : now,
1221
+ blocked_until: count >= AUTH_FAILURE_LIMIT ? now + AUTH_BLOCK_SECONDS : 0,
1222
+ last_attempt: now,
1223
+ };
1224
+ }
1225
+
1226
+ function pruneAuthFailures(store: OAuthStore, keep: number): void {
1227
+ const allowed = new Set(Object.entries(store.auth_failures)
1228
+ .sort((left, right) => right[1].last_attempt - left[1].last_attempt)
1229
+ .slice(0, keep)
1230
+ .map(([key]) => key));
1231
+ for (const key of Object.keys(store.auth_failures)) if (!allowed.has(key)) delete store.auth_failures[key];
1054
1232
  }
1055
1233
 
1056
1234
  function randomToken(prefix: string): string {
@@ -1089,7 +1267,8 @@ async function pkceS256(verifier: string): Promise<string> {
1089
1267
  function isAllowedRedirectUri(value: string): boolean {
1090
1268
  try {
1091
1269
  const url = new URL(value);
1092
- if (url.protocol === "https:") return true;
1270
+ if (url.username || url.password || url.hash) return false;
1271
+ if (url.protocol === "https:" && url.hostname) return true;
1093
1272
  if (url.protocol === "http:" && isLoopbackHost(url.hostname)) return true;
1094
1273
  return false;
1095
1274
  } catch {
@@ -1097,19 +1276,56 @@ function isAllowedRedirectUri(value: string): boolean {
1097
1276
  }
1098
1277
  }
1099
1278
 
1100
- function validateOrigin(request: Request, base: string, configured = ""): boolean {
1101
- const origin = request.headers.get("Origin");
1102
- if (!origin) return true;
1279
+ function corsPreflight(request: Request, base: string, configured: string): Response {
1280
+ const origin = request.headers.get("Origin") ?? "";
1281
+ if (!isConfiguredOrSameOrigin(origin, base, configured)) return json({ error: "origin_not_allowed" }, 403);
1282
+ const requestedMethod = (request.headers.get("Access-Control-Request-Method") ?? "").toUpperCase();
1283
+ if (requestedMethod && !["GET", "POST"].includes(requestedMethod)) return methodNotAllowed("GET, POST, OPTIONS");
1284
+ return new Response(null, {
1285
+ status: 204,
1286
+ headers: {
1287
+ "access-control-allow-origin": origin,
1288
+ "access-control-allow-methods": "GET, POST, OPTIONS",
1289
+ "access-control-allow-headers": "authorization, content-type, mcp-protocol-version, mcp-session-id",
1290
+ "access-control-max-age": "600",
1291
+ "cache-control": "no-store",
1292
+ "vary": "Origin, Access-Control-Request-Method, Access-Control-Request-Headers",
1293
+ },
1294
+ });
1295
+ }
1296
+
1297
+ function applyCors(response: Response, request: Request, base: string, configured: string): Response {
1298
+ if (response.status === 101) return response;
1299
+ const origin = request.headers.get("Origin") ?? "";
1300
+ if (!origin || !isConfiguredOrSameOrigin(origin, base, configured)) return response;
1301
+ const headers = new Headers(response.headers);
1302
+ headers.set("access-control-allow-origin", origin);
1303
+ headers.set("access-control-expose-headers", "www-authenticate, mcp-session-id");
1304
+ appendVary(headers, "Origin");
1305
+ return new Response(response.body, { status: response.status, statusText: response.statusText, headers });
1306
+ }
1307
+
1308
+ function appendVary(headers: Headers, value: string): void {
1309
+ const existing = headers.get("vary");
1310
+ const values = new Set((existing ?? "").split(",").map((item) => item.trim()).filter(Boolean));
1311
+ values.add(value);
1312
+ headers.set("vary", [...values].join(", "));
1313
+ }
1314
+
1315
+ function isConfiguredOrSameOrigin(origin: string, base: string, configured: string): boolean {
1103
1316
  if (isDefaultAllowedOrigin(origin, base)) return true;
1104
- const allowed = configured.split(",").map((item) => item.trim()).filter(Boolean);
1317
+ const allowed = configured.split(",").map((item) => item.trim()).filter((item) => item && item !== "null");
1105
1318
  return allowed.includes(origin);
1106
1319
  }
1107
1320
 
1321
+ function validateOrigin(request: Request, base: string, configured = ""): boolean {
1322
+ const origin = request.headers.get("Origin");
1323
+ return !origin || isConfiguredOrSameOrigin(origin, base, configured);
1324
+ }
1325
+
1108
1326
  function isDefaultAllowedOrigin(origin: string, base: string): boolean {
1109
1327
  try {
1110
- const parsed = new URL(origin);
1111
- if (origin === base) return true;
1112
- return parsed.protocol === "http:" && isLoopbackHost(parsed.hostname);
1328
+ return new URL(origin).origin === new URL(base).origin;
1113
1329
  } catch {
1114
1330
  return false;
1115
1331
  }
@@ -1135,10 +1351,23 @@ function searchParamsObject(params: URLSearchParams): Record<string, unknown> {
1135
1351
  return out;
1136
1352
  }
1137
1353
 
1354
+ function normalizeDisplayText(value: string, maxLength: number): string {
1355
+ const normalized = value.replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim();
1356
+ return (normalized || "MCP Client").slice(0, maxLength);
1357
+ }
1358
+
1138
1359
  function escapeHtml(value: string): string {
1139
1360
  return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
1140
1361
  }
1141
1362
 
1363
+ function workerErrorClass(error: unknown): string {
1364
+ if (error instanceof HttpError) return error.code;
1365
+ if (error instanceof TypeError) return "type_error";
1366
+ if (error instanceof RangeError) return "range_error";
1367
+ if (error instanceof Error) return error.name.replace(/[^A-Za-z0-9_-]/g, "_").toLowerCase().slice(0, 64) || "error";
1368
+ return "unknown_error";
1369
+ }
1370
+
1142
1371
  function errorMessage(error: unknown): string {
1143
1372
  if (error instanceof Error) return error.message;
1144
1373
  if (typeof error === "string") return error;