为什么我一开始死磕“本地模式”却连浏览器都打不开?
上周三下午三点十七分,我盯着 VS Code 里第 17 次报错的终端窗口,手边咖啡凉透,心里只剩一个念头:这破 puppeteer-core 怎么连 localhost 都连不上?
Error: net::ERR_CONNECTION_REFUSED at http://localhost:9222/json
at navigate (/node_modules/puppeteer-core/lib/cjs/puppeteer/common/Frame.js:138:25)
我翻遍 Puppeteer 文档、Chrome 启动参数、Docker 网络配置,甚至重装了 Chromium……直到凌晨一点,偶然在 Claude Code 的 MCP 插件设置页底部发现一行小字:
⚠️ MCP Server 默认禁用本地进程 spawn(出于安全策略),需手动启用
mcp.allowLocalProcess=true
——原来不是 Chrome 没起来,是 MCP 根本没让它起!puppeteer-core 在等一个永远不会出现的调试端口。
那一刻我顿悟:MCP 的三种模式,根本不是“技术选型”,而是权限与上下文的分层契约。
- “谁在调”?是 IDE 插件、CI 脚本,还是用户点击的按钮?
- “在哪调”?是开发机、K8s Pod,还是客户浏览器里的 Web Worker?
- “以谁的身份调”?是 root、普通用户、还是被
seccomp锁死的 sandbox 用户?
这才是真正的设计原点。
下面这张对比表,是我贴在工位显示器边框上的速查便签(手写体,带咖啡渍):
| 场景 | 推荐模式 | 关键约束 | 我的便签原文 |
|---|---|---|---|
| 调试前端组件(如 Storybook 快照) | ✅ 本地模式 | 仅限本机,无网络访问权 | 本地=快但受限 |
| 批量爬取公开电商页面(含 JS 渲染) | ✅ 远程模式 | 需自维 Grid/Selenium,证书自己管 | 远程=自由但要管证书 |
| 渲染用户上传的 HTML 报表模板 | ✅ 沙箱模式 | DOM 可操作,但 fetch/open/print 全受白名单控制 | 沙箱=安全但没 DOM 操作权 |

