machine-bridge-mcp 0.1.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.
@@ -0,0 +1,895 @@
1
+ import { DurableObject } from "cloudflare:workers";
2
+
3
+ const SERVER_NAME = "machine-bridge-mcp";
4
+ const SERVER_VERSION = "0.1.0";
5
+ const MCP_PROTOCOL_VERSION = "2025-06-18";
6
+ const JSONRPC_VERSION = "2.0";
7
+ const DEFAULT_MAX_BODY_BYTES = 32 * 1024 * 1024;
8
+ const MAX_BODY_BYTES = 64 * 1024 * 1024;
9
+ const TOKEN_TTL_SECONDS = 60 * 60 * 24 * 30;
10
+
11
+ interface BridgeEnv {
12
+ BRIDGE: DurableObjectNamespace<BridgeRoom>;
13
+ MCP_OAUTH_PASSWORD: string;
14
+ DAEMON_SHARED_SECRET: string;
15
+ OAUTH_TOKEN_VERSION: string;
16
+ MBM_WORKER_MAX_BODY_BYTES?: string;
17
+ MBM_ALLOWED_ORIGINS?: string;
18
+ }
19
+
20
+ type JsonRpcId = string | number | null;
21
+
22
+ interface JsonRpcRequest {
23
+ jsonrpc: "2.0";
24
+ id?: JsonRpcId;
25
+ method: string;
26
+ params?: unknown;
27
+ }
28
+
29
+ interface OAuthClient {
30
+ client_id: string;
31
+ client_name: string;
32
+ redirect_uris: string[];
33
+ created_at: number;
34
+ }
35
+
36
+ interface OAuthCode {
37
+ client_id: string;
38
+ redirect_uri: string;
39
+ code_challenge: string;
40
+ scope: string;
41
+ resource: string;
42
+ expires_at: number;
43
+ }
44
+
45
+ interface OAuthToken {
46
+ client_id: string;
47
+ scope: string;
48
+ resource: string;
49
+ version: string;
50
+ expires_at: number;
51
+ }
52
+
53
+ interface OAuthStore {
54
+ clients: Record<string, OAuthClient>;
55
+ codes: Record<string, OAuthCode>;
56
+ tokens: Record<string, OAuthToken>;
57
+ }
58
+
59
+ interface DaemonAttachment {
60
+ role: "daemon";
61
+ connectedAt: string;
62
+ daemonId: string;
63
+ workspaceHash?: string;
64
+ workspaceName?: string;
65
+ policy?: Record<string, unknown>;
66
+ tools?: string[];
67
+ }
68
+
69
+ interface PendingCall {
70
+ resolve: (value: unknown) => void;
71
+ reject: (error: Error) => void;
72
+ timeout: ReturnType<typeof setTimeout>;
73
+ }
74
+
75
+ const serverInfoTool = {
76
+ name: "server_info",
77
+ description: "Return bridge metadata, OAuth endpoint details, daemon connection status, and available tools.",
78
+ inputSchema: { type: "object", additionalProperties: true },
79
+ } as const;
80
+
81
+ const workspaceTools = [
82
+ {
83
+ name: "project_overview",
84
+ description: "Summarize the connected local workspace and daemon policy.",
85
+ inputSchema: { type: "object", additionalProperties: true },
86
+ },
87
+ {
88
+ name: "list_roots",
89
+ description: "List workspace roots exposed by the local daemon.",
90
+ inputSchema: { type: "object", additionalProperties: true },
91
+ },
92
+ {
93
+ name: "list_dir",
94
+ description: "List direct children of a workspace directory.",
95
+ inputSchema: {
96
+ type: "object",
97
+ properties: { path: { type: "string", default: "." } },
98
+ additionalProperties: true,
99
+ },
100
+ },
101
+ {
102
+ name: "list_files",
103
+ description: "Recursively list files under a workspace path.",
104
+ inputSchema: {
105
+ type: "object",
106
+ properties: {
107
+ path: { type: "string", default: "." },
108
+ max_files: { type: "integer", minimum: 1, maximum: 10000, default: 1000 },
109
+ },
110
+ additionalProperties: true,
111
+ },
112
+ },
113
+ {
114
+ name: "read_file",
115
+ 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.",
116
+ inputSchema: {
117
+ type: "object",
118
+ properties: {
119
+ path: { type: "string" },
120
+ max_bytes: { type: "integer", minimum: 1, maximum: 5242880, default: 1048576 },
121
+ },
122
+ required: ["path"],
123
+ additionalProperties: true,
124
+ },
125
+ },
126
+ {
127
+ name: "write_file",
128
+ description: "Write a UTF-8 file. Relative paths use the daemon workspace; absolute paths and parent-directory paths are allowed. Enabled by default.",
129
+ inputSchema: {
130
+ type: "object",
131
+ properties: {
132
+ path: { type: "string" },
133
+ content: { type: "string" },
134
+ create_only: { type: "boolean", default: false },
135
+ expected_sha256: { type: "string" },
136
+ },
137
+ required: ["path", "content"],
138
+ additionalProperties: true,
139
+ },
140
+ },
141
+ {
142
+ name: "search_text",
143
+ description: "Search plain text files under a workspace path for a substring.",
144
+ inputSchema: {
145
+ type: "object",
146
+ properties: {
147
+ query: { type: "string" },
148
+ path: { type: "string", default: "." },
149
+ max_matches: { type: "integer", minimum: 1, maximum: 1000, default: 100 },
150
+ max_files: { type: "integer", minimum: 1, maximum: 100000, default: 10000 },
151
+ },
152
+ required: ["query"],
153
+ additionalProperties: true,
154
+ },
155
+ },
156
+ {
157
+ name: "git_status",
158
+ description: "Run git status --short in the local workspace.",
159
+ inputSchema: { type: "object", additionalProperties: true },
160
+ },
161
+ {
162
+ name: "git_diff",
163
+ description: "Run git diff for the local workspace and return bounded output.",
164
+ inputSchema: {
165
+ type: "object",
166
+ properties: {
167
+ path: { type: "string", default: "." },
168
+ max_bytes: { type: "integer", minimum: 1, maximum: 5242880, default: 1048576 },
169
+ },
170
+ additionalProperties: true,
171
+ },
172
+ },
173
+ {
174
+ name: "exec_command",
175
+ 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.",
176
+ inputSchema: {
177
+ type: "object",
178
+ properties: {
179
+ command: { type: "string" },
180
+ timeout_seconds: { type: "integer", minimum: 1, maximum: 600, default: 120 },
181
+ },
182
+ required: ["command"],
183
+ additionalProperties: true,
184
+ },
185
+ },
186
+ ] as const;
187
+
188
+ const MCP_INSTRUCTIONS = [
189
+ "You are connected to a local workspace through machine-bridge-mcp.",
190
+ "The Worker is only a relay. File and command operations run on the user's local daemon.",
191
+ "Relative paths use the configured workspace as cwd; absolute paths and parent-directory paths are allowed by default.",
192
+ "Writes and shell execution are enabled by default in this bridge for ease of use.",
193
+ "Prefer inspecting files before editing, make minimal changes, and report commands you ran.",
194
+ ].join("\n");
195
+
196
+ export class BridgeRoom extends DurableObject<BridgeEnv> {
197
+ private readonly pending = new Map<string, PendingCall>();
198
+
199
+ constructor(ctx: DurableObjectState, env: BridgeEnv) {
200
+ super(ctx, env);
201
+ }
202
+
203
+ async fetch(request: Request): Promise<Response> {
204
+ const url = new URL(request.url);
205
+ const base = baseUrl(request);
206
+ try {
207
+ if (!validateOrigin(request, base, this.env.MBM_ALLOWED_ORIGINS)) {
208
+ return json({ error: "origin_not_allowed" }, 403);
209
+ }
210
+
211
+ if (url.pathname === "/" && request.method === "GET") {
212
+ return json({ ok: true, server: SERVER_NAME, version: SERVER_VERSION, mcp: `${base}/mcp`, daemon: this.daemonStatus(false) });
213
+ }
214
+ if (url.pathname === "/healthz") {
215
+ return json({ ok: true, server: SERVER_NAME, version: SERVER_VERSION, daemon: this.daemonStatus(false) });
216
+ }
217
+ if (url.pathname === "/.well-known/mcp.json") {
218
+ return json(this.mcpMetadata(base));
219
+ }
220
+ if (
221
+ url.pathname === "/.well-known/oauth-authorization-server" ||
222
+ url.pathname === "/.well-known/oauth-authorization-server/mcp" ||
223
+ url.pathname === "/.well-known/openid-configuration" ||
224
+ url.pathname === "/.well-known/openid-configuration/mcp"
225
+ ) {
226
+ return json(this.authorizationServerMetadata(base));
227
+ }
228
+ if (url.pathname === "/.well-known/oauth-protected-resource" || url.pathname === "/.well-known/oauth-protected-resource/mcp") {
229
+ return json(this.protectedResourceMetadata(base));
230
+ }
231
+ if (url.pathname === "/oauth/register" && request.method === "POST") return this.registerClient(request);
232
+ if (url.pathname === "/oauth/authorize" && request.method === "GET") return this.authorizePage(request, base);
233
+ if (url.pathname === "/oauth/authorize" && request.method === "POST") return this.authorizeSubmit(request, base);
234
+ if (url.pathname === "/oauth/token" && request.method === "POST") return this.exchangeToken(request, base);
235
+ if (url.pathname === "/daemon/ws") return this.acceptDaemonWebSocket(request);
236
+ if (url.pathname === "/mcp") return this.handleMcp(request, base);
237
+ if (url.pathname === "/api/daemon/status") return json(this.daemonStatus(false));
238
+ return json({ error: "not_found" }, 404);
239
+ } catch (error) {
240
+ if (error instanceof HttpError) return json({ error: error.code, message: error.message }, error.status);
241
+ console.error(JSON.stringify({ level: "error", message: "request_failed", path: url.pathname, error: errorMessage(error) }));
242
+ return json({ error: "internal_server_error" }, 500);
243
+ }
244
+ }
245
+
246
+ async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
247
+ const text = typeof message === "string" ? message : new TextDecoder().decode(message);
248
+ let body: Record<string, unknown>;
249
+ try {
250
+ body = JSON.parse(text) as Record<string, unknown>;
251
+ } catch {
252
+ ws.send(JSON.stringify({ type: "error", error: "invalid_json" }));
253
+ return;
254
+ }
255
+
256
+ if (body.type === "hello") {
257
+ const attachment = this.daemonAttachment(ws);
258
+ if (attachment) {
259
+ ws.serializeAttachment({
260
+ ...attachment,
261
+ workspaceHash: stringOrUndefined(body.workspace_hash) ?? attachment.workspaceHash,
262
+ workspaceName: stringOrUndefined(body.workspace_name) ?? attachment.workspaceName,
263
+ policy: asObject(body.policy),
264
+ tools: Array.isArray(body.tools) ? body.tools.filter((item): item is string => typeof item === "string") : attachment.tools,
265
+ });
266
+ }
267
+ ws.send(JSON.stringify({ type: "hello_ack", server: SERVER_NAME, version: SERVER_VERSION }));
268
+ return;
269
+ }
270
+
271
+ if (body.type === "heartbeat" || body.type === "ping") {
272
+ ws.send(JSON.stringify({ type: "pong", ts: body.ts ?? Date.now() }));
273
+ return;
274
+ }
275
+
276
+ if (body.type !== "tool_result" || typeof body.id !== "string") {
277
+ ws.send(JSON.stringify({ type: "error", error: "unknown_message_type" }));
278
+ return;
279
+ }
280
+
281
+ const pending = this.pending.get(body.id);
282
+ if (!pending) return;
283
+ clearTimeout(pending.timeout);
284
+ this.pending.delete(body.id);
285
+ if (body.ok === false) pending.reject(new Error(errorMessage(body.error)));
286
+ else pending.resolve(body.result);
287
+ }
288
+
289
+ async webSocketClose(ws: WebSocket): Promise<void> {
290
+ const attachment = this.daemonAttachment(ws);
291
+ if (attachment?.role !== "daemon") return;
292
+ for (const [id, pending] of this.pending) {
293
+ clearTimeout(pending.timeout);
294
+ pending.reject(new Error("daemon disconnected"));
295
+ this.pending.delete(id);
296
+ }
297
+ }
298
+
299
+ private async handleMcp(request: Request, base: string): Promise<Response> {
300
+ if (request.method === "GET") return new Response("GET SSE stream is not implemented; use POST Streamable HTTP.", { status: 405 });
301
+ if (request.method === "DELETE") return new Response(null, { status: 405 });
302
+ if (request.method !== "POST") return json({ error: "mcp endpoint expects POST JSON-RPC" }, 405);
303
+
304
+ if (!(await this.verifyAccessToken(bearerToken(request), base))) {
305
+ return new Response("OAuth bearer token required", {
306
+ status: 401,
307
+ headers: { "WWW-Authenticate": `Bearer resource_metadata="${base}/.well-known/oauth-protected-resource/mcp"` },
308
+ });
309
+ }
310
+
311
+ const body = await parseJsonRequest(request, this.bodyLimitBytes());
312
+ if (isJsonRpcResponse(body)) return new Response(null, { status: 202 });
313
+ if (!isJsonRpcRequest(body)) return json(rpcError(null, -32600, "Invalid JSON-RPC request"), 400);
314
+ const response = await this.dispatchJsonRpc(body, base);
315
+ if (response === null) return new Response(null, { status: 202 });
316
+ return json(response);
317
+ }
318
+
319
+ private async dispatchJsonRpc(request: JsonRpcRequest, base: string): Promise<Record<string, unknown> | null> {
320
+ if (request.method === "initialize") {
321
+ return rpcResult(request.id, {
322
+ protocolVersion: MCP_PROTOCOL_VERSION,
323
+ capabilities: { tools: { listChanged: false } },
324
+ serverInfo: { name: SERVER_NAME, version: SERVER_VERSION },
325
+ instructions: MCP_INSTRUCTIONS,
326
+ });
327
+ }
328
+ if (request.method === "notifications/initialized") return null;
329
+ if (request.method === "ping") return rpcResult(request.id, {});
330
+ if (request.method === "tools/list") return rpcResult(request.id, { tools: this.allTools() });
331
+ if (request.method === "tools/call") {
332
+ const params = asObject(request.params);
333
+ const name = requiredString(params, "name");
334
+ const args = asObject(params.arguments);
335
+ try {
336
+ const result = await this.callTool(name, args, base);
337
+ return rpcResult(request.id, textToolResult(result));
338
+ } catch (error) {
339
+ return rpcResult(request.id, textToolResult({ error: errorMessage(error) }, true));
340
+ }
341
+ }
342
+ return rpcError(request.id, -32601, `Method not found: ${request.method}`);
343
+ }
344
+
345
+ private async callTool(name: string, args: Record<string, unknown>, base: string): Promise<unknown> {
346
+ if (name === "server_info") {
347
+ return {
348
+ name: SERVER_NAME,
349
+ version: SERVER_VERSION,
350
+ mcp_url: `${base}/mcp`,
351
+ oauth: this.authorizationServerMetadata(base),
352
+ daemon: this.daemonStatus(true),
353
+ tools: this.allTools().map((tool) => tool.name),
354
+ };
355
+ }
356
+ if (workspaceTools.some((tool) => tool.name === name)) return this.callDaemonTool(name, args);
357
+ throw new Error(`unknown tool: ${name}`);
358
+ }
359
+
360
+ private async callDaemonTool(name: string, args: Record<string, unknown>): Promise<unknown> {
361
+ const socket = this.daemonSockets()[0];
362
+ if (!socket) throw new Error("local daemon is not connected; keep the CLI start command running");
363
+ const id = randomToken("call");
364
+ const timeoutMs = daemonToolTimeoutMs(name, args);
365
+ return await new Promise((resolve, reject) => {
366
+ const timeout = setTimeout(() => {
367
+ this.pending.delete(id);
368
+ reject(new Error(`daemon tool timed out: ${name}`));
369
+ }, timeoutMs);
370
+ this.pending.set(id, { resolve, reject, timeout });
371
+ socket.send(JSON.stringify({ type: "tool_call", id, tool: name, arguments: args, timeout_ms: timeoutMs }));
372
+ });
373
+ }
374
+
375
+ private async acceptDaemonWebSocket(request: Request): Promise<Response> {
376
+ if (request.headers.get("Upgrade")?.toLowerCase() !== "websocket") return new Response("Expected Upgrade: websocket", { status: 426 });
377
+ const expected = this.env.DAEMON_SHARED_SECRET ?? "";
378
+ const supplied = request.headers.get("X-Bridge-Token") ?? "";
379
+ if (!expected || !(await safeEqual(supplied, expected))) return new Response("Unauthorized daemon", { status: 401 });
380
+
381
+ for (const socket of this.daemonSockets()) {
382
+ try {
383
+ socket.close(1012, "replaced by newer daemon");
384
+ } catch {
385
+ // Ignore stale sockets.
386
+ }
387
+ }
388
+
389
+ const pair = new WebSocketPair();
390
+ const [client, server] = Object.values(pair) as [WebSocket, WebSocket];
391
+ this.ctx.acceptWebSocket(server);
392
+ server.serializeAttachment({
393
+ role: "daemon",
394
+ connectedAt: new Date().toISOString(),
395
+ daemonId: request.headers.get("X-Daemon-Id") || randomToken("daemon"),
396
+ } satisfies DaemonAttachment);
397
+ server.send(JSON.stringify({ type: "welcome", server: SERVER_NAME, version: SERVER_VERSION }));
398
+ return new Response(null, { status: 101, webSocket: client });
399
+ }
400
+
401
+ private allTools(): Array<Record<string, unknown>> {
402
+ return [serverInfoTool, ...workspaceTools].map((tool) => ({ ...tool }));
403
+ }
404
+
405
+ private daemonSockets(): WebSocket[] {
406
+ return this.ctx.getWebSockets().filter((socket) => {
407
+ const attachment = this.daemonAttachment(socket);
408
+ return attachment?.role === "daemon" && socket.readyState === WebSocket.OPEN;
409
+ });
410
+ }
411
+
412
+ private daemonAttachment(socket: WebSocket): DaemonAttachment | undefined {
413
+ const raw = socket.deserializeAttachment();
414
+ if (!raw || typeof raw !== "object") return undefined;
415
+ const candidate = raw as Partial<DaemonAttachment>;
416
+ return candidate.role === "daemon" ? (candidate as DaemonAttachment) : undefined;
417
+ }
418
+
419
+ private daemonStatus(detail: boolean): Record<string, unknown> {
420
+ const sockets = this.daemonSockets();
421
+ const attachment = sockets[0] ? this.daemonAttachment(sockets[0]) : undefined;
422
+ const tools = attachment?.tools ?? [];
423
+ const base = {
424
+ connected: sockets.length > 0,
425
+ count: sockets.length,
426
+ tool_count: tools.length,
427
+ connected_at: attachment?.connectedAt ?? null,
428
+ };
429
+ if (!detail) return base;
430
+ return {
431
+ ...base,
432
+ workspace_hash: attachment?.workspaceHash ?? null,
433
+ workspace_name: attachment?.workspaceName ?? null,
434
+ policy: attachment?.policy ?? null,
435
+ tools,
436
+ };
437
+ }
438
+
439
+ private mcpMetadata(base: string): Record<string, unknown> {
440
+ return {
441
+ name: SERVER_NAME,
442
+ version: SERVER_VERSION,
443
+ protocolVersion: MCP_PROTOCOL_VERSION,
444
+ transport: { type: "streamable-http", url: `${base}/mcp` },
445
+ auth: { type: "oauth" },
446
+ tools: this.allTools().map((tool) => tool.name),
447
+ instructions: MCP_INSTRUCTIONS,
448
+ };
449
+ }
450
+
451
+ private authorizationServerMetadata(base: string): Record<string, unknown> {
452
+ return {
453
+ issuer: base,
454
+ authorization_endpoint: `${base}/oauth/authorize`,
455
+ token_endpoint: `${base}/oauth/token`,
456
+ registration_endpoint: `${base}/oauth/register`,
457
+ response_types_supported: ["code"],
458
+ grant_types_supported: ["authorization_code"],
459
+ token_endpoint_auth_methods_supported: ["none"],
460
+ code_challenge_methods_supported: ["S256"],
461
+ scopes_supported: [SERVER_NAME],
462
+ };
463
+ }
464
+
465
+ private protectedResourceMetadata(base: string): Record<string, unknown> {
466
+ return {
467
+ resource: `${base}/mcp`,
468
+ authorization_servers: [base],
469
+ scopes_supported: [SERVER_NAME],
470
+ bearer_methods_supported: ["header"],
471
+ resource_name: SERVER_NAME,
472
+ };
473
+ }
474
+
475
+ private async oauthStore(): Promise<OAuthStore> {
476
+ const store = (await this.ctx.storage.get<OAuthStore>("oauth")) ?? { clients: {}, codes: {}, tokens: {} };
477
+ const now = Math.floor(Date.now() / 1000);
478
+ let changed = false;
479
+ for (const [code, value] of Object.entries(store.codes)) {
480
+ if (value.expires_at <= now) {
481
+ delete store.codes[code];
482
+ changed = true;
483
+ }
484
+ }
485
+ for (const [token, value] of Object.entries(store.tokens)) {
486
+ if (value.expires_at <= now) {
487
+ delete store.tokens[token];
488
+ changed = true;
489
+ }
490
+ }
491
+ if (changed) await this.ctx.storage.put("oauth", store);
492
+ return store;
493
+ }
494
+
495
+ private async registerClient(request: Request): Promise<Response> {
496
+ const body = await parseRequestBody(request, this.bodyLimitBytes());
497
+ const redirectUris = body.redirect_uris;
498
+ if (!Array.isArray(redirectUris) || redirectUris.length === 0) {
499
+ return json({ error: "invalid_client_metadata", error_description: "redirect_uris must be a non-empty array" }, 400);
500
+ }
501
+ if (redirectUris.length > 20) {
502
+ return json({ error: "invalid_client_metadata", error_description: "redirect_uris must contain at most 20 entries" }, 400);
503
+ }
504
+ const normalized = redirectUris.map((item) => String(item));
505
+ if (normalized.some((item) => item.length > 2048)) {
506
+ return json({ error: "invalid_client_metadata", error_description: "redirect_uri is too long" }, 400);
507
+ }
508
+ if (!normalized.every(isAllowedRedirectUri)) {
509
+ return json({ error: "invalid_client_metadata", error_description: "redirect_uris must be https or local http" }, 400);
510
+ }
511
+
512
+ const store = await this.oauthStore();
513
+ const client: OAuthClient = {
514
+ client_id: randomToken("mcp_client"),
515
+ client_name: (stringOrUndefined(body.client_name) ?? "MCP Client").slice(0, 128),
516
+ redirect_uris: normalized,
517
+ created_at: Math.floor(Date.now() / 1000),
518
+ };
519
+ store.clients[client.client_id] = client;
520
+ pruneOAuthClients(store, 100);
521
+ await this.ctx.storage.put("oauth", store);
522
+ return json({
523
+ client_id: client.client_id,
524
+ client_name: client.client_name,
525
+ redirect_uris: client.redirect_uris,
526
+ grant_types: ["authorization_code"],
527
+ response_types: ["code"],
528
+ token_endpoint_auth_method: "none",
529
+ client_id_issued_at: client.created_at,
530
+ });
531
+ }
532
+
533
+ private authorizePage(request: Request, base: string, error = "", submitted?: Record<string, unknown>): Response {
534
+ const url = new URL(request.url);
535
+ const sourceEntries = submitted ? Object.entries(submitted) : searchParamsEntries(url.searchParams);
536
+ const hidden = sourceEntries
537
+ .filter(([key]) => key !== "login_token")
538
+ .map(([key, value]) => `<input type="hidden" name="${escapeHtml(key)}" value="${escapeHtml(String(value))}">`)
539
+ .join("\n");
540
+ const resource = String(submitted?.resource ?? url.searchParams.get("resource") ?? `${base}/mcp`);
541
+ const errorBlock = error ? `<p style="color:#b91c1c">${escapeHtml(error)}</p>` : "";
542
+ return html(`<!doctype html>
543
+ <html>
544
+ <head><meta charset="utf-8"><title>Authorize ${SERVER_NAME}</title></head>
545
+ <body style="font-family: system-ui, sans-serif; max-width: 640px; margin: 48px auto; line-height: 1.5; padding: 0 16px;">
546
+ <h1>Authorize ${SERVER_NAME}</h1>
547
+ <p>Enter the MCP connection password printed by <code>machine-bridge-mcp start</code>.</p>
548
+ <p><strong>Resource:</strong> <code>${escapeHtml(resource)}</code></p>
549
+ ${errorBlock}
550
+ <form method="post" action="/oauth/authorize">
551
+ ${hidden}
552
+ <label>Connection password<br><input name="login_token" type="password" autofocus style="width: 100%; box-sizing: border-box; padding: 8px;"></label>
553
+ <p><button type="submit">Authorize</button></p>
554
+ </form>
555
+ </body>
556
+ </html>`);
557
+ }
558
+
559
+ private async authorizeSubmit(request: Request, base: string): Promise<Response> {
560
+ const body = await parseRequestBody(request, this.bodyLimitBytes());
561
+ const expectedLogin = this.env.MCP_OAUTH_PASSWORD ?? "";
562
+ if (!expectedLogin || !(await safeEqual(String(body.login_token ?? ""), expectedLogin))) {
563
+ return this.authorizePage(request, base, "Invalid connection password.", body);
564
+ }
565
+
566
+ const responseType = String(body.response_type ?? "");
567
+ const clientId = String(body.client_id ?? "");
568
+ const redirectUri = String(body.redirect_uri ?? "");
569
+ const codeChallenge = String(body.code_challenge ?? "");
570
+ const codeChallengeMethod = String(body.code_challenge_method ?? "");
571
+ const requestedResource = String(body.resource ?? `${base}/mcp`);
572
+
573
+ if (responseType !== "code") return this.authorizePage(request, base, "response_type must be code.", body);
574
+ if (requestedResource !== `${base}/mcp`) return this.authorizePage(request, base, "resource mismatch.", body);
575
+ if (codeChallengeMethod !== "S256" || !codeChallenge) return this.authorizePage(request, base, "PKCE S256 is required.", body);
576
+
577
+ const store = await this.oauthStore();
578
+ const client = store.clients[clientId];
579
+ if (!client) return this.authorizePage(request, base, "Unknown OAuth client.", body);
580
+ if (!client.redirect_uris.includes(redirectUri)) return this.authorizePage(request, base, "redirect_uri is not registered.", body);
581
+
582
+ const code = randomToken("mcp_code");
583
+ store.codes[code] = {
584
+ client_id: clientId,
585
+ redirect_uri: redirectUri,
586
+ code_challenge: codeChallenge,
587
+ scope: String(body.scope ?? SERVER_NAME),
588
+ resource: requestedResource,
589
+ expires_at: Math.floor(Date.now() / 1000) + 300,
590
+ };
591
+ await this.ctx.storage.put("oauth", store);
592
+
593
+ const params = new URLSearchParams({ code });
594
+ if (typeof body.state === "string" && body.state) params.set("state", body.state);
595
+ return Response.redirect(`${redirectUri}${redirectUri.includes("?") ? "&" : "?"}${params.toString()}`, 302);
596
+ }
597
+
598
+ private async exchangeToken(request: Request, base: string): Promise<Response> {
599
+ const body = await parseRequestBody(request, this.bodyLimitBytes());
600
+ if (String(body.grant_type ?? "") !== "authorization_code") return json({ error: "unsupported_grant_type" }, 400);
601
+
602
+ const code = String(body.code ?? "");
603
+ const verifier = String(body.code_verifier ?? "");
604
+ const store = await this.oauthStore();
605
+ const record = store.codes[code];
606
+ delete store.codes[code];
607
+ if (!record) {
608
+ await this.ctx.storage.put("oauth", store);
609
+ return json({ error: "invalid_grant" }, 400);
610
+ }
611
+ if (String(body.client_id ?? "") !== record.client_id || String(body.redirect_uri ?? "") !== record.redirect_uri) {
612
+ await this.ctx.storage.put("oauth", store);
613
+ return json({ error: "invalid_grant", error_description: "client or redirect mismatch" }, 400);
614
+ }
615
+ if (String(body.resource ?? record.resource) !== record.resource) {
616
+ await this.ctx.storage.put("oauth", store);
617
+ return json({ error: "invalid_target", error_description: "resource mismatch" }, 400);
618
+ }
619
+ if (!(await safeEqual(await pkceS256(verifier), record.code_challenge))) {
620
+ await this.ctx.storage.put("oauth", store);
621
+ return json({ error: "invalid_grant", error_description: "invalid code_verifier" }, 400);
622
+ }
623
+
624
+ const accessToken = randomToken("mcp_at");
625
+ store.tokens[`sha256:${await sha256Hex(accessToken)}`] = {
626
+ client_id: record.client_id,
627
+ scope: record.scope,
628
+ resource: `${base}/mcp`,
629
+ version: this.env.OAUTH_TOKEN_VERSION ?? "",
630
+ expires_at: Math.floor(Date.now() / 1000) + TOKEN_TTL_SECONDS,
631
+ };
632
+ await this.ctx.storage.put("oauth", store);
633
+ return json({ access_token: accessToken, token_type: "Bearer", expires_in: TOKEN_TTL_SECONDS, scope: record.scope });
634
+ }
635
+
636
+ private async verifyAccessToken(token: string, base: string): Promise<boolean> {
637
+ if (!token) return false;
638
+ const store = await this.oauthStore();
639
+ const key = `sha256:${await sha256Hex(token)}`;
640
+ const record = store.tokens[key];
641
+ if (!record) return false;
642
+ if (record.expires_at <= Math.floor(Date.now() / 1000)) {
643
+ delete store.tokens[key];
644
+ await this.ctx.storage.put("oauth", store);
645
+ return false;
646
+ }
647
+ const currentVersion = this.env.OAUTH_TOKEN_VERSION ?? "";
648
+ if (!record.version || !currentVersion || !(await safeEqual(record.version, currentVersion))) return false;
649
+ return record.resource === `${base}/mcp`;
650
+ }
651
+
652
+ private bodyLimitBytes(): number {
653
+ const parsed = Number.parseInt(this.env.MBM_WORKER_MAX_BODY_BYTES ?? "", 10);
654
+ if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_MAX_BODY_BYTES;
655
+ return Math.min(parsed, MAX_BODY_BYTES);
656
+ }
657
+ }
658
+
659
+ export default {
660
+ async fetch(request: Request, env: BridgeEnv): Promise<Response> {
661
+ const stub = env.BRIDGE.getByName("default");
662
+ return stub.fetch(request);
663
+ },
664
+ } satisfies ExportedHandler<BridgeEnv>;
665
+
666
+ function isJsonRpcRequest(value: unknown): value is JsonRpcRequest {
667
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
668
+ const candidate = value as Record<string, unknown>;
669
+ return candidate.jsonrpc === JSONRPC_VERSION && typeof candidate.method === "string";
670
+ }
671
+
672
+ function isJsonRpcResponse(value: unknown): boolean {
673
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
674
+ const candidate = value as Record<string, unknown>;
675
+ if (candidate.jsonrpc !== JSONRPC_VERSION || !("id" in candidate) || typeof candidate.method === "string") return false;
676
+ return "result" in candidate || "error" in candidate;
677
+ }
678
+
679
+ function rpcResult(id: JsonRpcId | undefined, result: unknown): Record<string, unknown> | null {
680
+ if (id === undefined) return null;
681
+ return { jsonrpc: JSONRPC_VERSION, id, result };
682
+ }
683
+
684
+ function rpcError(id: JsonRpcId | undefined, code: number, message: string): Record<string, unknown> {
685
+ return { jsonrpc: JSONRPC_VERSION, id: id ?? null, error: { code, message } };
686
+ }
687
+
688
+ function textToolResult(value: unknown, isError = false): Record<string, unknown> {
689
+ return { content: [{ type: "text", text: typeof value === "string" ? value : JSON.stringify(value, null, 2) }], isError };
690
+ }
691
+
692
+ function json(value: unknown, status = 200): Response {
693
+ return new Response(JSON.stringify(value), { status, headers: { "content-type": "application/json; charset=utf-8" } });
694
+ }
695
+
696
+ function html(value: string, status = 200): Response {
697
+ return new Response(value, { status, headers: { "content-type": "text/html; charset=utf-8" } });
698
+ }
699
+
700
+ function baseUrl(request: Request): string {
701
+ return new URL(request.url).origin;
702
+ }
703
+
704
+ function bearerToken(request: Request): string {
705
+ const match = (request.headers.get("Authorization") ?? "").match(/^Bearer\s+(.+)$/i);
706
+ return match?.[1]?.trim() ?? "";
707
+ }
708
+
709
+ async function parseJsonRequest(request: Request, limit: number): Promise<unknown> {
710
+ const text = await readBoundedText(request, limit);
711
+ try {
712
+ return JSON.parse(text);
713
+ } catch {
714
+ throw new HttpError(400, "invalid_json", "Request body is not valid JSON");
715
+ }
716
+ }
717
+
718
+ async function parseRequestBody(request: Request, limit: number): Promise<Record<string, unknown>> {
719
+ const contentType = (request.headers.get("content-type") ?? "").toLowerCase();
720
+ const text = await readBoundedText(request, limit);
721
+ if (contentType.includes("application/json") || text.trim().startsWith("{")) {
722
+ let parsed: unknown;
723
+ try {
724
+ parsed = text.trim() ? JSON.parse(text) : {};
725
+ } catch {
726
+ throw new HttpError(400, "invalid_json", "Request body is not valid JSON");
727
+ }
728
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new HttpError(400, "bad_request", "JSON body must be an object");
729
+ return parsed as Record<string, unknown>;
730
+ }
731
+ return searchParamsObject(new URLSearchParams(text));
732
+ }
733
+
734
+ async function readBoundedText(request: Request, limit: number): Promise<string> {
735
+ const length = Number(request.headers.get("content-length") ?? "0");
736
+ if (Number.isFinite(length) && length > limit) throw new HttpError(413, "request_body_too_large", `request body exceeds ${limit} bytes`);
737
+ if (!request.body) return "";
738
+ const reader = request.body.getReader();
739
+ const chunks: Uint8Array[] = [];
740
+ let total = 0;
741
+ try {
742
+ for (;;) {
743
+ const { done, value } = await reader.read();
744
+ if (done) break;
745
+ if (!value) continue;
746
+ total += value.byteLength;
747
+ if (total > limit) {
748
+ await reader.cancel();
749
+ throw new HttpError(413, "request_body_too_large", `request body exceeds ${limit} bytes`);
750
+ }
751
+ chunks.push(value);
752
+ }
753
+ } finally {
754
+ reader.releaseLock();
755
+ }
756
+ const combined = new Uint8Array(total);
757
+ let offset = 0;
758
+ for (const chunk of chunks) {
759
+ combined.set(chunk, offset);
760
+ offset += chunk.byteLength;
761
+ }
762
+ return new TextDecoder().decode(combined);
763
+ }
764
+
765
+ function asObject(value: unknown): Record<string, unknown> {
766
+ if (!value || typeof value !== "object" || Array.isArray(value)) return {};
767
+ return value as Record<string, unknown>;
768
+ }
769
+
770
+ function requiredString(value: Record<string, unknown>, key: string): string {
771
+ const field = value[key];
772
+ if (typeof field !== "string" || !field.trim()) throw new Error(`${key} must be a non-empty string`);
773
+ return field.trim();
774
+ }
775
+
776
+ function stringOrUndefined(value: unknown): string | undefined {
777
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
778
+ }
779
+
780
+ function clampNumber(value: unknown, fallback: number, min: number, max: number): number {
781
+ const number = typeof value === "number" && Number.isFinite(value) ? value : Number.parseInt(String(value ?? ""), 10);
782
+ const safe = Number.isFinite(number) ? number : fallback;
783
+ return Math.min(Math.max(Math.floor(safe), min), max);
784
+ }
785
+
786
+ function daemonToolTimeoutMs(name: string, args: Record<string, unknown>): number {
787
+ if (name !== "exec_command") return 60_000;
788
+ const seconds = clampNumber(args.timeout_seconds, 120, 1, 600);
789
+ return Math.min((seconds + 5) * 1000, 610_000);
790
+ }
791
+
792
+ function pruneOAuthClients(store: OAuthStore, keep: number): void {
793
+ const clients = Object.values(store.clients).sort((a, b) => b.created_at - a.created_at);
794
+ const allowed = new Set(clients.slice(0, keep).map((client) => client.client_id));
795
+ for (const clientId of Object.keys(store.clients)) if (!allowed.has(clientId)) delete store.clients[clientId];
796
+ }
797
+
798
+ function randomToken(prefix: string): string {
799
+ const bytes = new Uint8Array(32);
800
+ crypto.getRandomValues(bytes);
801
+ return `${prefix}_${base64Url(bytes)}`;
802
+ }
803
+
804
+ function base64Url(bytes: Uint8Array): string {
805
+ return btoa(String.fromCharCode(...bytes)).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
806
+ }
807
+
808
+ async function sha256Hex(value: string): Promise<string> {
809
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
810
+ return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
811
+ }
812
+
813
+ async function safeEqual(left: string, right: string): Promise<boolean> {
814
+ const encoder = new TextEncoder();
815
+ const [leftHash, rightHash] = await Promise.all([
816
+ crypto.subtle.digest("SHA-256", encoder.encode(left)),
817
+ crypto.subtle.digest("SHA-256", encoder.encode(right)),
818
+ ]);
819
+ const leftBytes = new Uint8Array(leftHash);
820
+ const rightBytes = new Uint8Array(rightHash);
821
+ let diff = leftBytes.length ^ rightBytes.length;
822
+ for (let index = 0; index < Math.max(leftBytes.length, rightBytes.length); index += 1) diff |= (leftBytes[index] ?? 0) ^ (rightBytes[index] ?? 0);
823
+ return diff === 0;
824
+ }
825
+
826
+ async function pkceS256(verifier: string): Promise<string> {
827
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
828
+ return base64Url(new Uint8Array(digest));
829
+ }
830
+
831
+ function isAllowedRedirectUri(value: string): boolean {
832
+ try {
833
+ const url = new URL(value);
834
+ if (url.protocol === "https:") return true;
835
+ if (url.protocol === "http:" && isLoopbackHost(url.hostname)) return true;
836
+ return false;
837
+ } catch {
838
+ return false;
839
+ }
840
+ }
841
+
842
+ function validateOrigin(request: Request, base: string, configured = ""): boolean {
843
+ const origin = request.headers.get("Origin");
844
+ if (!origin) return true;
845
+ const allowed = configured.split(",").map((item) => item.trim()).filter(Boolean);
846
+ if (allowed.length > 0) return allowed.includes(origin);
847
+ try {
848
+ const parsed = new URL(origin);
849
+ if (origin === base) return true;
850
+ if (parsed.protocol === "https:") return true;
851
+ return parsed.protocol === "http:" && isLoopbackHost(parsed.hostname);
852
+ } catch {
853
+ return false;
854
+ }
855
+ }
856
+
857
+ function isLoopbackHost(hostname: string): boolean {
858
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]";
859
+ }
860
+
861
+ function searchParamsEntries(params: URLSearchParams): Array<[string, string]> {
862
+ const entries: Array<[string, string]> = [];
863
+ params.forEach((value, key) => entries.push([key, value]));
864
+ return entries;
865
+ }
866
+
867
+ function searchParamsObject(params: URLSearchParams): Record<string, unknown> {
868
+ const out: Record<string, unknown> = {};
869
+ params.forEach((value, key) => {
870
+ if (out[key] === undefined) out[key] = value;
871
+ else if (Array.isArray(out[key])) (out[key] as string[]).push(value);
872
+ else out[key] = [out[key] as string, value];
873
+ });
874
+ return out;
875
+ }
876
+
877
+ function escapeHtml(value: string): string {
878
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
879
+ }
880
+
881
+ function errorMessage(error: unknown): string {
882
+ if (error instanceof Error) return error.message;
883
+ if (typeof error === "string") return error;
884
+ try {
885
+ return JSON.stringify(error);
886
+ } catch {
887
+ return String(error);
888
+ }
889
+ }
890
+
891
+ class HttpError extends Error {
892
+ constructor(readonly status: number, readonly code: string, message: string) {
893
+ super(message);
894
+ }
895
+ }