ScriptsJul 2, 2026·6 min read

Chrome MCP

让 chrome-devtools-mcp 通过持久 CDP 代理连接你日常使用的真实浏览器(保留登录态/插件),解决 Chrome 146+ 每次连接弹允许远程调试、多 Agent 抢焦点/CDP session 冲突、npx -y spawn hang 等痛点。含 cdp-proxy.mjs v3 + Chrome/Chrome Beta/Arc 三套启动脚本 + 安全清理脚本 + 完整运维排障文档。

Agent ready

Safe staging for this asset

This asset is staged first. The copied prompt tells the agent to inspect the staged files and ask before activating scripts, MCP config, or global config.

Stage only · 19/100Policy: stage
Agent surface
Any MCP/CLI agent
Kind
Mcp Config
Install
Stage only
Trust
Verified publisher
Entrypoint
chrome-mcp-proxy.sh
Safe staging command
npx -y tokrepo@latest install 6c14cd64-f501-4c74-841b-9730021ed94c --target codex

Stages files first; activation requires review of the staged README and plan.

#!/bin/bash

Chrome MCP + 持久 Proxy

用法: chrome-mcp-proxy.sh [proxy端口] [chrome端口]

Proxy 保持和 Chrome 的持久连接,弹窗只出现一次

PROXY_PORT=${1:-9401} CHROME_PORT=${2:-9222} PROXY_SCRIPT="/Users/wangkai/scripts/cdp-proxy.mjs" PID_DIR="/Users/wangkai/chrome-profiles/pids" LOG_DIR="/Users/wangkai/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

直接调 npx cache binary,绕过 npx -y 在本机的 stdin hang bug (2026-05-26)

CDP_MCP_BIN="$HOME/.npm/_npx/a44f1c2f3c68e710/node_modules/.bin/chrome-devtools-mcp" if [ -x "$CDP_MCP_BIN" ]; then exec "$CDP_MCP_BIN" --browserUrl "http://127.0.0.1:${PROXY_PORT}" else exec npx -y chrome-devtools-mcp@0.19.0 --browserUrl "http://127.0.0.1:${PROXY_PORT}" fi

#!/bin/bash

Chrome Beta MCP + 持久 Proxy

用法: chrome-beta-mcp-proxy.sh [proxy端口] [chrome端口]

与普通 Chrome proxy 完全独立,互不干扰

PROXY_PORT=${1:-9402} CHROME_PORT=${2:-9222} PROXY_SCRIPT="/Users/wangkai/scripts/cdp-proxy.mjs" PID_DIR="/Users/wangkai/chrome-profiles/pids" LOG_DIR="/Users/wangkai/chrome-profiles/logs" DEVTOOLS_PORT_FILE="$HOME/Library/Application Support/Google/Chrome Beta/DevToolsActivePort"

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}
--devtools-port-file "${DEVTOOLS_PORT_FILE}"
&>"${LOG_DIR}/proxy-${PROXY_PORT}.log" & echo $! > "${PID_DIR}/proxy-${PROXY_PORT}.pid" disown sleep 2 fi

启动 chrome-devtools-mcp,连接到 Proxy

直接调 npx cache binary,绕过 npx -y 在本机的 stdin hang bug (2026-05-26)

CDP_MCP_BIN="$HOME/.npm/_npx/a44f1c2f3c68e710/node_modules/.bin/chrome-devtools-mcp" if [ -x "$CDP_MCP_BIN" ]; then exec "$CDP_MCP_BIN" --browserUrl "http://127.0.0.1:${PROXY_PORT}" else exec npx -y chrome-devtools-mcp@0.19.0 --browserUrl "http://127.0.0.1:${PROXY_PORT}" fi

#!/bin/bash

Arc MCP + 持久 Proxy

用法: arc-mcp-proxy.sh [proxy端口] [arc调试端口]

Arc 必须用 open -a "Arc" --args --remote-debugging-port=9223 启动,否则 Arc 不开外部 CDP

Arc 不会自动更新原生 DevToolsActivePort 文件,本脚本通过 /json/version 抓 wsPath 写一份给 cdp-proxy 用

PROXY_PORT=${1:-9501} ARC_DEBUG_PORT=${2:-9223} PROXY_SCRIPT="/Users/wangkai/scripts/cdp-proxy.mjs" PID_DIR="/Users/wangkai/chrome-profiles/pids" LOG_DIR="/Users/wangkai/chrome-profiles/logs" ARC_DEVTOOLS_PORT_FILE="/Users/wangkai/chrome-profiles/arc-DevToolsActivePort"

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"

