- Side Panel with Chat UI, Quick Actions, Dev Tools - 22 browser automation tools (DOM, JS, screenshots, navigation) - MCP server (zero-dep, Node 18+) for external AI clients - Supports OpenRouter, Ollama, Anthropic, OpenAI, custom endpoints - Chrome/Brave MV3 extension with content script console capture
344 lines
16 KiB
JavaScript
344 lines
16 KiB
JavaScript
/**
|
||
* 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 <select>', inputSchema: { type: 'object', properties: { selector: { type: 'string' }, value: { type: 'string' } }, required: ['selector', 'value'] } },
|
||
{ name: 'browser_scroll', description: 'Scroll page or element', inputSchema: { type: 'object', properties: { direction: { type: 'string', enum: ['up','down','left','right'] }, amount: { type: 'number' }, selector: { type: 'string' } }, required: ['direction'] } },
|
||
{ name: 'browser_execute_js', description: 'Execute JavaScript in page context', inputSchema: { type: 'object', properties: { code: { type: 'string' } }, required: ['code'] } },
|
||
{ name: 'browser_press_key', description: 'Press keyboard key', inputSchema: { type: 'object', properties: { key: { type: 'string' }, modifiers: { type: 'array', items: { type: 'string' } } }, required: ['key'] } },
|
||
{ name: 'browser_wait', description: 'Wait for element or timeout', inputSchema: { type: 'object', properties: { selector: { type: 'string' }, timeout: { type: 'number' } } } },
|
||
{ name: 'browser_get_console_logs', description: 'Get console logs', inputSchema: { type: 'object', properties: { clear: { type: 'boolean' } } } },
|
||
{ name: 'browser_highlight', description: 'Highlight element visually', inputSchema: { type: 'object', properties: { selector: { type: 'string' }, color: { type: 'string' } }, required: ['selector'] } },
|
||
];
|
||
|
||
// ============================================================
|
||
// MCP JSON-RPC Handler
|
||
// ============================================================
|
||
async function handleMcp(body) {
|
||
const { method, params, id } = body;
|
||
|
||
if (method === 'initialize') {
|
||
return { jsonrpc: '2.0', id, result: { protocolVersion: '2024-11-05', capabilities: { tools: {} }, serverInfo: { name: 'browser-pilot', version: '1.0.0' } } };
|
||
}
|
||
if (method === 'notifications/initialized') return null;
|
||
if (method === 'tools/list') {
|
||
return { jsonrpc: '2.0', id, result: { tools: TOOLS } };
|
||
}
|
||
if (method === 'tools/call') {
|
||
const { name, arguments: args } = params;
|
||
try {
|
||
const resp = await callExtension(name, args || {});
|
||
const content = [];
|
||
if (resp.success) {
|
||
if (resp.result?.image) {
|
||
const b64 = resp.result.image.replace(/^data:image\/png;base64,/, '');
|
||
content.push({ type: 'image', data: b64, mimeType: 'image/png' });
|
||
} else {
|
||
content.push({ type: 'text', text: JSON.stringify(resp.result, null, 2) });
|
||
}
|
||
} else {
|
||
content.push({ type: 'text', text: 'Error: ' + resp.error });
|
||
}
|
||
return { jsonrpc: '2.0', id, result: { content, isError: !resp.success } };
|
||
} catch (e) {
|
||
return { jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: 'Error: ' + e.message }], isError: true } };
|
||
}
|
||
}
|
||
return { jsonrpc: '2.0', id, error: { code: -32601, message: 'Method not found: ' + method } };
|
||
}
|
||
|
||
// ============================================================
|
||
// HTTP Server
|
||
// ============================================================
|
||
const server = http.createServer(async (req, res) => {
|
||
const cors = {
|
||
'Access-Control-Allow-Origin': '*',
|
||
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
||
'Access-Control-Allow-Headers': 'Content-Type, Authorization, Mcp-Session-Id',
|
||
'Access-Control-Expose-Headers': 'Mcp-Session-Id'
|
||
};
|
||
|
||
if (req.method === 'OPTIONS') { res.writeHead(204, cors); res.end(); return; }
|
||
|
||
// Auth
|
||
if (AUTH_TOKEN && req.headers.authorization !== `Bearer ${AUTH_TOKEN}`) {
|
||
res.writeHead(401, { ...cors, 'Content-Type': 'application/json' });
|
||
res.end(JSON.stringify({ error: 'Unauthorized' }));
|
||
return;
|
||
}
|
||
|
||
const url = new URL(req.url, `http://localhost:${PORT}`);
|
||
|
||
// Health
|
||
if (url.pathname === '/health') {
|
||
res.writeHead(200, { ...cors, 'Content-Type': 'application/json' });
|
||
res.end(JSON.stringify({ status: 'ok', extensionConnected: !!extensionWs?.alive, tools: TOOLS.length }));
|
||
return;
|
||
}
|
||
|
||
// Tools list (convenience)
|
||
if (url.pathname === '/tools' && req.method === 'GET') {
|
||
res.writeHead(200, { ...cors, 'Content-Type': 'application/json' });
|
||
res.end(JSON.stringify({ tools: TOOLS }));
|
||
return;
|
||
}
|
||
|
||
// Direct execute (convenience)
|
||
if (url.pathname === '/execute' && req.method === 'POST') {
|
||
let body = ''; for await (const c of req) body += c;
|
||
try {
|
||
const { tool, params } = JSON.parse(body);
|
||
const result = await callExtension(tool, params || {});
|
||
res.writeHead(200, { ...cors, 'Content-Type': 'application/json' });
|
||
res.end(JSON.stringify(result));
|
||
} catch (e) {
|
||
res.writeHead(500, { ...cors, 'Content-Type': 'application/json' });
|
||
res.end(JSON.stringify({ success: false, error: e.message }));
|
||
}
|
||
return;
|
||
}
|
||
|
||
// MCP endpoint
|
||
if (url.pathname === '/mcp') {
|
||
if (req.method === 'GET') {
|
||
// SSE
|
||
res.writeHead(200, { ...cors, 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
|
||
res.write('event: open\ndata: {"status":"connected"}\n\n');
|
||
const ka = setInterval(() => res.write(': ping\n\n'), 15000);
|
||
req.on('close', () => clearInterval(ka));
|
||
return;
|
||
}
|
||
if (req.method === 'POST') {
|
||
let body = ''; for await (const c of req) body += c;
|
||
try {
|
||
const parsed = JSON.parse(body);
|
||
const result = await handleMcp(parsed);
|
||
if (result) {
|
||
res.writeHead(200, { ...cors, 'Content-Type': 'application/json' });
|
||
res.end(JSON.stringify(result));
|
||
} else {
|
||
res.writeHead(204, cors); res.end();
|
||
}
|
||
} catch (e) {
|
||
res.writeHead(400, { ...cors, 'Content-Type': 'application/json' });
|
||
res.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32700, message: e.message } }));
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
|
||
res.writeHead(404, cors); res.end('Not found');
|
||
});
|
||
|
||
// WebSocket upgrade
|
||
server.on('upgrade', (req, socket, head) => {
|
||
const key = req.headers['sec-websocket-key'];
|
||
if (!key) { socket.destroy(); return; }
|
||
|
||
const accept = crypto.createHash('sha1').update(key + WS_GUID).digest('base64');
|
||
socket.write([
|
||
'HTTP/1.1 101 Switching Protocols',
|
||
'Upgrade: websocket',
|
||
'Connection: Upgrade',
|
||
`Sec-WebSocket-Accept: ${accept}`,
|
||
'', ''
|
||
].join('\r\n'));
|
||
|
||
const ws = new WsConnection(socket);
|
||
extensionWs = ws;
|
||
console.log('✓ Extension connected');
|
||
|
||
ws.onMessage = (data) => {
|
||
try {
|
||
const msg = JSON.parse(data);
|
||
if (msg.id && pendingCalls.has(msg.id)) {
|
||
pendingCalls.get(msg.id).resolve(msg);
|
||
pendingCalls.delete(msg.id);
|
||
}
|
||
} catch {}
|
||
};
|
||
|
||
ws.onClose = () => {
|
||
console.log('✗ Extension disconnected');
|
||
if (extensionWs === ws) extensionWs = null;
|
||
for (const [id, { reject }] of pendingCalls) reject(new Error('Extension disconnected'));
|
||
pendingCalls.clear();
|
||
};
|
||
});
|
||
|
||
server.listen(PORT, '127.0.0.1', () => {
|
||
console.log(`
|
||
┌─────────────────────────────────────────────────┐
|
||
│ 🚀 Browser Pilot – MCP Server │
|
||
│ │
|
||
│ MCP: http://127.0.0.1:${PORT}/mcp │
|
||
│ Health: http://127.0.0.1:${PORT}/health │
|
||
│ WS: ws://127.0.0.1:${PORT} (extension bridge) │
|
||
│ │
|
||
│ Waiting for extension... │
|
||
└─────────────────────────────────────────────────┘
|
||
`);
|
||
});
|