2026年3月31日·1 分钟阅读

Chrome MCP Proxy

CDP proxy for Chrome DevTools MCP — persistent connection, multi-agent isolation, anti-focus-stealing. Built for Claude Code.

HE
henuwangkai · Community
快速使用

先拿来用,再决定要不要深挖

这里应该同时让用户和 Agent 知道第一步该复制什么、安装什么、落到哪里。

#!/usr/bin/env node /**

  • CDP Background Proxy v3 — Persistent Connection + Multi-Agent Isolation
  • Solves key pain points when using Chrome DevTools MCP with AI agents:
    1. Persistent WebSocket connection to Chrome (remote debugging popup only appears once)
    1. Blocks Target.activateTarget / Page.bringToFront (prevents focus stealing)
    1. Forces Target.createTarget with background=true
    1. Request ID remapping (prevents multi-client ID conflicts)
    1. Event routing by sessionId (prevents multi-Agent interference)
    1. Auto-reconnect on Chrome disconnect
  • Usage: node cdp-proxy.mjs [--port 9401] [--chrome-port 9222] */

import http from 'http'; import fs from 'fs'; import path from 'path'; import os from 'os'; import { WebSocketServer, WebSocket } from 'ws';

const args = process.argv.slice(2); const getArg = (name, def) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : def; };

const PROXY_PORT = parseInt(getArg('--port', '9401')); const CHROME_PORT = parseInt(getArg('--chrome-port', '9222')); const CHROME_HOST = getArg('--chrome-host', '127.0.0.1');

// Auto-detect Chrome's DevTools WebSocket path const DEFAULT_DEVTOOLS_PORT_FILE = process.platform === 'darwin' ? path.join(os.homedir(), 'Library/Application Support/Google/Chrome/DevToolsActivePort') : process.platform === 'win32' ? path.join(os.homedir(), 'AppData/Local/Google/Chrome/User Data/DevToolsActivePort') : path.join(os.homedir(), '.config/google-chrome/DevToolsActivePort');

const DEVTOOLS_PORT_FILE = getArg('--devtools-port-file', DEFAULT_DEVTOOLS_PORT_FILE);

// ═══════════════════════════════════════════ // Persistent Chrome Connection // ═══════════════════════════════════════════

let chromeWs = null; let chromeReady = false; let reconnecting = false;

const BLOCKED_METHODS = new Set([ 'Target.activateTarget', 'Page.bringToFront', ]);

function safeSend(ws, data) { if (ws && ws.readyState === WebSocket.OPEN) { ws.send(typeof data === 'string' ? data : JSON.stringify(data)); } }

function readDevToolsActivePort() { try { const content = fs.readFileSync(DEVTOOLS_PORT_FILE, 'utf-8').trim(); const lines = content.split('\n'); if (lines.length >= 2) { return { port: parseInt(lines[0]), wsPath: lines[1] }; } } catch (e) { /* ignore */ } return null; }