从 Arc 的 /json/version 抓真实 wsPath,写入合成 DevToolsActivePort 文件供 cdp-proxy 解析

WS_URL=$(curl -s --noproxy '*' "http://127.0.0.1:${ARC_DEBUG_PORT}/json/version" 2>/dev/null
| python3 -c "import sys,json; print(json.load(sys.stdin)['webSocketDebuggerUrl'])" 2>/dev/null)

if [ -n "$WS_URL" ]; then WS_PATH=$(echo "$WS_URL" | sed 's|ws://[^/]*||') printf '%s\n%s\n' "${ARC_DEBUG_PORT}" "${WS_PATH}" > "${ARC_DEVTOOLS_PORT_FILE}" fi

if ! lsof -i :${PROXY_PORT} >/dev/null 2>&1; then nohup node "${PROXY_SCRIPT}"
--port ${PROXY_PORT}
--chrome-port ${ARC_DEBUG_PORT}
--devtools-port-file "${ARC_DEVTOOLS_PORT_FILE}"
&>"${LOG_DIR}/proxy-${PROXY_PORT}.log" & echo $! > "${PID_DIR}/proxy-${PROXY_PORT}.pid" disown sleep 2 fi

直接调 npx cache binary,绕过 npx -y 在本机的 stdin hang bug (2026-05-26)

CDP_MCP_BIN="$HOME/.npm/_npx/a44f1c2f3c68e710/node_modules/.bin/chrome-devtools-mcp" if [ -x "$CDP_MCP_BIN" ]; then exec "$CDP_MCP_BIN" --browserUrl "http://127.0.0.1:${PROXY_PORT}" else exec npx -y chrome-devtools-mcp@0.19.0 --browserUrl "http://127.0.0.1:${PROXY_PORT}" fi

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

  • CDP Background Proxy v3 — 持久连接 + 多 Agent 隔离
    1. 持久 WebSocket 连接到 Chrome(弹窗只出现一次)
    1. 拦截 Target.activateTarget / Page.bringToFront(防抢焦点)
    1. 强制 createTarget background=true
    1. 请求 ID 重映射(防多客户端 ID 冲突)
    1. 事件按 sessionId 路由到对应客户端(防多 Agent 互扰)
    1. 自动重连
  • 用法: 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'); // idle reaper 默认 30min(AI 生成等待 4-5min 是常态,5min 默认太短会把 session 砍掉, // 表现为 chrome MCP 反复掉线)。传 --idle-timeout 0 可以完全禁用。 const IDLE_TIMEOUT_MS = parseInt(getArg('--idle-timeout', '1800000')); const IDLE_CHECK_INTERVAL_MS = parseInt(getArg('--idle-check', '60000'));

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(), lastActivityAt: Date.now(), }); } return clientState.get(clientWs); }

// 空闲客户端自动回收:idle 超阈值则 detach 其全部 session(WebSocket 保留)。 // 传 --idle-timeout 0 完全禁用(运维场景下,长时间等待 AI 生成不希望 session 被砍)。 if (IDLE_TIMEOUT_MS > 0) setInterval(() => { if (!chromeReady) return; const now = Date.now(); for (const [, state] of clientState) { const idleMs = now - state.lastActivityAt; if (idleMs < IDLE_TIMEOUT_MS || state.sessions.size === 0) continue;

    const sids = Array.from(state.sessions);
    console.log(`[Proxy] 空闲回收: idle=${Math.floor(idleMs / 1000)}s, detach ${sids.length} sessions`);

    for (const sid of sids) {
        cdpRequest('Target.detachFromTarget', { sessionId: sid }).catch(() => {});
        sessionOwners.delete(sid);
        state.sessions.delete(sid);
    }
}

}, IDLE_CHECK_INTERVAL_MS);

// ═══════════════════════════════════════════ // 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);
        }
    }
});

