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 BrowserThe 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.shStep 2: Add to your .mcp.json
{
"chrome": {
"command": "bash",
"args": ["~/scripts/chrome-mcp-proxy.sh", "9401", "9222"]
}
}Important: Do NOT add
http_proxyorhttps_proxyenv 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
DevToolsActivePortfile path; Linux users: adjust the path incdp-proxy.mjs) - Chrome with remote debugging enabled