/** * Browser Pilot – MCP Server + WebSocket Bridge * * Zero dependencies. Runs on Node.js 18+. * * This server does TWO things: * 1. HTTP MCP endpoint (port 9224) – for LLM clients (Claude Desktop, Cursor, etc.) * 2. WebSocket server (same port, upgrade) – for the Chrome extension to connect * * Flow: * Claude/Cursor --MCP HTTP--> This Server --WebSocket--> Chrome Extension --Chrome APIs--> Browser */ import http from 'node:http'; import crypto from 'node:crypto'; // ============================================================ // Config // ============================================================ const PORT = parseInt(process.env.PORT || '9224'); const AUTH_TOKEN = process.env.AUTH_TOKEN || ''; // ============================================================ // WebSocket (RFC 6455 minimal implementation) // ============================================================ const WS_GUID = '258EAFA5-E914-47DA-95CA-5AB5DC85B7F3'; class WsConnection { constructor(socket) { this.socket = socket; this.alive = true; this.buffer = Buffer.alloc(0); this.onMessage = null; this.onClose = null; socket.on('data', d => this._onData(d)); socket.on('close', () => { this.alive = false; this.onClose?.(); }); socket.on('error', () => { this.alive = false; }); } send(data) { if (!this.alive) return; const buf = Buffer.from(typeof data === 'string' ? data : JSON.stringify(data)); const frame = this._frame(buf, 0x01); this.socket.write(frame); } close() { this.socket.write(this._frame(Buffer.alloc(0), 0x08)); this.alive = false; this.socket.end(); } _frame(payload, opcode) { const len = payload.length; let header; if (len < 126) { header = Buffer.alloc(2); header[0] = 0x80 | opcode; header[1] = len; } else if (len < 65536) { header = Buffer.alloc(4); header[0] = 0x80 | opcode; header[1] = 126; header.writeUInt16BE(len, 2); } else { header = Buffer.alloc(10); header[0] = 0x80 | opcode; header[1] = 127; header.writeBigUInt64BE(BigInt(len), 2); } return Buffer.concat([header, payload]); } _onData(data) { this.buffer = Buffer.concat([this.buffer, data]); while (this.buffer.length >= 2) { const frame = this._parseFrame(); if (!frame) break; if (frame.opcode === 0x08) { this.alive = false; this.socket.end(); this.onClose?.(); return; } if (frame.opcode === 0x09) { this.socket.write(this._frame(frame.payload, 0x0A)); continue; } if (frame.opcode === 0x01 || frame.opcode === 0x02) { this.onMessage?.(frame.payload.toString('utf-8')); } } } _parseFrame() { if (this.buffer.length < 2) return null; const masked = (this.buffer[1] & 0x80) !== 0; let payloadLen = this.buffer[1] & 0x7F; let offset = 2; if (payloadLen === 126) { if (this.buffer.length < 4) return null; payloadLen = this.buffer.readUInt16BE(2); offset = 4; } else if (payloadLen === 127) { if (this.buffer.length < 10) return null; payloadLen = Number(this.buffer.readBigUInt64BE(2)); offset = 10; } const maskSize = masked ? 4 : 0; const total = offset + maskSize + payloadLen; if (this.buffer.length < total) return null; let mask = masked ? this.buffer.slice(offset, offset + 4) : null; if (masked) offset += 4; let payload = this.buffer.slice(offset, offset + payloadLen); if (mask) for (let i = 0; i < payload.length; i++) payload[i] ^= mask[i % 4]; this.buffer = this.buffer.slice(total); return { opcode: this.buffer.length >= 0 ? (this.buffer[-total] ?? this.buffer[0]) & 0x0F : 0, payload }; } } // Fix: the opcode parsing above is wrong after slicing. Let me redo properly: WsConnection.prototype._parseFrame = function() { if (this.buffer.length < 2) return null; const firstByte = this.buffer[0]; const secondByte = this.buffer[1]; const opcode = firstByte & 0x0F; const masked = (secondByte & 0x80) !== 0; let payloadLen = secondByte & 0x7F; let offset = 2; if (payloadLen === 126) { if (this.buffer.length < 4) return null; payloadLen = this.buffer.readUInt16BE(2); offset = 4; } else if (payloadLen === 127) { if (this.buffer.length < 10) return null; payloadLen = Number(this.buffer.readBigUInt64BE(2)); offset = 10; } const maskSize = masked ? 4 : 0; const total = offset + maskSize + payloadLen; if (this.buffer.length < total) return null; let mask = masked ? this.buffer.slice(offset, offset + 4) : null; if (masked) offset += 4; let payload = Buffer.from(this.buffer.slice(offset, offset + payloadLen)); if (mask) for (let i = 0; i < payload.length; i++) payload[i] ^= mask[i % 4]; this.buffer = this.buffer.slice(total); return { opcode, payload }; }; // ============================================================ // Extension connection state // ============================================================ let extensionWs = null; let pendingCalls = new Map(); let callId = 0; function callExtension(tool, params, timeout = 30000) { return new Promise((resolve, reject) => { if (!extensionWs || !extensionWs.alive) { return reject(new Error('Browser extension not connected. Open Browser Pilot in Chrome/Brave.')); } const id = ++callId; const timer = setTimeout(() => { pendingCalls.delete(id); reject(new Error('Timeout')); }, timeout); pendingCalls.set(id, { resolve: (r) => { clearTimeout(timer); resolve(r); }, reject: (e) => { clearTimeout(timer); reject(e); } }); extensionWs.send(JSON.stringify({ id, type: 'TOOL_CALL', tool, params })); }); } // ============================================================ // MCP Tools Schema // ============================================================ const TOOLS = [ { name: 'browser_navigate', description: 'Navigate to a URL', inputSchema: { type: 'object', properties: { url: { type: 'string' }, newTab: { type: 'boolean' } }, required: ['url'] } }, { name: 'browser_go_back', description: 'Go back in history', inputSchema: { type: 'object', properties: {} } }, { name: 'browser_go_forward', description: 'Go forward in history', inputSchema: { type: 'object', properties: {} } }, { name: 'browser_reload', description: 'Reload page', inputSchema: { type: 'object', properties: {} } }, { name: 'browser_get_tabs', description: 'List all open tabs', inputSchema: { type: 'object', properties: {} } }, { name: 'browser_switch_tab', description: 'Switch to tab by ID', inputSchema: { type: 'object', properties: { tabId: { type: 'number' } }, required: ['tabId'] } }, { name: 'browser_close_tab', description: 'Close tab', inputSchema: { type: 'object', properties: { tabId: { type: 'number' } }, required: ['tabId'] } }, { name: 'browser_screenshot', description: 'Screenshot visible viewport (base64 PNG)', inputSchema: { type: 'object', properties: {} } }, { name: 'browser_get_page_info', description: 'Get page URL, title, viewport, scroll', inputSchema: { type: 'object', properties: {} } }, { name: 'browser_get_text', description: 'Get text content of page or element', inputSchema: { type: 'object', properties: { selector: { type: 'string' } } } }, { name: 'browser_get_html', description: 'Get HTML of page or element', inputSchema: { type: 'object', properties: { selector: { type: 'string' }, outer: { type: 'boolean' } } } }, { name: 'browser_query_selector', description: 'Query elements by CSS selector', inputSchema: { type: 'object', properties: { selector: { type: 'string' }, limit: { type: 'number' } }, required: ['selector'] } }, { name: 'browser_get_interactive_elements', description: 'Find all interactive elements', inputSchema: { type: 'object', properties: { viewportOnly: { type: 'boolean' } } } }, { name: 'browser_click', description: 'Click element by selector', inputSchema: { type: 'object', properties: { selector: { type: 'string' } }, required: ['selector'] } }, { name: 'browser_type', description: 'Type text into input', inputSchema: { type: 'object', properties: { selector: { type: 'string' }, text: { type: 'string' }, clear: { type: 'boolean' } }, required: ['selector', 'text'] } }, { name: 'browser_select', description: 'Select option in