const handleChromeDisconnect = (reason) => {
    if (!chromeReady && chromeWs === null) return; // 已处理过
    console.log(`[Proxy] Chrome 断开 (${reason}),立即释放 ${pendingRequests.size} 个 pending 请求,5秒后重连...`);
    chromeReady = false;
    chromeWs = null;
    reconnecting = false;

    // 关键修复:给所有 pending 请求立即发 error,让客户端 Promise 立即 reject
    // 否则 chrome-devtools-mcp 的 Promise 永远悬空 → 后续命令排队僵死
    for (const [, req] of pendingRequests) {
        safeSend(req.clientWs, {
            id: req.originalId,
            error: { code: -32000, message: `Chrome disconnected (${reason}), request aborted` },
        });
    }
    pendingRequests.clear();

    // Chrome 重启后旧 session / tab 全部失效,清理客户端 state(保留 WebSocket)
    sessionOwners.clear();
    for (const [, state] of clientState) {
        state.sessions.clear();
        state.tabs.clear();
        state.proxyIds.clear();
    }

    setTimeout(() => connectToChrome(), 5000);
};

chromeWs.on('close', () => handleChromeDisconnect('close'));

chromeWs.on('error', (err) => {
    console.error('[Proxy] Chrome 错误:', err.message);
    handleChromeDisconnect('error');
});

}

// ═══════════════════════════════════════════ // 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,
                id: t.targetId,
                targetId: t.targetId,
                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 });
            const targetId = result.result?.targetId;
            res.writeHead(200, { 'Content-Type': 'application/json' });
            res.end(JSON.stringify({
                ...(result.result || {}),
                id: targetId,
                targetId,
                type: 'page',
                url: 'about:blank',
                webSocketDebuggerUrl: targetId
                    ? `ws://${CHROME_HOST}:${PROXY_PORT}/devtools/page/${targetId}`
                    : undefined,
            }));
        } catch (e) { res.writeHead(500); res.end(e.message); }
        return;
    }

    if (req.url === '/proxy/status') {
        const clientList = [];
        const now = Date.now();
        for (const [, state] of clientState) {
            clientList.push({
                tabs: state.tabs.size,
                sessions: state.sessions.size,
                idleMs: now - state.lastActivityAt,
            });
        }
        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) => {
    state.lastActivityAt = Date.now();
    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 互扰) ║ ║ ✓ 自动重连 ║ ║ ✓ 空闲客户端自动回收 session ║ ║ ║ ║ 状态: http://127.0.0.1:${PROXY_PORT}/proxy/status ║ ╚══════════════════════════════════════════════════════╝); console.log([Proxy] 空闲回收阈值: ${IDLE_TIMEOUT_MS / 1000}s, 检查间隔: ${IDLE_CHECK_INTERVAL_MS / 1000}s); connectToChrome(); });

#!/bin/bash

kill-old-chrome-mcp.sh

Conservative Chrome MCP cleanup.

The previous implementation kept the largest PID and killed the rest. That is

unsafe in Codex because multiple live MCP children can share the same app-server

parent; killing the wrong one closes the current tool transport.

Usage:

bash ~/scripts/kill-old-chrome-mcp.sh [proxy-port]

Only kills chrome-devtools-mcp processes that are:

1. connected to the requested proxy port in their command line,

2. not currently ESTABLISHED to that proxy port, and

3. older than STALE_SECONDS.

set -u

PORT="${1:-9401}" STALE_SECONDS="${STALE_SECONDS:-300}"

elapsed_seconds() { local pid="$1" local etime rest days a b c h m s

etime="$(ps -p "$pid" -o etime= 2>/dev/null | tr -d ' ')"
[ -z "$etime" ] && return 1

days=0
rest="$etime"
if [[ "$rest" == *-* ]]; then
    days="${rest%%-*}"
    rest="${rest#*-}"
fi

IFS=: read -r a b c <<EOF

$rest EOF

if [ -n "${c:-}" ]; then
    h="$a"
    m="$b"
    s="$c"
elif [ -n "${b:-}" ]; then
    h=0
    m="$a"
    s="$b"
else
    h=0
    m=0
    s="$a"
fi

case "${days}${h}${m}${s}" in
    ''|*[!0-9]*)
        return 1
        ;;
esac

echo $((days * 86400 + h * 3600 + m * 60 + s))

}

PIDS=() while IFS= read -r pid; do [ -n "$pid" ] && PIDS+=("$pid") done < <( ps -axo pid=,command= | awk -v port="$PORT" ' /chrome-devtools-mcp/ && /--browserUrl http://127.0.0.1:/ && $0 !~ /watchdog/ && $0 ~ ("--browserUrl http://127.0.0.1:" port) { print $1 } ' )