本地模式:把浏览器当“自家打印机”,快但容易卡纸
给内部 BI 工具加“一键截图”时,我天真地认为 mcp-local-browser 就是开个 Chromium 窗口的事。结果在同事的 M1 Mac 上,脚本刚执行就弹出:
[0512/142219.123456:FATAL:zygote_host_impl_linux.cc(117)] No usable sandbox!
查了一圈才明白:Chromium 在 macOS ARM64 下默认要求 sandbox,但 mcp-local-browser 启动时没传 --no-sandbox —— 它压根不帮你加这个 flag。
临时解法?在启动前塞环境变量:
export CHROMIUM_FLAGS="--no-sandbox --disable-gpu --headless=new"
npx mcp-local-browser --config ./local-config.json
但治标不治本。后来我整理出本地模式三大“地域性故障”:
- Windows 路径硬编码:
mcp-local-browser默认找C:\Program Files\Google\Chrome\Application\chrome.exe,但很多企业 IT 部署的是 32 位版,实际路径是C:\Program Files (x86)\...→ 解法:永远用where chrome或注册表读取HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\chrome.exe - Linux 容器字体缺失:Alpine 镜像里没有中文字体,
page.pdf()输出全是方块 → 解法:apk add fonts-liberation ttf-dejavu(别信fontconfig,它不解决渲染) - 调试端口冲突:有人在
mcp-server启动命令里硬加--remote-debugging-port=9222,结果和已运行的 Chrome 冲突 → ❌ 绝对别碰!
✅ 必做动作:写个 check_browser_ready() 健康检查函数:
check_browser_ready() {
local timeout=30
while [ $timeout -gt 0 ]; do
if curl -s http://localhost:9222/json | jq -e '.[0].id' >/dev/null 2>&1; then
echo "✅ Browser ready on port 9222"
return 0
fi
sleep 1
((timeout--))
done
echo "❌ Browser failed to start after 30s" >&2
return 1
}
远程模式:租个“浏览器云主机”,自由但得自己配钥匙
给某零售客户做价格监控时,我们用 mcp-remote-browser 对接自建 Selenium Grid。首日上线,所有任务全挂——日志里只有模糊的 net::ERR_FAILED。
抓包后发现真相:curl -v https://price-api.com 直接报:
SSL certificate problem: unable to get local issuer certificate
原来我们用的 selenium/hub:4.11 镜像基于 Debian Bookworm,但 ca-certificates 包版本太老,不认 Let’s Encrypt 新根证书(ISRG Root X1)。apt update && apt install -y ca-certificates 重启容器后,世界清静了。
远程模式的坑,本质是“网络链路变长后,每个环节都可能掉链子”:
- URL 地址错乱:Selenium Hub 返回的
page.url是http://selenium-hub:4444/wd/hub/session/abc123/url,而客户端需要的是https://client-domain.com/report/abc123→ 解法:在 MCP 客户端拦截page.url,用正则替换内网地址为对外域名 - WebSocket 断连:高频滚动抓取时,30 秒超时太短 → 改环境变量:
MCP_REMOTE_TIMEOUT_MS=120000
✅ 必做验证:用 OpenSSL 检查证书链是否完整:
openssl s_client -connect grid.example.com:4444 -servername grid.example.com -showcerts 2>/dev/null | openssl x509 -noout -text | grep "Issuer\|Subject"
❌ 绝对别碰:在远程浏览器里执行 localStorage.clear()。因为每个 session 是独立的 Chrome 实例,clear() 只清当前 tab,下次新 session 又是全新 localStorage —— 白清。
🛠️ 我的 docker-compose.yml 关键片段(防 IP 白名单拦截):
services:
selenium-hub:
image: selenium/hub:4.11
environment:
- JAVA_OPTS=-Dwebdriver.chrome.whitelistedIps=
注:空字符串
whitelistedIps表示不限制,否则默认只允许127.0.0.1和localhost
沙箱模式:把浏览器关进“透明玻璃房”,安全但手脚被绑
最惊悚的一次:客户上传了一个 HTML 报表模板,我们用 mcp-sandbox-browser 渲染,结果所有 window.open() 全静默失败,fetch('https://api.example.com') 直接抛 TypeError: fetch is not allowed in sandbox context。
翻源码才发现:沙箱策略不是靠 CSP 头控制,而是硬编码在 sandbox-config.json 里:
{
"allowedOrigins": ["https://sandbox-api.example.com"],
"disabledApis": ["window.open", "window.print", "navigator.geolocation"]
}
更隐蔽的坑:
<iframe srcdoc="<script>document.write('hi')</script>">→ 触发SecurityError: Failed to execute 'write' on 'document'(沙箱禁止动态文档写入)window.print()不报错,也不弹窗,直接消失 → 因为沙箱禁用所有系统级 API,且无 fallback 日志
✅ 必做能力探测:注入 sandbox-checker.js 到沙箱 iframe:
// sandbox-checker.js
console.log("SharedArrayBuffer:", typeof SharedArrayBuffer !== 'undefined');
console.log("Permissions API:", await navigator.permissions.query({name:'geolocation'}).then(r=>r.state,()=>null));
❌ 千万别干:eval(userInput)。哪怕你加了 Content-Security-Policy: script-src 'none',原型污染仍可能绕过(比如篡改 Function.prototype.constructor)。
🛠️ 我的 workaround 流程:
- 用
DOMPurify.sanitize(html, {ALLOWED_TAGS: ['div','span','table']})过滤用户 HTML - 注入前,先加一层
sandbox="allow-scripts allow-same-origin"iframe - 用
MutationObserver监听iframe.contentDocument加载完成(比load事件更可靠,尤其对srcdoc)
我的终极选择逻辑:三句话定乾坤
上线前,我常问自己这三句话——它们比任何架构图都管用:
“本地模式”只用在:你完全控制机器 + 需要毫秒级响应 + 不碰外部网络
✅ 例:VS Code 插件实时渲染 Markdown 预览(延迟 >50ms 用户就感知卡顿)
❌ 例:爬取淘宝商品页(本地模式连不了淘宝 CDN,且会被风控)
“远程模式”只用在:你要跑真实网络请求 + 能自主维护服务 + 接受 1~3 秒延迟
✅ 例:每日凌晨跑 Selenium 自动化测试(可容忍重试、可升级 Grid)
❌ 例:用户提交表单后即时预览(延迟不可控,且运维成本高)
“沙箱模式”只用在:输入不可信 + 输出要展示给他人 + 宁可功能残缺也要零漏洞
✅ 例:在线代码编辑器的 HTML 预览(用户能写任意 JS,必须隔离)
❌ 例:内部数据看板(可信环境,沙箱反而拖慢渲染)
最后,血泪总结上线前三件事:
- 本地模式:用
strace -e trace=clone,execve,connect -p $(pgrep -f 'mcp-local-browser')抓进程行为,确认没意外connect()外网 - 远程模式:用
mitmproxy --mode upstream:https://your-grid.com:4444拦截全部流量,看有没有漏网的fetch()请求 - 沙箱模式:用
lsof -i :PORT | grep LISTEN确认沙箱进程没偷偷开监听端口(曾经有沙箱因 debug 模式暴露了 9229 端口!)

现在,每当我看到 ERR_CONNECTION_REFUSED,第一反应不再是查 Chrome 参数——而是摸出这张便签,对着三行手写字,深呼吸,再决定:这次,到底该让浏览器住进哪个房间?