Chrome MCP Background Proxy
Fixes three breaking issues that hit anyone running chrome-devtools-mcp against
their real, logged-in Chrome:
- Chrome 146+ remote-debugging consent popup — appears every time a fresh WebSocket attaches to the debug port. The persistent proxy holds one long-lived connection so the popup only ever fires once (and once again after a Chrome restart).
- Focus stealing —
chrome-devtools-mcpaggressively callsTarget.activateTargetandPage.bringToFront, yanking the foreground tab away while you're typing. The proxy intercepts both methods and forcesTarget.createTargetto run withbackground: true. - Multi-agent / multi-window conflicts — when several Claude Code windows
(or sub-agents) share one Chrome, request IDs collide and CDP events get
delivered to the wrong client. The proxy rewrites every request ID and
routes events back to the correct client by
sessionId/targetId.
Architecture
Claude Code (stdio)
→ chrome-mcp-proxy.sh
→ chrome-devtools-mcp (connects to the proxy's WebSocket)
→ cdp-proxy.mjs v3 (port 9401)
├─ Persistent WebSocket to Chrome (popup only fires once)
├─ Request-ID remapping (no cross-client collisions)
├─ Event routing by sessionId (multi-agent isolation)
└─ Blocks Target.activateTarget / Page.bringToFront
→ real Chrome (auto-discovered via DevToolsActivePort)Files
| File | Role |
|---|---|
chrome-mcp-proxy.sh |
MCP entry script — unsets local proxy env vars, ensures the persistent proxy is running (nohup + disown), then execs chrome-devtools-mcp@latest against the proxy. Wire this into ~/.mcp.json. |
cdp-proxy.mjs |
The persistent CDP proxy (Node 18+, only dep is ws). Holds one WebSocket to Chrome and multiplexes any number of MCP clients on top. |
kill-old-chrome-mcp.sh |
Safe cleanup — keeps the newest chrome-devtools-mcp process and kills the rest. Read the iron rules below before running. |
chrome-mcp-healthcheck.sh |
Pre-flight check (Chrome up? proxy up? CDP connected? process count sane?). Returns 0/1 and posts a Feishu card on failure. Wire it as a pre-step before any MCP-driven automation. |
chrome-mcp-watchdog.sh |
launchd-friendly self-healing loop with a 10-min Feishu alert cooldown. Auto-restarts the proxy and runs cleanup when it drifts. |
Setup
# 1. Drop the scripts into ~/scripts/ and chmod +x them
chmod +x ~/scripts/chrome-mcp-proxy.sh ~/scripts/kill-old-chrome-mcp.sh \
~/scripts/chrome-mcp-healthcheck.sh ~/scripts/chrome-mcp-watchdog.sh
# 2. One-time Chrome setup: open chrome://inspect/#remote-debugging
# and tick "Allow remote debugging for this browser instance"
# 3. Wire it into ~/.mcp.json (NO env proxy vars — the script unsets them):
# "chrome": {
# "command": "bash",
# "args": ["/Users/<you>/scripts/chrome-mcp-proxy.sh", "9401", "9222"]
# }
# 4. Sanity check
curl -s --noproxy '*' http://127.0.0.1:9401/proxy/status | jqYou should see "chromeConnected": true and the popup-once promise comes true:
new Claude Code windows reuse the existing proxy connection.
Iron rules (do not violate)
- Always go through the proxy. Never point
chrome-devtools-mcpat port 9222 directly — you lose popup suppression and focus protection. - Connect to the real Chrome (the one with your logins, extensions,
bookmarks). Never start an empty
--user-data-dir=/tmp/xChrome — Chrome 146+ requires a custom data dir for--remote-debugging-port, which kills your session state. - No HTTP_PROXY env vars anywhere near the MCP config. The script
unsets them; if your shell rc forces them back you'll fail to connect to127.0.0.1:9401. - Never kill the current session's MCP processes.
- The Claude Code → MCP pipe is stdio; killing the child = permanent loss
of
mcp__chrome__*tools for the running session. - When you need to clean up, check
lsof -nP -p <pid>for ESTABLISHED stdio first. Only kill processes with no live client. - "Newest = keep" is not safe — the active session is not always the
newest PID. Run
lsof -i :9401 -i :9402 | grep ESTABLISHEDfirst.
- The Claude Code → MCP pipe is stdio; killing the child = permanent loss
of
- The proxy is a long-lived service. Started via
nohup+disown— it survives Claude Code exits and reconnects to Chrome every 5s. Don't kill it casually.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
Could not connect to Chrome |
Chrome not running OR DevToolsActivePort missing |
open -a "Google Chrome", then check ~/Library/Application Support/Google/Chrome/DevToolsActivePort exists |
chromeConnected: false in /proxy/status |
Chrome restarted and the consent popup is waiting | Click the popup once — proxy auto-reattaches |
| Popup appears on every new Claude Code window | Proxy died last time | Restart it: unset http_proxy; nohup node ~/scripts/cdp-proxy.mjs --port 9401 --chrome-port 9222 &>~/chrome-profiles/logs/proxy-9401.log & disown |
| Commands slow / timing out | Session leak — old chrome-devtools-mcp clients didn't detach |
Idle clients auto-detach after 5 min (--idle-timeout 300000); to force, close the idle Claude Code window or /mcp disable chrome there |
| Multi-agent runs interfering | Proxy is < v3 | Check /proxy/status for sessions and clientDetails fields — if missing, restart the proxy to load v3 |
evaluate_script times out but /json/version returns 200 |
MCP CDP session is hung (heavy take_snapshot or large evaluate_script poisoned the channel) |
The proxy can't fix this — only /mcp reconnect or restart the Claude Code window helps |
Tested environment
- macOS 14+, Chrome 146/147 stable, Node 18+
chrome-devtools-mcp@latestfrom npm- Single Chrome + 3-5 concurrent Claude Code windows / sub-agents
License
MIT — copy, fork, modify freely.
#!/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
exec npx -y chrome-devtools-mcp@latest --browserUrl "http://127.0.0.1:${PROXY_PORT}"
#!/usr/bin/env node /**
- CDP Background Proxy v3 — 持久连接 + 多 Agent 隔离
- 持久 WebSocket 连接到 Chrome(弹窗只出现一次)
- 拦截 Target.activateTarget / Page.bringToFront(防抢焦点)
- 强制 createTarget background=true
- 请求 ID 重映射(防多客户端 ID 冲突)
- 事件按 sessionId 路由到对应客户端(防多 Agent 互扰)
- 自动重连
- 用法: 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 IDLE_TIMEOUT_MS = parseInt(getArg('--idle-timeout', '300000')); 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 保留) 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,
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 = [];
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
只杀旧的 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"
#!/bin/bash
Chrome MCP 健康检查(纯检查+飞书告警,不自动修复)
发帖前调用,有问题飞书通知 William 手动处理
返回: 0=健康 1=有问题(已发飞书)
PROXY_PORT=9401 FEISHU_WEBHOOK="https://open.feishu.cn/open-apis/bot/v2/hook/REPLACE-WITH-YOUR-WEBHOOK-ID"
export PATH="/usr/local/bin:/opt/homebrew/bin:/usr/sbin:/usr/bin:/bin:/sbin:$PATH" 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"
alert_feishu() {
local title="$1" detail="$2"
curl -s -o /dev/null --max-time 5
-H "Content-Type: application/json"
-d "{"msg_type":"interactive","card":{"header":{"title":{"tag":"plain_text","content":"⚠️ ${title}"},"template":"orange"},"elements":[{"tag":"div","text":{"tag":"plain_text","content":"${detail}"}},{"tag":"note","elements":[{"tag":"plain_text","content":"$(date '+%Y-%m-%d %H:%M:%S') · healthcheck"}]}]}}"
"$FEISHU_WEBHOOK" 2>/dev/null
}
ERRORS=""
检查1:Chrome 是否运行
DEVTOOLS_FILE="$HOME/Library/Application Support/Google/Chrome/DevToolsActivePort" if [ ! -f "$DEVTOOLS_FILE" ]; then ERRORS="${ERRORS}Chrome未运行(DevToolsActivePort不存在)\n" fi
检查2:Proxy 端口
if ! lsof -i :${PROXY_PORT} >/dev/null 2>&1; then ERRORS="${ERRORS}Proxy未运行(端口${PROXY_PORT}无监听)\n" else # 检查3:Proxy HTTP健康 STATUS=$(curl -s --max-time 3 "http://127.0.0.1:${PROXY_PORT}/proxy/status" 2>/dev/null) if [ $? -ne 0 ] || [ -z "$STATUS" ]; then ERRORS="${ERRORS}Proxy端口占用但HTTP无响应\n" else # 检查4:Proxy与Chrome连接(JSON可能有换行/空格) if ! echo "$STATUS" | tr -d ' \n' | grep -q '"chromeConnected":true' 2>/dev/null; then ERRORS="${ERRORS}Proxy运行但与Chrome的WebSocket断开\n" fi fi fi
检查5:MCP进程数
MCP_COUNT=$(pgrep -f "chrome-devtools-mcp" | wc -l | tr -d ' ') if [ "$MCP_COUNT" -gt 30 ]; then ERRORS="${ERRORS}chrome-devtools-mcp进程堆积(${MCP_COUNT}个)\n" fi
输出结果
if [ -n "$ERRORS" ]; then echo "UNHEALTHY" echo -e "$ERRORS" alert_feishu "Chrome MCP 异常,社媒发不了" "发帖前健康检查失败:\n${ERRORS}请手动处理后重试" exit 1 else echo "HEALTHY" exit 0 fi
#!/bin/bash
Chrome MCP 守护脚本 — 弹性巡检 + 飞书告警
launchd 每60秒触发,但脚本内部有冷却机制防止重复告警
PROXY_PORT=9401 CHROME_PORT=9222 LOG="/Users/wangkai/chrome-profiles/logs/watchdog.log" PROXY_SCRIPT="/Users/wangkai/scripts/cdp-proxy.mjs" PID_DIR="/Users/wangkai/chrome-profiles/pids" LOG_DIR="/Users/wangkai/chrome-profiles/logs" STATE_DIR="/Users/wangkai/chrome-profiles/watchdog-state" MAX_LOG_LINES=500 FEISHU_WEBHOOK="https://open.feishu.cn/open-apis/bot/v2/hook/REPLACE-WITH-YOUR-WEBHOOK-ID"
告警冷却:同类错误10分钟内不重复发
ALERT_COOLDOWN=600
export PATH="/usr/local/bin:/opt/homebrew/bin:/usr/sbin:/usr/bin:/bin:/sbin:$PATH" 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"
mkdir -p "$PID_DIR" "$LOG_DIR" "$STATE_DIR"
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" >> "$LOG" }
飞书告警(带冷却)
$1=告警类型key $2=标题 $3=详情
alert_feishu() { local key="$1" title="$2" detail="$3" local cooldown_file="${STATE_DIR}/alert_${key}"
# 冷却检查
if [ -f "$cooldown_file" ]; then
local last_alert=$(cat "$cooldown_file")
local now=$(date +%s)
local diff=$((now - last_alert))
if [ "$diff" -lt "$ALERT_COOLDOWN" ]; then
return 0 # 冷却中,跳过
fi
fi
# 发送飞书
local payload=$(cat <<EOFPAYLOAD{ "msg_type": "interactive", "card": { "header": { "title": {"tag": "plain_text", "content": "⚠️ Chrome MCP: ${title}"}, "template": "orange" }, "elements": [ {"tag": "div", "text": {"tag": "plain_text", "content": "${detail}"}}, {"tag": "note", "elements": [{"tag": "plain_text", "content": "$(date '+%Y-%m-%d %H:%M:%S') · watchdog"}]} ] } } EOFPAYLOAD )
local http_code
http_code=$(curl -s -o /dev/null -w "%{http_code}" \
--max-time 5 \
-H "Content-Type: application/json" \
-d "$payload" \
"$FEISHU_WEBHOOK" 2>/dev/null)
if [ "$http_code" = "200" ]; then
date +%s > "$cooldown_file"
log "ALERT: Feishu sent [${key}] ${title}"
else
log "ALERT_FAIL: Feishu HTTP ${http_code} for [${key}]"
fi}
日志轮转
if [ -f "$LOG" ] && [ "$(wc -l < "$LOG")" -gt "$MAX_LOG_LINES" ]; then tail -200 "$LOG" > "${LOG}.tmp" && mv "${LOG}.tmp" "$LOG" fi
===== 检查1:Chrome 是否在运行 =====
DEVTOOLS_FILE="$HOME/Library/Application Support/Google/Chrome/DevToolsActivePort" if [ ! -f "$DEVTOOLS_FILE" ]; then # Chrome 没开不算错误,静默跳过 exit 0 fi
===== 检查2:Proxy 是否健康 =====
PROXY_OK=false PROXY_ERR="" if lsof -i :${PROXY_PORT} >/dev/null 2>&1; then STATUS=$(curl -s --max-time 3 "http://127.0.0.1:${PROXY_PORT}/proxy/status" 2>/dev/null) if [ $? -eq 0 ] && [ -n "$STATUS" ]; then PROXY_OK=true else PROXY_ERR="端口${PROXY_PORT}占用但HTTP健康检查无响应" fi else PROXY_ERR="端口${PROXY_PORT}无进程监听" fi
if [ "$PROXY_OK" = false ]; then log "WARN: Proxy unhealthy — ${PROXY_ERR}, restarting..."
# 杀旧进程
if [ -f "${PID_DIR}/proxy-${PROXY_PORT}.pid" ]; then
kill "$(cat "${PID_DIR}/proxy-${PROXY_PORT}.pid")" 2>/dev/null
sleep 1
fi
lsof -ti :${PROXY_PORT} | xargs kill -9 2>/dev/null
sleep 1
# 重启
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
# 验证
if lsof -i :${PROXY_PORT} >/dev/null 2>&1; then
log "OK: Proxy restarted on port ${PROXY_PORT}"
alert_feishu "proxy_restart" "Proxy已自动重启" "原因: ${PROXY_ERR}\n结果: 重启成功,端口${PROXY_PORT}已恢复"
else
TAIL_LOG=$(tail -5 "${LOG_DIR}/proxy-${PROXY_PORT}.log" 2>/dev/null | tr '\n' ' ')
log "ERROR: Proxy restart failed"
alert_feishu "proxy_fail" "Proxy重启失败!" "原因: ${PROXY_ERR}\n重启后仍无法监听端口${PROXY_PORT}\n日志: ${TAIL_LOG}"
fifi
===== 检查3:清理累积进程 =====
MCP_COUNT=$(pgrep -f "chrome-devtools-mcp" | wc -l | tr -d ' ') if [ "$MCP_COUNT" -gt 5 ]; then log "WARN: ${MCP_COUNT} chrome-devtools-mcp processes, cleaning..." bash /Users/wangkai/scripts/kill-old-chrome-mcp.sh >> "$LOG" 2>&1 log "OK: Cleanup done (was ${MCP_COUNT})" alert_feishu "mcp_cleanup" "MCP进程堆积已清理" "清理前: ${MCP_COUNT}个chrome-devtools-mcp进程\n已自动清理保留最新" fi
===== 检查4:Proxy连接Chrome是否正常 =====
if [ "$PROXY_OK" = true ]; then # 检查 proxy status 里的连接状态 CHROME_CONNECTED=$(echo "$STATUS" | grep -o '"chromeConnected":true' 2>/dev/null) if [ -z "$CHROME_CONNECTED" ]; then log "WARN: Proxy running but Chrome connection lost" alert_feishu "chrome_disconn" "Proxy与Chrome断开" "Proxy在端口${PROXY_PORT}正常运行,但与Chrome的WebSocket连接已断开\nProxy会自动重连,如持续收到此告警请检查Chrome" fi fi
exit 0