MCP ConfigsApr 6, 2026·2 min read

Chrome MCP Background Proxy — Fix Popups, Focus Stealing & Multi-Agent Conflicts

Drop-in proxy for Chrome MCP. Solves Chrome 146+ debugging popups, focus stealing, and multi-agent conflicts with persistent CDP connection, request ID remapping, and sessionId-based event routing.

HE
henuwangkai · Community
Quick Use

Use it first, then decide how deep to go

This block should tell both the user and the agent what to copy, install, and apply first.

What It Does

Chrome MCP lets AI agents (Claude Code, Cursor, etc.) control your browser. But it has 3 major pain points:

Problem What Happens This Proxy Fixes It
Chrome 146+ popup "Allow remote debugging?" pops up every time you start a new session Persistent connection — popup appears only once
Focus stealing Chrome jumps to foreground while agent works Intercepts focus commands, all tabs open in background
Multi-agent conflicts Multiple agents crash each other's browser sessions Request ID remapping + event routing by sessionId

Architecture

Claude Code → chrome-mcp-proxy.sh → chrome-devtools-mcp
                                          ↓
                                    cdp-proxy.mjs (port 9401)
                                          ↓
                                    Your Chrome Browser

The proxy sits between chrome-devtools-mcp and Chrome, maintaining one persistent WebSocket connection.

Quick Install (3 steps)

Step 1: Save the 3 scripts

Create ~/scripts/ and save these files there:

chrome-mcp-proxy.sh — MCP entry point (click to expand)
#!/bin/bash
# Chrome MCP + 持久 Proxy
# 用法: chrome-mcp-proxy.sh [proxy端口] [chrome端口]
# Proxy 保持和 Chrome 的持久连接,弹窗只出现一次

PROXY_PORT=${1:-9401}
CHROME_PORT=${2:-9222}
PROXY_SCRIPT="$HOME/scripts/cdp-proxy.mjs"
PID_DIR="$HOME/chrome-profiles/pids"
LOG_DIR="$HOME/chrome-profiles/logs"

mkdir -p "$PID_DIR" "$LOG_DIR"

# 本地连接绕过代理
unset http_proxy HTTP_PROXY https_proxy HTTPS_PROXY all_proxy ALL_PROXY
export no_proxy="127.0.0.1,localhost"
export NO_PROXY="127.0.0.1,localhost"

# 如果 Proxy 没跑,启动它(持久运行,不随 Claude Code 退出而死)
if ! lsof -i :${PROXY_PORT} >/dev/null 2>&1; then
    nohup node "${PROXY_SCRIPT}" \
        --port ${PROXY_PORT} \
        --chrome-port ${CHROME_PORT} \
        &>"${LOG_DIR}/proxy-${PROXY_PORT}.log" &
    echo $! > "${PID_DIR}/proxy-${PROXY_PORT}.pid"
    disown
    sleep 2
fi