if [ "${#PIDS[@]}" -eq 0 ]; then echo "No chrome-devtools-mcp processes found for port ${PORT}" exit 0 fi

echo "Found ${#PIDS[@]} chrome-devtools-mcp process(es) for port ${PORT}" KILLED=0 KEPT=0

for pid in "${PIDS[@]}"; do STATE="$(ps -p "$pid" -o state= 2>/dev/null | tr -d ' ')" ETIMES="$(elapsed_seconds "$pid" || true)"

if [ -z "$STATE" ] || [ -z "$ETIMES" ]; then
    echo "  keep PID ${pid}: cannot determine state or age"
    KEPT=$((KEPT + 1))
    continue
fi

if lsof -nP -a -p "$pid" -iTCP:"$PORT" -sTCP:ESTABLISHED >/dev/null 2>&1; then
    echo "  keep PID ${pid}: active TCP connection to ${PORT}"
    KEPT=$((KEPT + 1))
    continue
fi

if [ "$ETIMES" -lt "$STALE_SECONDS" ]; then
    echo "  keep PID ${pid}: disconnected but only ${ETIMES}s old"
    KEPT=$((KEPT + 1))
    continue
fi

WATCHDOG_PIDS="$(
    ps -axo pid=,command= |
        awk -v parent="$pid" '/watchdog/ && $0 ~ ("--parent-pid=" parent) { print $1 }'
)"

kill "$pid" $WATCHDOG_PIDS 2>/dev/null || true
echo "  killed stale PID ${pid} (age ${ETIMES}s, no TCP connection to ${PORT})"
KILLED=$((KILLED + 1))

done

echo "Cleanup complete: kept ${KEPT}, killed ${KILLED}"


name: Chrome MCP 完整运维 Skill description: Chrome MCP 架构、配置、排障、铁律 — 涵盖 proxy v3 防抢焦点、持久连接、多 Agent 隔离、Chrome 146 弹窗解决方案 type: reference originSessionId: 2a80b6e4-fcb0-4098-a88a-252be006699b

Chrome MCP 完整运维 Skill

架构总览

Claude Code (stdio)
  → chrome-mcp-proxy.sh
    → chrome-devtools-mcp (连接 proxy 的 WebSocket)
      → cdp-proxy.mjs v3 (port 9401)
          ├── 持久 WebSocket 连接到 Chrome(弹窗只出现一次)
          ├── 请求 ID 重映射(防多客户端冲突)
          ├── 事件按 sessionId 路由(防多 Agent 互扰)
          └── 拦截抢焦点命令
        → 真实 Chrome (通过 DevToolsActivePort 文件连接)

核心原则:必须通过 proxy 中间层,绝不直连 Chrome。

铁律(绝不可违反)

