数据截至 (上游 commit fd0b7e1d9ed9)
第 2 章:浏览器与页面模型
这章讲什么: Chrome 是什么时候、以哪种方式被拿到的;页面在内部怎么被编号和跟踪;以及为什么这个 server 敢让模型写文件。
2.1 拿到一台浏览器:启动还 是接管
它要解决的小问题
用户可能想要三种东西:开一台干净的新 Chrome、接管自己正开着的那台 Chrome、或者连一台远程/容器里的 Chrome。三种的收尾语义完全不同——自己开的要负责关掉,别人的绝不能关。
两条路
分叉点在 src/index.ts:139-170:
有 browserUrl / wsEndpoint / autoConnect ?
│
├── 是 ──→ ensureBrowserConnected() src/browser.ts:47
│ browserMode = 'connected'
│ 退出时 disconnect() ← 用 户的 Chrome 继续活着
│
└── 否 ──→ ensureBrowserLaunched() src/browser.ts:279
browserMode = 'launched'
退出时 close() ← 子进程被回收
两个函数都先看 browser?.connected,已有连接就直接复用——browser 和 browserMode 是模块级变量(src/browser.ts:21-22),整个进程一台浏览器。
一个真实的时序坑
两处赋值都写成了"先 mode 后 browser",并配了注释(src/browser.ts:129-134、:285):
const connected = await puppeteer.connect(connectOptions);
browserMode = 'connected';
browser = connected;
原因:如果反过来,一个并发的 closeBrowser() 可能看到 browser 已设而 browserMode 还是 undefined,于是走到 disconnect() 分支——把一台自己启动的 Chrome 变成孤儿进程。
autoConnect:从 profile 目录里读端口
只给了 --userDataDir 没给地址时,代码去读该目录下的 DevToolsActivePort 文件(src/browser.ts:85-104),第一行是端口、第二行是路径,拼成 ws://127.0.0.1:<port><path>。读不到就报一句带修复建议的错:"检查 Chrome 是否在运行、是否开了远程调试,去 chrome://inspect/#remote-debugging 看看"。
启动参数里的几个决定
| 设置 | 值 | 为什么 |
|---|---|---|
userDataDir | ~/.cache/chrome-devtools-mcp/chrome-profile[-channel] | 默认持久化 profile,登录态跨会话保留;--isolated 可关掉 |
pipe | true | 用管道而非 websocket 通信 |
defaultViewport | null | 不强加视口,页面用真实窗口尺寸 |
handleDevToolsAsPage | true | DevTools 窗口本身也当页面看待,这是第 6 章读取"用户选中元素"的前提 |
| headless 时 | --screen-info={3840x2160} | 给无头模式一块大屏 |
| 总是加 | --hide-crash-restore-bubble | 挡掉崩溃恢复气泡,免得挡住页面 |
还有一个 targetFilter(src/browser.ts:24):过滤掉 chrome://、chrome-untrusted://(未开扩展时还有 chrome-extension://),但放行 chrome://newtab/ 和 chrome://inspect——因为它们可能是浏览器里唯一开着的页面,过滤掉就一个页面都没有了。
已经在跑的报错
启动失败且错误里带 "The browser is already running" 时,换成一句可操作的话(src/browser.ts:263-274):
The browser is already running for <dir>. Use --isolated to run multiple browser instances.
2.2 McpContext:浏览器级状态
职责
McpContext(src/McpContext.ts:81)持有一台浏览器上的所有跨页面状态:
| 状态 | 字段 | 说明 |
|---|---|---|
| 页面表 | #mcpPages: Map<Page, McpPage> | Puppeteer 页面 → 本项目的包装 |
| 当前选中页 | #selectedPage | 大多数工具的隐式目标 |
| 隔离上下文 | #isolatedContexts: Map<string, BrowserContext> | 名字 → 独立 cookie/存储空间 |
| 扩展 service worker | #extensionServiceWorkers | 编号为 sw-1、sw-2… |
| 性能追踪 | #isRunningTrace、#traceResults | 只保留最新一条 trace |
| 堆快照 | #heapSnapshotManager | 见第 6 章 |
| 路径沙箱 | #roots、#allowUnrestrictedPaths | 见 2.4 |
构造是私有的,只能走 McpContext.from(:198),因为 #init(:139)必须先跑:建页面快照、建扩展 worker 快照、订阅 targetcreated/targetdestroyed。
上下文什么时候重建
getContext() 里(src/index.ts:172):
if (context?.browser !== browser) {
context?.dispose(); // 摘监听、清页面、关堆快照 worker
context = await McpContext.from(browser, …, {reconnected: context !== undefined});
}
判据是"浏览器实例变了",不是"连接断了"。浏览器换了(重连出一个新实例),整个上下文重来。
重连通知
重建时带上 reconnected: true,consumeReconnectNotice()(src/McpContext.ts:455)会在下一次响应里吐一句一次性提示(src/McpResponse.ts:840-845):
Note: the browser was restarted or reconnected since the last call. Page ids have changed. Call list_pages to see open pages.
消费即清除。这样模型不会拿着旧 id 一头撞死。
2.3 页面 id:一个计数器的讲究
关键设计
// src/McpContext.ts:74-78
// Page ids are handed out from a process-wide counter so they stay unique
// across all contexts, in particular across browser reconnects.
let nextPageId = 1;
计数器是模块级的,不是实例级的。 意味着重连后新页面从 4、5、6 继续发号,而不是重新从 1 开始。
为什么重要:如果重新从 1 开始,模型手里那个"页面 1"会静默地指向另一个完全不相干的页面——点错东西且毫无征兆。现在它只会得到 getPageById 抛的 No page found(:433-439),一个响亮的失败远好过一个安静的错误。
页面表怎么维护
两条路径并存:
事件驱动(实时) 轮询快照(兜底)
browser.on('targetcreated') createPagesSnapshot()
│ │
▼ ▼
#createMcpPage(page) #fetchBrowserPages()
│ │
│ 剔除已消失的页面
│ │
└──────→ #mcpPages ←────────────┘
#createMcpPage(:521)幂等:同一个 Puppeteer Page 只会有一个 McpPage。
#fetchBrowserPages(:575)额外做两件事:按 experimentalDevToolsDebugging 决定要不要显示 devtools:// 页面;把 chrome-extension:// 的 page 型 target 也捞进来(它们不在 browser.pages() 里)。
选中页失效时的回退
createPagesSnapshot(:538)末尾有一段带注释的判断:
// Only fall back when the selected page is actually gone. Gating on
// `isClosed()` instead of `pages` membership avoids silently swapping a
// live page that is momentarily missing from the snapshot.
if ((!this.#selectedPage || this.#selectedPage.pptrPage.isClosed()) && pages[0]) { … }
判据是 isClosed() 而不是"是否在列表里"——页面可能只是暂时没被快照捞到,那不该换掉用户的选择。真的换了,会记在 #selectedPageFallback 里,响应中提示"之前选中的页面已关闭,现在选中页面 N"(src/McpResponse.ts:936-947)。
隔离上下文
new_page 的 isolatedContext 参数(src/tools/pages.ts:115)按名字复用/创建一个 Puppeteer BrowserContext——同名共享 cookie 与存储,不同名完全隔离。适合让 agent 同时以两个账号登录同一站点。
#getBrowserContextToNameMap(src/McpContext.ts:529)还会自动发现外部创建的隐身上下文,给它们编号 isolated-context-1、isolated-context-2……
dispose() 里有一条明确的注释:隔离上下文故意不关(:158-161)——要么整个浏览器会关,要么是断连,不该顺手销毁浏览器状态。