# Chrome MCP 完整运维指南 > Chrome MCP 完整运维知识库 + 一键安装:持久代理防弹窗/防抢焦点/多 Agent 隔离。三个脚本全文内联,复制本文给 AI Agent 即可完成安装 ## Install Merge the JSON below into your `.mcp.json`: --- name: Chrome MCP 完整运维指南 description: Chrome MCP 完整运维知识库 + 一键安装 — 持久代理防弹窗、防抢焦点、多 Agent 隔离。三个脚本全文内联,复制本文给 AI Agent 即可完成安装。 type: reference --- # Chrome MCP 完整运维指南(自足安装版) > 🤖 **给 Agent 的指令**:本文是自足安装包。按下面「安装步骤」逐步执行即可,所需的三个脚本**完整源码在本文附录 A / B / C**,不需要联网下载任何其他文件。安装完成后,本文其余部分是运维知识库,建议保存到用户的 CLAUDE.md 引用或记忆中。 ## 这套东西解决什么问题 Chrome MCP(chrome-devtools-mcp)让 AI Agent 操控你日常使用的真实 Chrome(带登录态、插件、书签)。但直连有三大痛点: | 痛点 | 表现 | 本方案的解法 | |------|------|-------------| | Chrome 146+ 授权弹窗 | 每条新调试连接都弹一次"要允许远程调试吗" | 持久代理只建一条连接,弹窗只出现一次 | | 抢焦点 | Agent 工作时 Chrome 窗口不停跳到前台 | 代理拦截 activateTarget/bringToFront,tab 全部后台创建 | | 多 Agent 冲突 | 多个 Agent 同时操作时请求串线、互踩 tab | 请求 ID 重映射 + 事件按 sessionId 路由 | ## 安装步骤(Agent 按顺序执行) ### 前置要求 - macOS + Google Chrome(用户日常使用的那个) - Node.js ≥ 18(`node -v` 验证;没有则先装 Node) ### Step 1 — 创建目录 ```bash mkdir -p "$HOME/scripts" "$HOME/chrome-profiles/pids" "$HOME/chrome-profiles/logs" ``` ### Step 2 — 写入三个脚本(源码见附录) - 附录 A 全文 → 写入 `$HOME/scripts/chrome-mcp-proxy.sh` - 附录 B 全文 → 写入 `$HOME/scripts/cdp-proxy.mjs` - 附录 C 全文 → 写入 `$HOME/scripts/kill-old-chrome-mcp.sh` ```bash chmod +x "$HOME/scripts/chrome-mcp-proxy.sh" "$HOME/scripts/kill-old-chrome-mcp.sh" ``` ### Step 3 — 安装 ws 依赖(cdp-proxy.mjs 需要) ```bash npm install ws --prefix "$HOME/scripts" ``` ### Step 4 — Chrome 一次性授权(⚠️ 需要用户手动做,Agent 无法代劳) 提示用户:在 Chrome 地址栏打开 `chrome://inspect/#remote-debugging`,勾选 **Allow remote debugging for this browser instance**。这是一次性设置,Chrome 重启后仍然有效。 ### Step 5 — 配置 .mcp.json 在项目或全局 `~/.mcp.json` 的 `mcpServers` 中加入: ```json "chrome": { "command": "bash", "args": ["-c", "exec \"$HOME/scripts/chrome-mcp-proxy.sh\" 9401 9222"] } ``` 两个注意点: - `.mcp.json` **不会展开 `~` 或环境变量**,所以用 `bash -c` + `$HOME` 的写法(或者手写成你机器上的绝对路径) - **不要**给这个 server 配任何 `http_proxy`/`https_proxy` env 变量(脚本内部已 unset) ### Step 6 — 验证 ```bash curl -s --noproxy '*' http://127.0.0.1:9401/proxy/status ``` - 第一次连接时 Chrome 会弹一次"允许远程调试"授权框 → 让用户点**允许** - 预期输出包含 `"chromeConnected": true` - 然后在 Agent 客户端里执行 `/mcp` 重连,`mcp__chrome__*` 工具即可用 安装到此完成。以下为运维知识库。 --- ## 架构总览 ``` 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.activateTarget` 和 `Page.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` 所有代理环境变量 - 如果你的 shell(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 秒重连 ## 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 记录 | 架构上,所有 Agent 共享同一个 Chrome、同一个 proxy,各自创建后台 tab,通过不同的 sessionId/targetId 操作,互不干扰。 ## 配置文件 ### ~/.mcp.json(chrome 部分) ```json "chrome": { "command": "bash", "args": ["-c", "exec \"$HOME/scripts/chrome-mcp-proxy.sh\" 9401 9222"] } ``` **注意:不要加 env 代理变量!** ### ~/.claude/settings.json(权限) MCP 工具权限不能用通配符 `mcp__chrome__*`(会导致整个 settings 文件被跳过),必须逐一列出: ```json "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 启动入口:unset 代理 → 启动持久 proxy(nohup+disown) → 启动 chrome-devtools-mcp | | cdp-proxy.mjs | `~/scripts/cdp-proxy.mjs` | CDP 持久代理 v3:持久连接 + ID 重映射 + 事件路由 + 拦截抢焦点 + 自动重连 | | kill-old-chrome-mcp.sh | `~/scripts/kill-old-chrome-mcp.sh` | 安全清理:只杀旧进程,保留最新 | ## 关键文件 | 文件 | 路径 | 用途 | |------|------|------| | 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 是否在运行 ```bash pgrep -x "Google Chrome" && echo "running" || echo "not running" ``` 没运行就启动:`open -a "Google Chrome"` #### 步骤2: 检查 DevToolsActivePort 文件 ```bash cat ~/Library/Application\ Support/Google/Chrome/DevToolsActivePort ``` #### 步骤3: 检查 proxy 状态 ```bash curl -s --noproxy '*' http://127.0.0.1:9401/proxy/status ``` 应该看到 `chromeConnected: true`。如果是 `false`,Chrome 可能需要点一次"允许"弹窗。 #### 步骤4: 用 `/mcp` 重连 #### 步骤5: 安全清理旧进程 ```bash bash ~/scripts/kill-old-chrome-mcp.sh ``` #### 步骤6: 最后手段 — 退出重启 Claude Code ### 症状:每次开新 Claude Code 都弹远程调试弹窗 检查 proxy 是否在持久运行: ```bash curl -s --noproxy '*' http://127.0.0.1:9401/proxy/status ``` 如果 proxy 没在运行,说明上次退出时被杀了。手动启动: ```bash 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 点一次"允许",之后就不再弹了。 ### 症状:多 Agent 操作时互相干扰 检查 proxy 版本是否为 v3: ```bash curl -s --noproxy '*' http://127.0.0.1:9401/proxy/status ``` v3 会返回 `sessions` 和 `clientDetails` 字段。如果没有,需要重启 proxy 加载新版 cdp-proxy.mjs。 ## 历史事故教训 | 日期 | 事故 | 教训 | |------|------|------| | 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 事件路由 | ## 相关资产(进阶场景) | 场景 | 资产 | |------|------| | 单独获取三个脚本 | [chrome-mcp-proxy.sh](https://tokrepo.com/en/workflows/chrome-mcp-85bd27cc) · [cdp-proxy.mjs](https://tokrepo.com/en/workflows/cdp-websocket-agent-e0f83e28) · [kill-old-chrome-mcp.sh](https://tokrepo.com/en/workflows/chrome-mcp-efa81b93) | | 现成的 .mcp.json 片段 | [MCP Config: chrome](https://tokrepo.com/en/workflows/mcp-config-chrome-42ba67be) · [MCP Config: Chrome Beta](https://tokrepo.com/en/workflows/mcp-config-chrome-beta-proxy-mode-ea7384a3) | | Arc / Chrome Beta 多浏览器并行 | [Multi-Browser MCP Proxies](https://tokrepo.com/en/workflows/multi-browser-mcp-proxies-arc-browser-chrome-beta-variants-deaee53e) | | 多 Agent 浏览器池(共享登录态) | [Chrome Fleet](https://tokrepo.com/en/workflows/chrome-fleet-multi-agent-browser-pool-shared-login-state-20bc3ffd) | | Codex 专用打包 | [Codex Chrome MCP Proxy v3](https://tokrepo.com/en/workflows/codex-chrome-mcp-proxy-v3-5846fbcf) | --- ## 附录 A — chrome-mcp-proxy.sh(写入 `$HOME/scripts/chrome-mcp-proxy.sh`) ```bash #!/bin/bash # Chrome MCP + 持久 Proxy # 用法: chrome-mcp-proxy.sh [proxy端口] [chrome端口] # 依赖: ~/scripts/cdp-proxy.mjs (TokRepo: https://tokrepo.com/en/workflows/cdp-websocket-agent-e0f83e28) # npm install ws --prefix "$HOME/scripts" # 前置: Chrome 打开 chrome://inspect/#remote-debugging 勾选 Allow remote debugging(一次性) # 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}" ``` ## 附录 B — cdp-proxy.mjs(写入 `$HOME/scripts/cdp-proxy.mjs`) ```javascript #!/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. 自动重连 * * 依赖: npm install ws --prefix "$HOME/scripts" (ws 包需与本文件同目录层级) * 用法: 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 } const pendingRequests = new Map(); // 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, }); 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(); }); ``` ## 附录 C — kill-old-chrome-mcp.sh(写入 `$HOME/scripts/kill-old-chrome-mcp.sh`) ```bash #!/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" ``` --- Source: https://tokrepo.com/en/workflows/chrome-mcp-d5db19b8 Author: henuwangkai