Scripts2026年7月2日·6 分钟阅读

持久代理运维套件 (CDP Proxy v3 全脚本)

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

Agent 就绪

这个资产会安全暂存

这个资产会先安全暂存。复制的指令会要求 Agent 读取暂存文件,并在激活脚本、MCP 配置或全局配置前先确认。

Stage only · 19/100策略:需暂存
Agent 入口
任意 MCP/CLI Agent
类型
Mcp Config
安装
Stage only
信任
已验证发布者
入口
chrome-mcp-proxy.sh
安全暂存命令
npx -y tokrepo@latest install 6c14cd64-f501-4c74-841b-9730021ed94c --target codex

先暂存文件;激活前需要读取暂存 README 和安装计划。

#!/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 抢资源

讨论

登录后参与讨论。
还没有评论,来写第一条吧。

相关资产

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

完整运维 Skill — 持久代理/防抢焦点/多 Agent 隔离

生产级运维手册:cdp-proxy v3 持久 WebSocket 连接(解决 Chrome 146 远程调试弹窗)、防抢用户焦点、多 Agent 请求 ID 重映射与 sessionId 事件路由隔离、npx hang 排障、不直连 Chrome 的铁律与完整故障排查流程。

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