1. 必须经过 proxy

  • proxy (cdp-proxy.mjs) 拦截 Target.activateTargetPage.bringToFront,防止浏览器抢用户焦点
  • 强制 createTarget 在后台创建 tab(background: true
  • .mcp.json 中 chrome 配置必须用 chrome-mcp-proxy.sh,不能用 chrome-devtools-mcp 直连

2. 必须连接真实浏览器

  • 用户日常使用的 Chrome(有登录态、插件、书签)
  • Chrome 需在 chrome://inspect/#remote-debugging 中开启远程调试(一次性设置)
  • proxy 通过读取 ~/Library/Application Support/Google/Chrome/DevToolsActivePort 文件自动连接
  • 绝对禁止--user-data-dir=/tmp/xxx~/.cache/chrome-devtools-mcp/chrome-profile 启动无状态 Chrome

3. 不走网络代理

  • .mcp.json 中 chrome 服务不配 env 代理变量
  • chrome-mcp-proxy.sh 脚本内部已 unset 所有代理环境变量
  • 本机 zshrc 有全局代理 (http_proxy=127.0.0.1:7897),会拦截本地连接,所以必须 unset

4. 绝不杀当前 session 的 MCP 进程

  • Chrome MCP 是 Claude Code 通过 stdio 管道启动的子进程
  • 进程一死,管道断裂,当前 session 永远无法恢复
  • 清理多实例只用安全脚本: bash ~/scripts/kill-old-chrome-mcp.sh

5. proxy 是持久服务

  • proxy 用 nohup + disown 启动,不随 Claude Code 退出而死
  • 启动时立即建立到 Chrome 的持久 WebSocket 连接
  • 所有 Claude Code 会话共用这一条连接
  • Chrome 断开后自动每 5 秒重连

6. 绝不在 MCP server 配置里用 npx -y chrome-devtools-mcp@xxx(2026-05-26 新增)

  • 本机的 npx -y 在 MCP host stdio spawn 环境下会永久 hang,根本不 spawn binary,导致 30s timeout
  • 同样症状跨所有版本:@latest (1.0.x) / @0.19.0 都中招
  • 触发条件不明,疑为 ~/.npm/_locks/ stale lock 或 npm registry metadata 探测被某状态阻塞
  • 正确写法:3 个 proxy 脚本末尾的 fallback 模板
    CDP_MCP_BIN="$HOME/.npm/_npx/a44f1c2f3c68e710/node_modules/.bin/chrome-devtools-mcp"
    if [ -x "$CDP_MCP_BIN" ]; then
        exec "$CDP_MCP_BIN" --browserUrl "http://127.0.0.1:${PROXY_PORT}"
    else
        exec npx -y chrome-devtools-mcp@0.19.0 --browserUrl "http://127.0.0.1:${PROXY_PORT}"
    fi
  • 任何新 Agent 的 MCP 配置(Codex / Cursor / Cline / 别的)都要走 proxy 脚本,不要直接配 command="npx"

Chrome 146+ 远程调试弹窗问题

问题

Chrome 146 开始,外部程序通过 WebSocket 连接调试端口时,Chrome 会弹出"要允许远程调试吗?"授权窗口。每次新建 WebSocket 连接都会弹一次。

解决方案:持久连接

cdp-proxy.mjs v3 在启动时就建立一条持久连接到 Chrome,所有客户端共用。弹窗只在以下情况出现:

  • proxy 首次启动时(点一次"允许")
  • Chrome 重启后 proxy 重连时(点一次"允许")

之后新开 Claude Code 窗口不再弹窗,因为复用已有连接。

前置条件

Chrome 地址栏打开 chrome://inspect/#remote-debugging,勾选 Allow remote debugging for this browser instance(一次性设置)。

无效方案(已验证不可行)

方案 结果
--remote-debugging-port=9222 Chrome 146 要求 --user-data-dir 为非默认目录,丢失登录态
--silent-debugger-extension-api 对远程调试弹窗无效
--disable-features=AutomationControlled 对弹窗无效
defaults write com.google.Chrome DevToolsRemoteDebuggingAllowed Chrome 不读取
macOS managed preferences plist Chrome 不读取
.mobileconfig 配置描述文件 Chrome 不读取
devtools.remote_debugging.allowed in Local State 无效
chrome-devtools-mcp --autoConnect 仍然弹窗
锁定 chrome-devtools-mcp 旧版本 (0.19.0) 无效

多 Agent 隔离(v3 新增)

多个 Agent(或 sub-agent)同时操作 Chrome 时,proxy v3 保证互不干扰:

机制 说明
请求 ID 重映射 每个客户端的请求 ID 统一映射为全局唯一 ID,响应精准路由回原客户端
事件按 sessionId 路由 Target.attachToTarget 的 session 归属记录到对应客户端,后续该 session 的事件只发给该客户端
Tab 归属追踪 Target.createTarget 的响应记录 tab 归属,Target 域的全局事件按 targetId 路由
客户端断开清理 客户端断开时清理其 session 归属、未完成请求、tab 记录
空闲自动回收 客户端 idle 超 5min(--idle-timeout 300000)→ 对其全部 session 发 Target.detachFromTarget,WebSocket 保留,下次操作时 chrome-devtools-mcp 自动重新 attach(无感)

架构上,所有 Agent 共享同一个 Chrome、同一个 proxy,各自创建后台 tab,通过不同的 sessionId/targetId 操作,互不干扰。

配置文件

~/.mcp.json(chrome 部分)

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

注意:不要加 env 代理变量!

~/.claude/settings.json(权限)

MCP 工具权限不能用通配符 mcp__chrome__*(会导致整个 settings 文件被跳过),必须逐一列出:

"permissions": {
  "allow": [
    "mcp__chrome__click",
    "mcp__chrome__close_page",
    "mcp__chrome__take_screenshot",
    ...
  ]
}

~/.claude/settings.local.json(MCP 启用)

同样需要逐一列出 MCP 工具名,不能用通配符。

关键脚本

脚本 路径 用途
chrome-mcp-proxy.sh ~/scripts/chrome-mcp-proxy.sh MCP 启动入口(普通 Chrome 9222 ↔ proxy 9401):unset 代理 → 启动持久 proxy(nohup+disown) → 优先调 cache binary,fallback npx(2026-05-26 改)
chrome-beta-mcp-proxy.sh ~/scripts/chrome-beta-mcp-proxy.sh 同上,Chrome Beta ↔ proxy 9402
arc-mcp-proxy.sh ~/scripts/arc-mcp-proxy.sh 同上,Arc 9223 ↔ proxy 9501(Arc 必须用 open -a Arc --args --remote-debugging-port=9223 启动)
cdp-proxy.mjs ~/scripts/cdp-proxy.mjs CDP 持久代理 v3:持久连接 + ID 重映射 + 事件路由 + 拦截抢焦点 + 自动重连
kill-old-chrome-mcp.sh ~/scripts/kill-old-chrome-mcp.sh ⚠️ macOS BSD head -n -1 bug,等效 no-op(2026-05-26 发现,待修)。临时用 inline ps + sort + tail 替代

关键文件

文件 路径 用途
DevToolsActivePort ~/Library/Application Support/Google/Chrome/DevToolsActivePort Chrome 自动写入,包含调试端口和 WebSocket 路径
proxy 日志 ~/chrome-profiles/logs/proxy-9401.log proxy 运行日志
proxy PID ~/chrome-profiles/pids/proxy-9401.pid proxy 进程 PID

排障流程

症状:Chrome MCP 工具报错 "Could not connect to Chrome"

步骤1: 检查 Chrome 是否在运行

pgrep -x "Google Chrome" && echo "running" || echo "not running"

没运行就启动:open -a "Google Chrome"

步骤2: 检查 DevToolsActivePort 文件

cat ~/Library/Application\ Support/Google/Chrome/DevToolsActivePort

步骤3: 检查 proxy 状态

curl -s --noproxy '*' http://127.0.0.1:9401/proxy/status

应该看到 chromeConnected: true。如果是 false,Chrome 可能需要点一次"允许"弹窗。

步骤4: 用 /mcp 重连

步骤5: 安全清理旧进程

bash ~/scripts/kill-old-chrome-mcp.sh

步骤6: 最后手段 — 退出重启 Claude Code

症状:每次开新 Claude Code 都弹远程调试弹窗

检查 proxy 是否在持久运行:

curl -s --noproxy '*' http://127.0.0.1:9401/proxy/status

如果 proxy 没在运行,说明上次退出时被杀了。手动启动:

unset http_proxy HTTP_PROXY https_proxy HTTPS_PROXY all_proxy ALL_PROXY
nohup node ~/scripts/cdp-proxy.mjs --port 9401 --chrome-port 9222 &>~/chrome-profiles/logs/proxy-9401.log &
disown

然后在 Chrome 点一次"允许",之后就不再弹了。

症状:感觉 "DevTools 协议被占满"(命令变慢/超时)

/proxy/statussessions 数,如果远大于 tabs 总数,说明有客户端 session 泄漏(老 Claude Code 窗口关的时候 chrome-devtools-mcp 没 detach 干净)。

  • v3.1 后 proxy 自带空闲回收:idle > 5min 自动 detach 全部 session
  • 想立即清理:关掉闲置 Claude Code 窗口(触发 clientDisconnect),或在闲置窗口里 /mcp disable chrome
  • 重启 proxy 是最暴力的清理(但所有窗口都要重连)

症状:多 Agent 操作时互相干扰

检查 proxy 版本是否为 v3:

curl -s --noproxy '*' http://127.0.0.1:9401/proxy/status

v3 会返回 sessionsclientDetails 字段。如果没有,需要重启 proxy 加载新版 cdp-proxy.mjs。

症状:/mcp reconnect 30 秒 timeout,但 proxy/status 显示 chromeConnected: true(2026-05-26 新增 — npx -y hang)

特征

  • Claude Code 交互窗口 /mcp 重连 chrome / beta / arc 任一 server → connection timed out after 30000ms
  • proxy /proxy/status 显示 chromeConnected: true, clients: 0
  • proxy 日志没有任何"客户端连接"事件,新 MCP client 从未到达 proxy
  • chrome-devtools-mcp 孤儿进程数量正常 / 全清也不解决
  • Codex 那边 chrome MCP 同样症状

根因npx -y chrome-devtools-mcp@xxx 在 MCP host stdio spawn 环境下永久 hang,根本不 spawn binary。npx 卡在某个 metadata / lock 阶段不返回。

快速判定(30 秒)

# 直接调 cache binary 测 handshake
BIN=$(find ~/.npm/_npx -name "chrome-devtools-mcp" -type l -path "*/node_modules/.bin/*" 2>/dev/null | head -1)
cat <<'EOF' > /tmp/mcp-init.txt
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}
{"jsonrpc":"2.0","method":"notifications/initialized"}
EOF
timeout 10 bash -c "(cat /tmp/mcp-init.txt; sleep 8) | $BIN --browserUrl http://127.0.0.1:9402" | head -3
# 秒回 initialize response = binary OK,proxy OK,问题在 npx
# 同样测 `npx -y chrome-devtools-mcp@0.19.0` 30s 没输出 = npx hang 确诊

