为什么我一开始死磕“本地模式”却连浏览器都打不开?

上周三下午三点十七分,我盯着 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 操作权

手写便签特写:本地=快但受限,远程=自由但要管证书,沙箱=安全但没 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.urlhttp://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.1localhost


沙箱模式:把浏览器关进“透明玻璃房”,安全但手脚被绑

最惊悚的一次:客户上传了一个 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 流程

  1. DOMPurify.sanitize(html, {ALLOWED_TAGS: ['div','span','table']}) 过滤用户 HTML
  2. 注入前,先加一层 sandbox="allow-scripts allow-same-origin" iframe
  3. MutationObserver 监听 iframe.contentDocument 加载完成(比 load 事件更可靠,尤其对 srcdoc

我的终极选择逻辑:三句话定乾坤

上线前,我常问自己这三句话——它们比任何架构图都管用:

“本地模式”只用在:你完全控制机器 + 需要毫秒级响应 + 不碰外部网络
✅ 例:VS Code 插件实时渲染 Markdown 预览(延迟 >50ms 用户就感知卡顿)
❌ 例:爬取淘宝商品页(本地模式连不了淘宝 CDN,且会被风控)

“远程模式”只用在:你要跑真实网络请求 + 能自主维护服务 + 接受 1~3 秒延迟
✅ 例:每日凌晨跑 Selenium 自动化测试(可容忍重试、可升级 Grid)
❌ 例:用户提交表单后即时预览(延迟不可控,且运维成本高)

“沙箱模式”只用在:输入不可信 + 输出要展示给他人 + 宁可功能残缺也要零漏洞
✅ 例:在线代码编辑器的 HTML 预览(用户能写任意 JS,必须隔离)
❌ 例:内部数据看板(可信环境,沙箱反而拖慢渲染)

最后,血泪总结上线前三件事:

  1. 本地模式:用 strace -e trace=clone,execve,connect -p $(pgrep -f 'mcp-local-browser') 抓进程行为,确认没意外 connect() 外网
  2. 远程模式:用 mitmproxy --mode upstream:https://your-grid.com:4444 拦截全部流量,看有没有漏网的 fetch() 请求
  3. 沙箱模式:用 lsof -i :PORT | grep LISTEN 确认沙箱进程没偷偷开监听端口(曾经有沙箱因 debug 模式暴露了 9229 端口!)

三模式对比思维导图:本地(闪电图标)、远程(云朵图标)、沙箱(盾牌图标)

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