Files
core d708c8b58c Initial commit: Browser Pilot - Agentic Browser Control Extension
- 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
2026-07-28 11:04:37 +02:00

344 lines
16 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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... │
└─────────────────────────────────────────────────┘
`);
});