修复:3 个 proxy 脚本末尾改为"优先 cache binary,fallback npx"(见铁律 §6 模板)。改完 /mcp reconnect 立即秒连。

禁忌

  • 不要再尝试杀 zombie / 重启 proxy / 改 chrome-devtools-mcp 版本 — 这些都不是根因
  • 不要直接配 command="npx" 在任何 agent 的 mcp config 里 — 同坑

症状:所有工具都报 "No page selected"(chrome-devtools-mcp 0.24.0 init 死锁)

特征

  • proxy /proxy/status 显示 chromeConnected: trueclients ≥ 1,但对应 client sessions: 0, tabs: 0
  • Chrome curl http://127.0.0.1:9402/json/list 能看到真实 page targets
  • list_pages / new_page / select_page 全部报 No page selected

根因(chrome-devtools-mcp 0.24.0 代码逻辑):

  • index.js:210 每个 tool call 先调 getSelectedMcpPage(),没选中就抛 No page selected
  • 唯一会 auto-select 的路径是 McpContext.#init()createPagesSnapshot(),但只有 puppeteer init 当时能看到 page 才会 select
  • Chrome 重启 + chrome-devtools-mcp spawn 时序冲突 → puppeteer init 时 0 page → 永久死锁
  • 没有事件监听器(browser.on('targetcreated') 不存在),后续 Chrome 开 tab 也不会触发刷新

