摘要
这篇文章拆的是 ChromeDevTools/chrome-devtools-mcp。
我这次观察到的源码状态是:
| 项目 | 观察值 |
|---|---|
| 官方仓库 | ChromeDevTools/chrome-devtools-mcp |
| 许可证 | Apache-2.0 |
| npm 包 | chrome-devtools-mcp |
| npm latest | 0.23.0 |
| npm registry 更新时间 | 2026-04-22T17:10:54.833Z |
| 本文观察源码 commit | c9c1683b67ac927a5cd5178692d1de7d8e06617b |
| 源码 package version | 0.23.0 |
| 观察日期 | 2026-04-26 |
一句话概括它的实现:
Chrome DevTools MCP 是一个本地 MCP stdio server。它用 Puppeteer 启动或连接 Chrome,把页面、网络、控制台、截图、可访问性树、Trace 和 Lighthouse 结果包装成一组面向 Agent 的工具;复杂的 DevTools 分析能力则直接复用 chrome-devtools-frontend 里的模型和 formatter。
这篇文章不把 README 再讲一遍,而是按源码拆它的几个核心问题:
- MCP server 是怎样启动并接到 stdio 的
- 它为什么选择 Puppeteer 做浏览器控制层
- 工具是怎样注册、过滤、串行执行和格式化输出的
- 页面快照里的
uid为什么比坐标点击更适合 Agent - 性能 Trace 为什么能直接产出 DevTools 风格的 insight
- 本地 CLI / daemon、telemetry、测试和打包分别承担什么角色
下面所有代码片段都是基于源码的裁剪讲解版:
- 不原样搬运完整源码
- 只保留表达设计意图的主干
- 每一行都写中文注释
- 真实源码以文中的 commit 链接为准
0. 先把几个词对齐
读这个仓库前,先把几个边界分清。
| 术语 | 在这个项目里的含义 |
|---|---|
| MCP | Model Context Protocol,Agent 通过它发现工具、调用工具、读取返回内容 |
| stdio transport | MCP client 和这个 server 的默认通信方式,进程标准输入输出就是协议通道 |
| CDP | Chrome DevTools Protocol,Chrome 暴露给 DevTools 和自动化工具的底层调试协议 |
| Puppeteer | 这个项目主要使用的浏览器控制库,负责 launch、connect、page、locator、tracing、screenshot 等动作 |
| DevTools frontend | chrome-devtools-frontend 包,项目复用其中的 TraceEngine、formatter、issue aggregator、source map 能力 |
| a11y tree | 页面可访问性树,take_snapshot 用它生成给 Agent 看的文本快照和元素 uid |
| slim mode | --slim 模式,只暴露 navigate、evaluate、screenshot 三个轻量工具 |
这个项目最值得学的地方不是“它能点浏览器”,而是它做了一层很明确的工程翻译:
把 Chrome / DevTools 原本给人类开发者使用的调试能力,翻译成 LLM 可以稳定调用、可以分页阅读、可以引用元素、可以保存大文件的工具协议。
1. 启动链路:bin 很薄,真正入口在 createMcpServer
MCP server 的命令入口是 src/bin/chrome-devtools-mcp.ts。
这个文件只做三件事:
- 设置进程标题。
- 检查 Node 版本,要求 Node 20.19、22.12 或更新维护线。
- 动态加载
chrome-devtools-mcp-main.js。
真正启动 server 的逻辑在 src/bin/chrome-devtools-mcp-main.ts。裁剪后可以理解成这样:
await checkForUpdates("update hint"); // 启动前检查 npm registry 是否有新版本,并只打印提示
const args = parseArguments(VERSION); // 用 yargs 解析浏览器、分类、telemetry、slim 等命令行参数
const logFile = args.logFile ? saveLogsToFile(args.logFile) : undefined; // 如果用户指定日志文件,就把 debug 日志写进去
const {server, clearcutLogger} = await createMcpServer(args, {logFile}); // 根据参数创建 MCP server 和可选 telemetry logger
const transport = new StdioServerTransport(); // 创建 MCP stdio transport,让父进程通过 stdin/stdout 通信
await server.connect(transport); // 把 MCP server 挂到 stdio transport 上,等待 client 调用工具
void clearcutLogger?.logServerStart(computeFlagUsage(args, cliOptions)); // 异步记录 server 启动和 flag 使用情况
这里有两个关键判断。
第一,chrome-devtools-mcp 默认不是 HTTP server,也不是 WebSocket server,而是本地 stdio MCP server。Claude Code、Codex、Gemini CLI、Cursor 这类 MCP client 启动它之后,工具调用都发生在这个子进程通道里。
第二,浏览器不是启动时立刻创建。createMcpServer() 里定义了 getContext(),但真正调用 ensureBrowserLaunched() 或 ensureBrowserConnected() 是在工具第一次执行时。这样 listTools 这种只需要工具 schema 的操作不会提前拉起 Chrome。
2. 浏览器层:不是手写 CDP,而是把 Puppeteer 当执行底座
浏览器连接逻辑集中在 src/browser.ts。
它分成两条路径。
| 路径 | 触发参数 | 行为 |
|---|---|---|
| connect | --browser-url、--ws-endpoint、--auto-connect、部分 userDataDir 场景 |
连接已有 Chrome |
| launch | 默认路径、--channel、--executable-path、--headless、--isolated |
启动新的 Chrome |
这层没有自己实现 CDP 握手,而是调用 Puppeteer:
puppeteer.connect()连接远端调试地址puppeteer.launch()启动 ChrometargetFilter过滤掉大多数chrome://、chrome-untrusted://和默认关闭的 extension 页面handleDevToolsAsPage: true让 DevTools 窗口也能作为页面目标被识别- 默认 user data dir 放在
$HOME/.cache/chrome-devtools-mcp/chrome-profile... --isolated会使用临时 profile,关闭浏览器后清掉状态
这解释了它和普通 Playwright / Selenium 工具的一个差异:它更关心“调试一个真实 Chrome 会话”,而不只是“跑一段浏览器自动化脚本”。
它的浏览器选择逻辑可以裁剪成这样:
if (serverArgs.browserUrl || serverArgs.wsEndpoint || serverArgs.autoConnect) { // 如果用户提供已有浏览器入口
browser = await ensureBrowserConnected(connectOptions); // 通过 Puppeteer 连接已有 Chrome
} else { // 如果没有已有浏览器入口
browser = await ensureBrowserLaunched(launchOptions); // 通过 Puppeteer 启动新的 Chrome
} // 浏览器获取流程结束
if (context?.browser !== browser) { // 如果当前上下文还没有绑定这个 browser
context = await McpContext.from(browser, logger, contextOptions); // 基于 browser 构建页面、网络、控制台和 DevTools 上下文
} // 上下文复用或重建结束
这里没有复杂的账号鉴权层。它主要依赖本地进程边界和用户显式传入的 Chrome 调试地址。如果用户用 --ws-endpoint 连接需要鉴权的远端 WebSocket,可以通过 --ws-headers 传自定义 header;如果用户打开了一个没有保护的 remote debugging port,风险就落回 Chrome 调试端口本身。
3. 工具注册:模块很多,但统一收敛到 ToolDefinition
工具定义的类型在 src/tools/ToolDefinition.ts。
每个工具大概都有这些字段:
namedescriptionannotations.categoryannotations.readOnlyHintschemahandler
其中 schema 使用 zod 描述参数,MCP SDK 会把它暴露给 client。definePageTool() 会额外打上 pageScoped: true,表示这个工具需要一个当前页面,比如 click、fill、take_snapshot、list_network_requests。
工具集合由 src/tools/tools.ts 汇总:
| 模块 | 代表工具 |
|---|---|
pages.ts |
list_pages、new_page、navigate_page、select_page |
input.ts |
click、fill、hover、press_key、drag |
snapshot.ts |
take_snapshot、wait_for |
network.ts |
list_network_requests、get_network_request |
console.ts |
list_console_messages、get_console_message |
performance.ts |
performance_start_trace、performance_stop_trace、performance_analyze_insight |
lighthouse.ts |
lighthouse_audit |
script.ts |
evaluate_script |
screenshot.ts |
take_screenshot |
extensions.ts |
extension 安装、卸载、触发、查看 |
memory.ts |
heap snapshot 相关实验工具 |
inPage.ts / webmcp.ts |
页面自己暴露的工具和实验 WebMCP 工具 |
createTools() 做的事情很直接:
const rawTools = args.slim ? Object.values(slimTools) : allToolModules; // slim 模式只取三件套,普通模式取完整工具集合
const tools = rawTools.map(tool => typeof tool === "function" ? tool(args) : tool); // 支持根据 CLI 参数动态生成工具定义
tools.sort((a, b) => a.name.localeCompare(b.name)); // 按名称排序,让工具列表稳定可测
return tools; // 返回最终工具定义数组
真正注册时还有一层过滤,逻辑在 src/index.ts:
for (const tool of createTools(serverArgs)) { // 遍历根据当前参数生成的所有工具
registerTool(tool); // 按分类开关、实验条件和 pageScoped 规则注册到 MCP server
} // 工具注册结束
await loadIssueDescriptions(); // 加载 DevTools issue 描述文件,供控制台和问题格式化使用
registerTool() 里最重要的不是 server.registerTool() 本身,而是它外面包了一层运行时治理。
它会做这些事:
- 按
--no-category-performance、--no-category-network等参数关掉部分工具。 - 按
experimentalVision、experimentalMemory、experimentalWebmcp等开关决定是否暴露实验工具。 - 对 page-scoped 工具,在实验 page id routing 下把
pageId合并进 schema。 - 用
Mutex串行化工具调用,避免多个工具同时操作同一浏览器状态。 - 每次调用前懒加载
McpContext。 - 每次调用后把
McpResponse转成 MCPcontent,必要时带structuredContent。 - 捕获异常,转换成 MCP tool error。
- 记录工具调用耗时和成功率 telemetry。
裁剪后的 handler 外壳可以写成这样:
const guard = await toolMutex.acquire(); // 获取全局工具锁,避免并发点击、导航和截图互相打架
try { // 开始执行工具主体
const context = await getContext(); // 懒启动或复用 Chrome,并拿到 MCP 运行上下文
const response = new McpResponse(serverArgs); // 创建统一响应收集器,后面负责格式化页面、快照和日志
const page = context.getSelectedMcpPage(); // 对 page-scoped 工具,取当前选中的页面
await tool.handler({params, page}, response, context); // 把工具参数、页面和上下文交给具体工具实现
return await response.handle(tool.name, context); // 把响应收集器转换成 MCP content 和 structuredContent
} finally { // 无论成功还是失败都要释放锁
guard.dispose(); // 释放工具锁,让下一个工具调用继续
} // 工具 handler 外壳结束
这个设计很工程化:各个工具文件只关心“我要做什么”,而分类过滤、页面路由、错误转换、输出格式、telemetry 都收在统一外壳里。
4. McpContext:把浏览器状态变成 Agent 可理解的上下文
McpContext 是运行时核心。
它不是一个简单的 browser wrapper,而是维护了几类状态:
| 状态 | 作用 |
|---|---|
browser |
Puppeteer Browser 实例 |
#pages |
当前可暴露给 Agent 的页面列表 |
#mcpPages |
Puppeteer Page 到 McpPage 的映射 |
#selectedPage |
当前工具默认操作的页面 |
#networkCollector |
按页面收集 network request |
#consoleCollector |
按页面收集 console、uncaught error、DevTools issue |
#devtoolsUniverseManager |
为页面创建 DevTools Universe,用于 source map、issue、stack trace |
#isolatedContexts |
支持通过工具创建隔离浏览器上下文 |
#traceResults |
保存最近一次性能 trace 解析结果 |
#heapSnapshotManager |
解析和分页读取 heap snapshot |
页面不是直接交给工具使用,而是包成 McpPage。
McpPage 负责每个页面自己的状态:
- 当前 text snapshot
uid -> AXNode的映射- 额外 DOM handle
- emulation 设置
- dialog 状态
- DevTools page
- in-page tools
waitForEventsAfterAction()等动作等待逻辑
这层包装的意义是:工具不要直接面对杂乱的 Puppeteer Page,而是面对一个已经有页面 ID、快照、等待策略、弹窗状态和元素映射的 Agent 页面对象。
5. 快照与输入:先用 a11y tree 定位,再用 Locator 执行
Agent 点页面有两种常见方式。
一种是坐标。坐标直观,但对窗口大小、滚动位置、DPR、布局变化非常敏感。
另一种是语义元素引用。Chrome DevTools MCP 更偏向这一种:先用 take_snapshot 读取页面可访问性树,再给节点分配 uid,后续 click、fill、hover 都用 uid 找回元素。
快照逻辑在 src/TextSnapshot.ts,入口工具在 src/tools/snapshot.ts。
核心流程可以裁剪成这样:
const rootNode = await page.pptrPage.accessibility.snapshot(snapshotOptions); // 从 Chrome 获取当前页面的可访问性树
const backendNodeId = node.backendNodeId; // 读取 AXNode 背后的 DOM backend node id
const uniqueBackendId = `${node.loaderId}_${backendNodeId}`; // 用 loaderId 和 backendNodeId 组合出跨快照唯一键
const id = uniqueBackendNodeIdToMcpId.get(uniqueBackendId) ?? `${snapshotId}_${idCounter++}`; // 复用旧 uid,或为新节点生成 uid
idToNode.set(id, nodeWithId); // 建立 uid 到快照节点的反查表,供后续 click 和 fill 使用
return new TextSnapshot({root: rootNodeWithId, idToNode, snapshotId}); // 返回可格式化、可反查的页面文本快照
后续 click 工具在 src/tools/input.ts 里走的是这条链:
const handle = await request.page.getElementByUid(uid); // 根据 snapshot uid 找回真实 DOM ElementHandle
await request.page.waitForEventsAfterAction(async () => { // 执行动作后等待可能发生的导航和 DOM 稳定
await handle.asLocator().click({count: dblClick ? 2 : 1}); // 用 Puppeteer Locator 点击元素,而不是裸坐标点击
}); // 点击和等待流程结束
void handle.dispose(); // 释放 ElementHandle,避免长期持有页面对象
waitForEventsAfterAction() 来自 src/WaitForHelper.ts。它做了两件很关键的事:
- 监听
Page.frameStartedNavigating,如果动作触发导航,就等 navigation 完成。 - 注入
MutationObserver,等 DOM 在短时间内稳定后再返回。
这也是为什么这个项目的工具不是简单包一层 Puppeteer API。它针对 Agent 使用场景补了“动作后验证窗口”:模型点击之后通常马上要继续观察,如果不等导航和 DOM 稳定,下一步看到的很可能是半更新状态。
6. 网络、控制台和响应输出:先收集,再按 Agent 阅读方式格式化
网络和控制台数据不是每次调用工具时临时全量抓取,而是由 PageCollector 挂在页面事件上持续收集。
NetworkCollector 监听 request,并按主 frame navigation 切分历史。默认返回当前导航以来的请求,也支持保留最近 3 次导航的历史。
ConsoleCollector 更复杂一些。它不仅收集 console message,还通过 CDP 监听 Runtime.exceptionThrown,并用 DevTools 的 issue aggregator 把浏览器 issue 也整理进控制台数据。
这层数据最后不会直接作为大 JSON 扔给模型,而是进入 McpResponse。
McpResponse 的职责可以理解成输出层:
- 追加普通文本行
- 附加截图 image content
- 根据需要生成页面列表
- 根据需要生成 snapshot
- 按 id 展开 network request 详情
- 按 id 展开 console message 详情
- 给 network / console / heap snapshot 做分页
- 把 trace summary 和 trace insight 格式化成文本
- 在
experimentalStructuredContent下同步返回结构化对象 - 对大截图或 trace 等重资产返回文件路径,而不是把所有字节塞进上下文
这和仓库里的 Design Principles 是一致的:输出要同时给人类和机器读,大文件优先走引用,工具要小而确定。
7. 性能分析:复用 DevTools TraceEngine,而不是自造指标解释器
性能工具在 src/tools/performance.ts。
它暴露三个工具:
| 工具 | 作用 |
|---|---|
performance_start_trace |
开始 trace,可选 reload 和 auto stop |
performance_stop_trace |
停止当前 trace |
performance_analyze_insight |
对某个 insight 做进一步解释 |
Trace 的关键不是“录下来”,而是“录完之后怎样解释”。这个项目没有自己写一套 LCP / INP / CLS 分析器,而是复用 chrome-devtools-frontend 的 TraceEngine 和 formatter。解析逻辑在 src/trace-processing/parse.ts。
裁剪后的流程如下:
await page.pptrPage.tracing.start({categories}); // 用 Puppeteer 开启 Chrome tracing,并传入 DevTools / Lighthouse 兼容类别
const traceEventsBuffer = await page.tracing.stop(); // 停止 tracing,拿到原始 trace buffer
const result = await parseRawTraceBuffer(traceEventsBuffer); // 把原始 trace 交给 DevTools TraceEngine 解析
context.storeTraceRecording(result); // 保存最近一次 trace,供后续 analyze insight 使用
response.attachTraceSummary(result); // 把 trace summary 交给 McpResponse 统一格式化输出
parseRawTraceBuffer() 内部会:
- 解码 trace buffer。
- 解析 JSON 里的
traceEvents。 - 调用
DevTools.TraceEngine.TraceModel.Model.createWithAllHandlers()创建的 engine。 - 得到
parsedTrace和insights。 - 用
PerformanceTraceFormatter输出 summary。 - 用
PerformanceInsightFormatter输出单个 insight 的解释。
这就是 Chrome DevTools MCP 相比“纯 Puppeteer MCP server”的优势之一:它不只是能录 trace,还能拿到 DevTools 自己的分析语义。
这里还有一个隐私边界要注意。默认情况下,性能工具可能把 trace 里的 URL 发给 Google CrUX API,以获取真实用户体验数据。README 和启动 disclaimer 都提示可以用 --no-performance-crux 关闭。
8. DevTools frontend 是它的隐藏重资产
看 package.json 会发现一个关键依赖:chrome-devtools-frontend。在 src/third_party/index.ts 里,项目直接导出:
export * as DevTools from "../../node_modules/chrome-devtools-frontend/mcp/mcp.js"; // 把 DevTools frontend 的 MCP 友好入口重新导出给业务代码使用
这带来了几类能力:
- TraceEngine 解析性能 trace
- Performance formatter 输出 summary 和 insight
- IssueAggregator 聚合浏览器 issue
- DebuggerWorkspaceBinding 解析 source map 后的 stack trace
- Heap snapshot worker 解析内存快照
为了让 DevTools frontend 能在 Node 环境和打包产物里工作,仓库还做了不少工程处理。
src/DevToolsConnectionAdapter.ts 把 Puppeteer 的 CDP session 包装成 DevTools 期望的 CDPConnection。src/DevtoolsUtils.ts 里的 UniverseManager 则为每个页面创建 DevTools Universe,并把 DebuggerModel 之类的模型接上。
这层可以裁剪成这样:
const session = await page.createCDPSession(); // 为当前 Puppeteer Page 创建 CDP session
const connection = new PuppeteerDevToolsConnection(session); // 把 Puppeteer session 适配成 DevTools CDPConnection
const targetManager = universe.context.get(DevTools.TargetManager); // 从 DevTools Universe 里取 TargetManager
const target = targetManager.createTarget("main", "", "frame", null, session.id(), undefined, connection); // 为当前页面创建 DevTools target
return {target, universe}; // 返回页面对应的 DevTools 分析宇宙
这也是它能做 source-mapped console stack trace 的原因。控制台消息来自 Puppeteer / CDP,但 stack trace 的人类可读化交给 DevTools 的 debugger workspace 处理。
9. 实验 CLI:本质是 daemon + MCP client
这个包除了 chrome-devtools-mcp,还暴露了一个实验命令 chrome-devtools。
它的文档在 docs/cli.md,入口在 src/bin/chrome-devtools.ts。
CLI 没有另写一套浏览器自动化逻辑,而是这样复用 MCP server:
chrome-devtools start启动后台 daemon。- daemon 通过 stdio 启动
chrome-devtools-mcp。 - daemon 自己作为 MCP client 连接这个 server。
- CLI 命令通过 Unix socket 或 Windows named pipe 发给 daemon。
- daemon 调
mcpClient.callTool()。 - CLI 把 MCP response 渲染成 Markdown 或 JSON。
这条链路的好处是状态持久。你连续执行 chrome-devtools list_pages、chrome-devtools navigate_page、chrome-devtools take_screenshot,背后复用的是同一个后台 browser / MCP server。
但它和 MCP 主路径不完全一样:CLI 默认 headless,默认在没有 userDataDir 时使用 isolated,并且固定打开 experimentalStructuredContent,方便 --output-format=json。
10. 安全与 telemetry:本地工具不等于没有边界
Chrome DevTools MCP 的能力很强,所以 README 和启动日志都反复提示:MCP client 可以检查、调试和修改浏览器或 DevTools 中的数据,不要把敏感浏览器实例暴露给不可信 client。
源码里能看到几个边界设计。
第一,profile 选择明确。
- 默认 profile 在
.cache/chrome-devtools-mcp下,不直接使用你的日常 Chrome profile。 --isolated用临时 profile,适合测试和一次性任务。--browser-url/--ws-endpoint连接已有浏览器时,风险由那个调试端口和浏览器 profile 决定。
第二,性能 CrUX 可关闭。
- 默认
performanceCrux为 true。 --no-performance-crux可以关闭向 CrUX 查询 URL 现场数据。
第三,usage statistics 默认开启但可关闭。
--no-usage-statistics可以关闭。CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS或CI环境变量存在时也会关闭。- telemetry 参数会做净化,例如
uid、reqid、msgid被 blocklist 掉,字符串和数组记录长度而不是原值。
第四,工具输出尽量引用大对象。
截图超过阈值会落临时文件。trace 可以保存为 .json 或 .json.gz。network request / response body 也可以保存到文件。这既减少 token 压力,也避免把大块数据无差别灌给模型。
11. 测试和发布:工具 schema、行为等待、CLI daemon 都可测
测试使用 Node 自带的 node:test。仓库里有三类测试值得注意。
第一类是 MCP e2e。tests/index.test.ts 直接用 @modelcontextprotocol/sdk 的 Client 和 StdioClientTransport 启动构建后的 server,然后调用 list_pages,并检查工具列表是否符合分类和实验开关预期。
第二类是工具行为测试。比如 tests/tools/input.test.ts 会构造本地 HTML,验证 click 之后确实等待导航完成,也会验证 DOM 变化稳定后 handler 才返回。
第三类是 CLI / daemon 测试。tests/e2e/chrome-devtools-commands.test.ts 会用随机 session id 启停 daemon,验证 list_pages 和 take_screenshot 通过命令行能走通。
发布侧也有几个细节:
npm run build先用 TypeScript 编译。scripts/post-build.ts给 DevTools frontend 在 Node 打包环境里补 i18n、codemirror、runtime mock 和 issue descriptions。rollup.config.mjs把第三方依赖打到build/src/third_party,同时生成第三方 license notices。scripts/generate-docs.ts从工具定义生成docs/tool-reference.md和 slim reference,并用 token 统计控制工具描述体积。server.json描述 MCP registry 元信息,声明 npm 包和 stdio transport。
这些不是边角料。对一个给 Agent 用的工具来说,工具 schema、文档、token 体积、错误输出、打包后的第三方依赖,都会直接影响模型能不能稳定调用。
总结:它真正封装的是“调试工作流”,不只是浏览器 API
Chrome DevTools MCP 的核心实现可以收束成四层。
第一层是协议层:MCP stdio server,负责工具发现、工具调用和返回内容。
第二层是浏览器层:Puppeteer 负责 launch / connect / page / locator / tracing / screenshot,避免项目自己手写 CDP 客户端。
第三层是 Agent 语义层:McpContext、McpPage、TextSnapshot、WaitForHelper、McpResponse 把浏览器状态转换成 uid、分页列表、文件引用、结构化内容和稳定等待。
第四层是 DevTools 分析层:chrome-devtools-frontend 提供 TraceEngine、formatter、issue aggregator、source map、heap snapshot 解析,让输出接近 Chrome DevTools 自己的解释能力。
所以它不是“把 Puppeteer 包成 MCP”这么简单。
更准确地说,它把 Chrome DevTools 里一组原本面向人类开发者的调试工作流,拆成了 Agent 可以组合调用的小工具:先看页面,基于 uid 操作元素,读 console 和 network,必要时截图,性能问题则录 trace 并让 DevTools 的分析模型给出 insight。
这也是它对前端 Agent 最有价值的地方:模型不再只能猜代码哪里错,而是可以进入真实浏览器,用接近开发者调试的证据链来定位问题。