function getChromeWsUrl() { const portInfo = readDevToolsActivePort(); if (portInfo) { return ws://${CHROME_HOST}:${portInfo.port}${portInfo.wsPath}; } return null; }

// ═══════════════════════════════════════════ // Multi-Client Isolation // ═══════════════════════════════════════════

let globalIdCounter = 1;

// proxyId → { clientWs, originalId, method, createdAt } const pendingRequests = new Map();

// Clean up requests that have been pending for over 60 seconds setInterval(() => { const now = Date.now(); let cleaned = 0; for (const [proxyId, req] of pendingRequests) { if (now - req.createdAt > 60000) { safeSend(req.clientWs, { id: req.originalId, error: { code: -32000, message: 'CDP request timeout (60s)' } }); pendingRequests.delete(proxyId); const state = clientState.get(req.clientWs); if (state) state.proxyIds.delete(proxyId); cleaned++; } } if (cleaned > 0) { console.log([Proxy] Cleaned ${cleaned} timed-out requests (${pendingRequests.size} remaining)); } }, 30000);

// sessionId → clientWs (which client attached this session) const sessionOwners = new Map();

// clientWs → { tabs: Set, sessions: Set, proxyIds: Set } const clientState = new Map();

function getOrCreateState(clientWs) { if (!clientState.has(clientWs)) { clientState.set(clientWs, { tabs: new Set(), sessions: new Set(), proxyIds: new Set() }); } return clientState.get(clientWs); }

// ═══════════════════════════════════════════ // Chrome Connection Manager // ═══════════════════════════════════════════

function connectToChrome() { if (reconnecting) return; reconnecting = true;

const wsUrl = getChromeWsUrl();
if (!wsUrl) {
    console.error('[Proxy] Chrome not running, retrying in 5s...');
    setTimeout(() => { reconnecting = false; connectToChrome(); }, 5000);
    return;
}

console.log(`[Proxy] Connecting to Chrome: ${wsUrl}`);
chromeWs = new WebSocket(wsUrl);

chromeWs.on('open', () => {
    console.log('[Proxy] ✓ Chrome connected');
    chromeReady = true;
    reconnecting = false;
});

chromeWs.on('message', (rawData) => {
    try {
        const msg = JSON.parse(rawData.toString());

        // Response messages (have id) → route to originator
        if (msg.id !== undefined && pendingRequests.has(msg.id)) {
            const { clientWs, originalId, method } = pendingRequests.get(msg.id);
            pendingRequests.delete(msg.id);

            const state = clientState.get(clientWs);

            // Track createTarget
            if (method === 'Target.createTarget' && msg.result?.targetId) {
                if (state) state.tabs.add(msg.result.targetId);
                console.log(`[Proxy] New tab: ${msg.result.targetId}`);
            }

            // Track attachToTarget → record session ownership
            if (method === 'Target.attachToTarget' && msg.result?.sessionId) {
                const sid = msg.result.sessionId;
                sessionOwners.set(sid, clientWs);
                if (state) state.sessions.add(sid);
                console.log(`[Proxy] Session ${sid.slice(0, 8)}... → client`);
            }

            // Restore original ID
            msg.id = originalId;
            safeSend(clientWs, msg);
            return;
        }

        // Event messages (no id) → route by sessionId
        if (msg.method) {
            // Events with sessionId: send to owning client
            if (msg.sessionId && sessionOwners.has(msg.sessionId)) {
                safeSend(sessionOwners.get(msg.sessionId), msg);
                return;
            }

            // Target domain global events: route by targetId or broadcast
            if (msg.method.startsWith('Target.')) {
                const targetId = msg.params?.targetInfo?.targetId || msg.params?.targetId;
                if (targetId) {
                    for (const [ws, state] of clientState) {
                        if (state.tabs.has(targetId)) {
                            safeSend(ws, msg);
                            return;
                        }
                    }
                }
                // No owner found, broadcast
                for (const [ws] of clientState) {
                    safeSend(ws, msg);
                }
                return;
            }

            // Other events without sessionId, broadcast
            for (const [ws] of clientState) {
                safeSend(ws, msg);
            }
            return;
        }

        // Other messages, broadcast
        for (const [ws] of clientState) {
            safeSend(ws, rawData);
        }
    } catch (e) {
        for (const [ws] of clientState) {
            safeSend(ws, rawData);
        }
    }
});

chromeWs.on('close', () => {
    console.log('[Proxy] Chrome disconnected, reconnecting in 5s...');
    chromeReady = false;
    chromeWs = null;
    reconnecting = false;
    sessionOwners.clear();
    setTimeout(() => connectToChrome(), 5000);
});

chromeWs.on('error', (err) => {
    console.error('[Proxy] Chrome error:', err.message);
    chromeReady = false;
    chromeWs = null;
    reconnecting = false;
    setTimeout(() => connectToChrome(), 5000);
});

}

// ═══════════════════════════════════════════ // HTTP Endpoints // ═══════════════════════════════════════════

function cdpRequest(method, params = {}) { return new Promise((resolve, reject) => { if (!chromeReady) return reject(new Error('Chrome not connected')); const proxyId = globalIdCounter++; const timeout = setTimeout(() => { pendingRequests.delete(proxyId); reject(new Error('timeout')); }, 5000); const fakeClient = { send: (data) => { clearTimeout(timeout); resolve(typeof data === 'string' ? JSON.parse(data) : data); }, readyState: WebSocket.OPEN, }; pendingRequests.set(proxyId, { clientWs: fakeClient, originalId: proxyId, method }); chromeWs.send(JSON.stringify({ id: proxyId, method, params })); }); }

const server = http.createServer(async (req, res) => { try { if (req.url === '/json/version') { const portInfo = readDevToolsActivePort(); const wsUrl = portInfo ? ws://${CHROME_HOST}:${PROXY_PORT}${portInfo.wsPath} : ws://${CHROME_HOST}:${PROXY_PORT}/devtools/browser/proxy; res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ Browser: 'Chrome (via CDP Proxy v3)', webSocketDebuggerUrl: wsUrl })); return; }

    if (req.url === '/json/list' || req.url === '/json') {
        if (!chromeReady) { res.writeHead(503); res.end('Chrome not connected'); return; }
        try {
            const result = await cdpRequest('Target.getTargets');
            const targets = (result.result?.targetInfos || []).map(t => ({
                ...t,
                webSocketDebuggerUrl: `ws://${CHROME_HOST}:${PROXY_PORT}/devtools/page/${t.targetId}`,
            }));
            res.writeHead(200, { 'Content-Type': 'application/json' });
            res.end(JSON.stringify(targets));
        } catch (e) { res.writeHead(500); res.end(e.message); }
        return;
    }

    if (req.url === '/json/new') {
        if (!chromeReady) { res.writeHead(503); res.end('Chrome not connected'); return; }
        try {
            const result = await cdpRequest('Target.createTarget', { url: 'about:blank', background: true });
            res.writeHead(200, { 'Content-Type': 'application/json' });
            res.end(JSON.stringify(result.result || {}));
        } catch (e) { res.writeHead(500); res.end(e.message); }
        return;
    }

    if (req.url === '/proxy/status') {
        const clientList = [];
        for (const [, state] of clientState) {
            clientList.push({ tabs: state.tabs.size, sessions: state.sessions.size });
        }
        res.writeHead(200, { 'Content-Type': 'application/json' });
        res.end(JSON.stringify({
            chromeConnected: chromeReady,
            clients: clientState.size,
            sessions: sessionOwners.size,
            pendingRequests: pendingRequests.size,
            clientDetails: clientList,
        }, null, 2));
        return;
    }

    res.writeHead(404);
    res.end('Not found');
} catch (e) { res.writeHead(500); res.end(e.message); }

});

// ═══════════════════════════════════════════ // WebSocket Client Handler // ═══════════════════════════════════════════

const wss = new WebSocketServer({ server });

wss.on('connection', (clientWs, req) => { const state = getOrCreateState(clientWs); console.log([Proxy] Client connected (total: ${clientState.size}));

clientWs.on('message', (data) => {
    if (!chromeReady) {
        try {
            const msg = JSON.parse(data.toString());
            if (msg.id !== undefined) {
                safeSend(clientWs, { id: msg.id, error: { code: -1, message: 'Chrome not connected' } });
            }
        } catch (e) { /* ignore */ }
        return;
    }

    try {
        const msg = JSON.parse(data.toString());

        // Block focus-stealing commands
        if (BLOCKED_METHODS.has(msg.method)) {
            console.log(`[Proxy] Blocked: ${msg.method}`);
            const reply = { id: msg.id, result: {} };
            if (msg.sessionId) reply.sessionId = msg.sessionId;
            safeSend(clientWs, reply);
            return;
        }

        // Force background tab creation
        if (msg.method === 'Target.createTarget') {
            if (!msg.params) msg.params = {};
            msg.params.background = true;
            console.log(`[Proxy] Background tab: ${msg.params.url || 'about:blank'}`);
        }

        // ID remapping
        if (msg.id !== undefined) {
            const proxyId = globalIdCounter++;
            pendingRequests.set(proxyId, {
                clientWs,
                originalId: msg.id,
                method: msg.method,
                createdAt: Date.now(),
            });
            state.proxyIds.add(proxyId);
            msg.id = proxyId;
        }

        safeSend(chromeWs, msg);
    } catch (e) {
        safeSend(chromeWs, data);
    }
});

const cleanup = () => {
    for (const sid of state.sessions) {
        sessionOwners.delete(sid);
    }
    for (const pid of state.proxyIds) {
        pendingRequests.delete(pid);
    }
    clientState.delete(clientWs);
    console.log(`[Proxy] Client disconnected (remaining: ${clientState.size}, freed ${state.tabs.size} tabs, ${state.sessions.size} sessions)`);
};

clientWs.on('close', cleanup);
clientWs.on('error', cleanup);

});

// ═══════════════════════════════════════════ // Start // ═══════════════════════════════════════════

process.on('uncaughtException', (err) => { console.error('[Proxy] Uncaught exception (recovered):', err.message); }); process.on('unhandledRejection', (reason) => { console.error('[Proxy] Unhandled rejection (recovered):', reason); });

server.listen(PROXY_PORT, () => { console.log(╔══════════════════════════════════════════════════════════╗ ║ CDP Background Proxy v3 — Persistent + Multi-Agent ║ ║ ║ ║ Proxy: http://127.0.0.1:${PROXY_PORT} ║ ║ ║ ║ ✓ Persistent connection (popup only once) ║ ║ ✓ Blocks activateTarget / bringToFront ║ ║ ✓ Forces createTarget background=true ║ ║ ✓ Request ID remapping (multi-client safe) ║ ║ ✓ Event routing by sessionId (multi-Agent safe) ║ ║ ✓ Auto-reconnect ║ ║ ║ ║ Status: http://127.0.0.1:${PROXY_PORT}/proxy/status ║ ╚══════════════════════════════════════════════════════════╝); connectToChrome(); });