修复:必须重启 chrome-devtools-mcp 让 puppeteer 在 Chrome 已有 page 时重 init

  1. 确认 Chrome 已有真实 page:curl -s http://127.0.0.1:9402/json/list | jq '.[] | select(.type=="page")'
  2. Claude Code 里 /mcp 把对应 server(如 beta)disable 再 enable
  3. 验证:调 list_pages 应返回 [selected] 标记的 page

禁忌

  • 不要直接 kill chrome-devtools-mcp 进程,stdio 管道断了 Claude Code 不会自动 respawn
  • /mcp 是唯一干净的重启路径

历史事故教训

日期 事故 教训
2026-03-15 手动 kill 全部进程导致 session 失效 只用安全脚本清理
2026-03-24 Chrome 更新到 146,远程调试开始弹窗 改用持久连接 proxy,弹窗只出现一次
2026-03-24 settings.json 用 mcp__chrome__* 通配符导致整个文件被跳过 MCP 权限必须逐一列出工具名
2026-03-24 --remote-debugging-port 在 Chrome 146 要求非默认 user-data-dir 不能用此方式,会丢失登录态
2026-03-24 macOS managed preferences / mobileconfig 设置 Chrome 策略 Chrome 不读取这些策略,无效
2026-03-24 proxy v2 多客户端共用连接时请求 ID 冲突 v3 加入 ID 重映射 + sessionId 事件路由
2026-05-05 Chrome Beta 重启 + chrome-devtools-mcp 0.24.0 spawn 同时发生,puppeteer init 时 0 page → 所有工具永久报 No page selected 必须 /mcp disable+enable 重启 MCP server,让 puppeteer 在 Chrome 已稳定有 page 时重 init
2026-05-26 /mcp reconnect beta 永久 30s timeout,proxy 健康。误诊 4 轮(zombie / Codex @latest / proxy 状态污染 / 都不是)。真因:npx -y chrome-devtools-mcp@0.19.0 在本机会永久 hang,根本不 spawn binary 3 个 proxy 脚本末尾全部改为"优先调 cache binary,fallback npx"(铁律 §6);同步建立"Codex 控普通 Chrome 9401 / Claude Code 控 Chrome Beta 9402"的物理隔离方案。诊断教训:proxy 健康 + 进程清干净后仍 timeout,第一时间剥层测试 bash → npx → binary 三层 spawn,别在 proxy/Codex 层耗时间