# 启动 chrome-devtools-mcp,连接到 Proxy
exec npx -y chrome-devtools-mcp@latest --browserUrl "http://127.0.0.1:${PROXY_PORT}"
cdp-proxy.mjs — CDP persistent proxy with multi-agent isolation (click to expand)
#!/usr/bin/env node
/**
 * CDP Background Proxy v3 — 持久连接 + 多 Agent 隔离
 *
 * 1. 持久 WebSocket 连接到 Chrome(弹窗只出现一次)
 * 2. 拦截 Target.activateTarget / Page.bringToFront(防抢焦点)
 * 3. 强制 createTarget background=true
 * 4. 请求 ID 重映射(防多客户端 ID 冲突)
 * 5. 事件按 sessionId 路由到对应客户端(防多 Agent 互扰)
 * 6. 自动重连
 *
 * 用法: 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');

const DEVTOOLS_PORT_FILE = getArg('--devtools-port-file',
    path.join(os.homedir(), 'Library/Application Support/Google/Chrome/DevToolsActivePort'));

// ═══════════════════════════════════════════
// 持久 Chrome 连接
// ═══════════════════════════════════════════

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;
}

// ═══════════════════════════════════════════
// 多客户端隔离
// ═══════════════════════════════════════════

// 全局自增 ID,保证唯一
let globalIdCounter = 1;

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

// 每30秒清理超过60秒未响应的 pending requests
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} 个超时请求 (剩 ${pendingRequests.size} 个)`);
    }
}, 30000);

// sessionId → clientWs(哪个客户端 attach 了这个 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 连接管理
// ═══════════════════════════════════════════

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

    const wsUrl = getChromeWsUrl();
    if (!wsUrl) {
        console.error('[Proxy] Chrome 未运行,5秒后重试...');
        setTimeout(() => { reconnecting = false; connectToChrome(); }, 5000);
        return;
    }

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

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

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

            // ── 响应消息(有 id)→ 路由到发起者 ──
            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);

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

                // 追踪 attachToTarget → 记录 session 归属
                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)}... → 客户端`);
                }

                // 还原原始 ID
                msg.id = originalId;
                safeSend(clientWs, msg);
                return;
            }

            // ── 事件消息(无 id)→ 按 sessionId 路由 ──
            if (msg.method) {
                // 带 sessionId 的事件:发给对应客户端
                if (msg.sessionId && sessionOwners.has(msg.sessionId)) {
                    safeSend(sessionOwners.get(msg.sessionId), msg);
                    return;
                }

                // Target 域的全局事件:按 targetId 路由或广播
                if (msg.method.startsWith('Target.')) {
                    const targetId = msg.params?.targetInfo?.targetId || msg.params?.targetId;
                    if (targetId) {
                        // 找到拥有这个 tab 的客户端
                        for (const [ws, state] of clientState) {
                            if (state.tabs.has(targetId)) {
                                safeSend(ws, msg);
                                return;
                            }
                        }
                    }
                    // 找不到归属,广播
                    for (const [ws] of clientState) {
                        safeSend(ws, msg);
                    }
                    return;
                }

                // 其他无 sessionId 的事件,广播
                for (const [ws] of clientState) {
                    safeSend(ws, msg);
                }
                return;
            }

            // 其他消息,广播
            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 断开,5秒后重连...');
        chromeReady = false;
        chromeWs = null;
        reconnecting = false;
        // 清理所有 session 归属(Chrome 重启后 session 失效)
        sessionOwners.clear();
        setTimeout(() => connectToChrome(), 5000);
    });

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

// ═══════════════════════════════════════════
// HTTP 端点
// ═══════════════════════════════════════════

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 客户端处理
// ═══════════════════════════════════════════

const wss = new WebSocketServer({ server });

wss.on('connection', (clientWs, req) => {
    const state = getOrCreateState(clientWs);
    console.log(`[Proxy] 客户端连接 (共 ${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());

            // 拦截抢焦点命令
            if (BLOCKED_METHODS.has(msg.method)) {
                console.log(`[Proxy] 拦截: ${msg.method}`);
                const reply = { id: msg.id, result: {} };
                if (msg.sessionId) reply.sessionId = msg.sessionId;
                safeSend(clientWs, reply);
                return;
            }

            // 强制后台创建 tab
            if (msg.method === 'Target.createTarget') {
                if (!msg.params) msg.params = {};
                msg.params.background = true;
                console.log(`[Proxy] 后台创建 tab: ${msg.params.url || 'about:blank'}`);
            }

            // ── ID 重映射 ──
            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 = () => {
        // 清理这个客户端的 session 归属
        for (const sid of state.sessions) {
            sessionOwners.delete(sid);
        }
        // 清理未完成的请求
        for (const pid of state.proxyIds) {
            pendingRequests.delete(pid);
        }
        clientState.delete(clientWs);
        console.log(`[Proxy] 客户端断开 (剩 ${clientState.size} 个, 释放 ${state.tabs.size} tab, ${state.sessions.size} session)`);
    };

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

// ═══════════════════════════════════════════
// 启动
// ═══════════════════════════════════════════

process.on('uncaughtException', (err) => {
    console.error('[Proxy] 异常(已恢复):', err.message);
});
process.on('unhandledRejection', (reason) => {
    console.error('[Proxy] Promise 拒绝(已恢复):', reason);
});

server.listen(PROXY_PORT, () => {
    console.log(`
╔══════════════════════════════════════════════════════╗
║   CDP Background Proxy v3 — 持久连接 + 多Agent隔离  ║
║                                                      ║
║   Proxy:  http://127.0.0.1:${PROXY_PORT}                    ║
║                                                      ║
║   ✓ 持久连接(弹窗只出现一次)                       ║
║   ✓ 拦截 activateTarget / bringToFront               ║
║   ✓ 强制 createTarget background=true                ║
║   ✓ 请求 ID 重映射(防多客户端冲突)                 ║
║   ✓ 事件按 sessionId 路由(防多 Agent 互扰)         ║
║   ✓ 自动重连                                         ║
║                                                      ║
║   状态: http://127.0.0.1:${PROXY_PORT}/proxy/status          ║
╚══════════════════════════════════════════════════════╝
`);
    connectToChrome();
});
kill-old-chrome-mcp.sh — Safe process cleanup (click to expand)
#!/bin/bash
# kill-old-chrome-mcp.sh
# 只杀旧的 Chrome MCP 进程,保留最新的(当前 Claude Code session 的)
#
# 用法:bash ~/scripts/kill-old-chrome-mcp.sh

PIDS=$(ps aux | grep "[c]hrome-devtools-mcp" | grep -v watchdog | grep -v "npm exec" | awk '{print $2}')

if [ -z "$PIDS" ]; then
    echo "没有找到 chrome-devtools-mcp 进程"
    exit 0
fi

COUNT=$(echo "$PIDS" | wc -l | tr -d ' ')

if [ "$COUNT" -le 1 ]; then
    echo "只有 1 个 Chrome MCP 进程 (PID: $PIDS),无需清理"
    exit 0
fi

# 保留最新的(PID最大的),杀掉其他所有
NEWEST=$(echo "$PIDS" | sort -n | tail -1)
OLD_PIDS=$(echo "$PIDS" | sort -n | head -n -1)

echo "发现 $COUNT 个 Chrome MCP 进程"
echo "保留最新: PID $NEWEST"
echo "杀掉旧的: $OLD_PIDS"

for pid in $OLD_PIDS; do
    # 同时杀掉关联的 npm exec 和 watchdog 进程
    PARENT_PIDS=$(ps aux | grep -E "npm exec.*chrome-devtools|watchdog.*parent-pid=$pid" | grep -v grep | awk '{print $2}')
    kill $pid $PARENT_PIDS 2>/dev/null
    echo "  已杀: PID $pid (及关联进程)"
done

echo "✅ 清理完成,保留 PID $NEWEST"

Then make them executable:

mkdir -p ~/scripts ~/chrome-profiles/pids ~/chrome-profiles/logs
chmod +x ~/scripts/chrome-mcp-proxy.sh ~/scripts/kill-old-chrome-mcp.sh

Step 2: Add to your .mcp.json

{
  "chrome": {
    "command": "bash",
    "args": ["~/scripts/chrome-mcp-proxy.sh", "9401", "9222"]
  }
}

Important: Do NOT add http_proxy or https_proxy env vars to the chrome config. The script handles proxy bypass internally.

Step 3: Enable Chrome Remote Debugging

Open chrome://inspect/#remote-debugging in Chrome and check "Allow remote debugging for this browser instance" (one-time setup).

Start Claude Code. Click "Allow" on the Chrome popup once. Done — future sessions won't prompt again.

Troubleshooting

Symptom Fix
"Could not connect to Chrome" Check Chrome is running: pgrep -x "Google Chrome"
Proxy not responding curl -s --noproxy '*' http://127.0.0.1:9401/proxy/status
Multiple MCP processes piling up bash ~/scripts/kill-old-chrome-mcp.sh
Chrome popup keeps appearing Proxy may have died — it auto-restarts, just click "Allow" once

Requirements

  • Node.js 18+
  • macOS (uses DevToolsActivePort file path; Linux users: adjust the path in cdp-proxy.mjs)
  • Chrome with remote debugging enabled

Discussion

Sign in to join the discussion.
No comments yet. Be the first to share your thoughts.

Related Assets