多 Agent 物理隔离方案(2026-05-26 推荐)

为避免多 agent(Claude Code / Codex / Cursor / ...)抢同一个 proxy 撞 init 死锁、版本冲突、CDP session 池竞争,采用物理隔离

Agent 控制的浏览器 proxy 端口 proxy 脚本 server 名(在该 agent 的 mcp config 里)
Claude Code Chrome Beta 9402 chrome-beta-mcp-proxy.sh 9402 9222 beta
Claude Code(可选) 普通 Chrome 9401 chrome-mcp-proxy.sh 9401 9222 chrome
Codex 普通 Chrome 9401 chrome-mcp-proxy.sh 9401 9222 chrome
任何新 Agent 给它分一个 proxy 端口 + Chrome 实例 9403 / 9404 / ... 复制并改端口 自起

原则

  1. 一个 proxy 端口对应一个浏览器实例
  2. 多个 agent 可以共用同一 proxy(proxy v3 多 client 设计),但仅当确定它们用同一版本 chrome-devtools-mcp 且都走 binary(不走 npx -y
  3. 业务场景天然区分时(如 Codex 跑业务页 / Claude Code 跑社媒页),直接物理隔离最稳

配置示例(Codex ~/.codex/config.toml):

[mcp_servers.chrome]
# 通过脚本启动,绝不直接 npx
command = "bash"
args = ["/Users/wangkai/scripts/chrome-mcp-proxy.sh", "9401", "9222"]

反例(永远不要这么写):

[mcp_servers.chrome]
command = "npx"
args = ["-y", "chrome-devtools-mcp@latest", "--browserUrl", "http://127.0.0.1:9402"]
# ❌ 1. npx -y 在本机 hang(铁律 §6)
# ❌ 2. @latest 是 1.0.x,有 0.24.0+ init 死锁(§历史事故 2026-05-05)
# ❌ 3. 共享 9402 跟 Claude Code beta 抢资源

Discussion

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

Related Assets

Codex Chrome MCP Proxy v3

Sanitized public Chrome MCP bundle for Codex: persistent CDP proxy, real Chrome login-state control, background tabs, focus protection, multi-agent isolation, and cached chrome-devtools-mcp startup fallback.

ScriptsConfigs
henuwangkai

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

Persistent CDP proxy + entry script that lets chrome-devtools-mcp run against your real, logged-in Chrome without the Chrome 146+ consent popup spamming on every connection, without focus stealing (Target.activateTarget / Page.bringToFront are intercepted, createTarget is forced to background), and without request-ID / event collisions when multiple Claude Code windows or sub-agents share one Chrome. Includes the proxy core (cdp-proxy.mjs v3), entry script, safe cleanup, pre-flight healthcheck, and a launchd-style self-healing watchdog with Feishu alerts.

MCP ConfigsScripts
henuwangkai· ⭐ 1

Chrome MCP

Chrome DevTools MCP

MCP Configs
henuwangkai

Chrome MCP Operations Runbook — Iron Rules, Architecture & Troubleshooting

Operations skill for running chrome-devtools-mcp against your real Chrome at scale. Covers the proxy architecture, five iron rules (always-via-proxy, real-browser-only, no-env-proxy, never-kill-current-session, persistent-proxy), Chrome 146+ remote-debug popup workaround, multi-agent isolation guarantees, configuration recipes for ~/.mcp.json and ~/.claude/settings.json (with the 'no glob in permissions' gotcha), step-by-step troubleshooting flow, and four field notes from real incidents — port cleanup heuristics that backfire, protocol-layer hang detection, why 'newest = keep' is wrong, and why heavy pages need filePath-first take_snapshot to avoid 25k token overflow. Pairs with the 'Chrome MCP Background Proxy' script bundle.

SkillsMCP Configs
henuwangkai