跳到主要内容

数据截至 (上游 commit fd0b7e1d9ed9)

第 4 章:声明式响应装配

这章讲什么: 为什么每个工具的返回值看起来那么一致、输出体积怎么被控制住,以及同一批数据怎么同时喂给"读文本的模型"和"读 JSON 的程序"。


4.1 问题:每个工具都想附带同一堆上下文

点了一次按钮,模型接下来大概率想知道:页面跳了吗?控制台报错了吗?现在页面结构长什么样?有没有弹对话框?

如果每个 handler 自己去取这些、自己拼文本,会有三个后果:56 份重复代码、输出格式各不相同、并且没人能统一控制体积。

本项目的解法:handler 只表达意图,不生产文本。


4.2 声明式的收集器

handler 侧长什么样

click 的 handler(src/tools/input.ts:102-133),它对输出做的全部事情是:

response.appendResponseLine(`Successfully clicked on the element`);
response.attachWaitForResult(result);
if (request.params.includeSnapshot) {
response.includeSnapshot();
}

三句话都是打标记:加一行文字、挂上等待结果、要求附带快照。至于快照怎么生成、放在响应哪个位置、要不要转 JSON,handler 一概不管。

标记 → 输出段落

McpResponse(src/McpResponse.ts:70)的接口就是一张标记表:

方法打的标记最终产出
appendResponseLine一行自由文本排在最前的说明文字
includeSnapshot(params?)要页面快照## Latest page snapshot + 树
setIncludePages(true)要页面列表## Pages + 每行 id: 标题 (url) [selected]
setIncludeNetworkRequests(true, opts)要网络清单## Network requests + 分页信息
setIncludeConsoleData(true, opts)要控制台清单## Console messages + 分页信息
attachNetworkRequest(reqid)要某条请求的详情完整头/体(可落盘)
attachConsoleMessage(msgid)要某条消息的详情堆栈/关联请求
attachTraceSummary(trace)要 trace 摘要语义化性能结论
attachImage(data)挂一张图content 里的 image 项
setError(err)出错了末尾 Error: …

为什么这样更好

三个直接收益:

  1. 一致性由结构保证。 所有工具的输出顺序完全相同,模型不用适应 56 种格式。
  2. 横切能力只写一遍。 分页、脱敏、压缩编码、落盘,全部在这一层加。
  3. 数据获取可以并行。 见下。

4.3 handle():七件事并行做

// src/McpResponse.ts:678-694
const [snapshot, detailedNetworkRequest, detailedConsoleMessage,
thirdPartyDeveloperTools, webmcpTools, consoleMessages, networkRequests
] = await Promise.all([
this.#handleSnapshot(context),
this.#handleAttachedNetworkRequest(context),
this.#handleAttachedConsoleMessage(),
this.#handleThirdPartyDevelopeTools(),
this.#handleWebMCP(),
this.#handleConsoleList(context),
this.#handleNetworkRequestList(context),
]);

每个 #handleXxx 都先看自己的标记,没打就立刻返回 undefined。所以没被要的东西一分钱也不花,被要的几件并发跑。

其中 #handleSnapshot(:450)还顺带做两件事:如果要页面列表就先刷新页面快照;如果快照带了 filePath 就写文件并只返回文件名,不把整棵树塞进响应。


4.4 format():一条固定的渲染流水线

顺序

format()(src/McpResponse.ts:735)有近 700 行,但结构极简单——一串 if,顺序即输出顺序:

① 重连提示
② handler 写的文本行
③ 导航结果(navigatedToUrl)
④ 模拟状态:网络条件/地理/视口/UA/CPU 节流/配色
⑤ 未处理对话框(带"去调 handle_dialog"的指引)
⑥ 页面列表(+ 扩展页面 / 扩展 service worker)
⑦ trace 摘要 / trace insight
⑧ Lighthouse 结果
⑨ 页面快照
⑩ 堆快照各类数据
⑪ 单条网络请求详情 / 单条控制台消息详情
⑫ 扩展列表 / 三方开发者工具 / WebMCP 工具
⑬ 网络清单(分页)
⑭ 控制台清单(分页)
⑮ 错误信息

这个顺序本身是设计: 状态类信息(模拟设置、对话框)在前——模型先知道"环境是什么";大块数据(快照、清单)在后。

模拟状态是无条件回显的

第 ④ 组每一项都只看 this.#page?.xxx 有没有值,不看工具是什么。所以只要你开了 Slow 3G,之后每一次工具调用的响应都会带一句 Emulating network conditions: Slow 3G,顺带告诉你当前导航超时是多少毫秒(:861-868)。

模型很容易忘记自己十几轮之前开过节流,这个无条件回显就是在防这件事。

对话框提示自带下一步

# Open dialog
alert: Are you sure?
Call handle_dialog to handle it before continuing.

注意 handleDialog.name从工具定义里取的(:910),不是硬编码字符串。改工具名不会让提示失真。同样的写法在 listPages().name(:843)、takeSnapshot.name(src/McpPage.ts:642)都出现过。

页面标题带熔断

// src/McpResponse.ts:1463-1468
async function fetchPageTitle(page: Page): Promise<string> {
return Promise.race([
page.title().catch(() => ''),
new Promise<string>(resolve => setTimeout(() => resolve(''), 1000)),
]);
}

一个卡死的页面不能让 list_pages 挂住。标题还会被 truncateTitle 截到 50 字符(:1456)。


4.5 双轨输出:文本 + 结构化

两个消费者

format()

┌──────┴──────┐
▼ ▼
response[] structuredContent{}
(join('\n')) (嵌套对象)
│ │
▼ ▼
给模型读 给程序读
· CLI 的 --output-format json
· MCP 客户端做结构化解析

几乎每个 if 块都同时往两边写,例如快照那段(:1075-1088):

structuredContent.snapshot = data.snapshot.toJSON();
response.push('## Latest page snapshot');
response.push(compactEncode ? compactEncode(structuredContent.snapshot)
: data.snapshot.toString());

结构化那份总是生成;文本那份可以选择用压缩编码代替。

开关

structuredContent 只在 --experimentalStructuredContent 时才真的挂到结果上(src/ToolHandler.ts:366-367)。CLI 侧对这个实验开关的默认值处理在 getCliOptions(src/bin/chrome-devtools.ts:53-64)。


4.6 三个省 token 的机制

机制一:分页

#dataWithPagination(src/McpResponse.ts:1417)包了 paginate(src/utils/pagination.ts:22),用在网络清单、控制台清单、堆快照聚合、重复字符串等处。

规则表:

情况行为
没给 pageSize 也没给 pageIdx不分页,全量返回
给了其一默认每页 20 条
pageIdx 越界或为负回到第 0 页,并输出 Invalid page number provided. Showing first page.

每页都会带一行导航文字:

Showing 1-20 of 137 (Page 1 of 7).
Next page: 1

越界不报错而是回退 + 提示,又是一次"可自愈错误"的实践。

机制二:折叠重复的控制台消息

ConsoleFormatter.groupConsecutive(src/formatters/ConsoleFormatter.ts:240)把连续且类型/文本/参数个数都相同的消息合并成一条 GroupedConsoleFormatter,带计数。

一个循环里报 500 次同样的错,合并后是一行。注意是"连续"合并,不是全局去重——保留了时间上的先后信息。

机制三:压缩编码

--experimentalDataFormat=toon|gcf 时,文本那一路改用紧凑编码(src/McpResponse.ts:813-837)。两个编码库都是可选 peer dependency,动态 import(src/third_party/index.ts:62-69)。

没装的话,错误信息直接给出两条安装命令:

The `@toon-format/toon` package is required to use --experimentalDataFormat=toon.
- For npx: npx --package chrome-devtools-mcp@latest --package @toon-format/toon@latest …
- For npm: npm install @toon-format/toon (add -g if installed globally)

4.7 大资产走文件,不走上下文

docs/design-principles.md 里有一条 "Reference over Value":截图、trace、视频这类重资产返回路径,不返回原始数据流。落地处:

工具规则位置
take_screenshot给了 filePath → 存文件;否则超过 2MB 也自动存临时文件;都不满足才内联 base64src/tools/screenshot.ts:268-286
performance_stop_trace给了 filePath 存原始 trace,.gz 结尾自动 gzipsrc/tools/performance.ts:201-222
evaluate_script给了 filePath 就把 JSON 结果写文件,只回一句路径src/tools/script.ts:166-175
take_snapshot给了 filePath 就写文件,响应里只有 Saved snapshot to …src/McpResponse.ts:467-476
get_network_request请求体/响应体可分别落盘src/formatters/NetworkFormatter.ts

那个 2MB 自动落盘的兜底特别值得学:它不依赖模型记得传 filePath,而是在超标时自己改变策略

截图还有一层可选降采样:--screenshotMaxWidth/MaxHeight 会算出一个 clip.scale(src/tools/screenshot.ts:92),让 CDP 直接按比例缩小截图,而不是先截大图再压。两个上限取较小的缩放比,保持宽高比。


4.8 脱敏

--redactNetworkHeaders(默认开)让网络头走 DevTools 自己的脱敏函数(src/formatters/NetworkFormatter.ts:168-180):

const redacted = DevTools.NetworkRequestFormatter.sanitizeHeaders(headersList);

复用 DevTools 前端的实现,而不是自己维护一份敏感头名单。 上游更新了,这里跟着更新。

重定向链上的每一跳也走同一份配置(:191)。


4.9 slim 模式:同一个类,砍掉整条流水线

// src/SlimMcpResponse.ts:15-28
export class SlimMcpResponse extends McpResponse {
override async handle(_context: McpContext) {
const text = {type: 'text', text: this.responseLines.join('\n')};
return {content: [text], structuredContent: text};
}
}

继承但完全覆盖 handle:只输出 handler 写的文本行,不取快照、不列页面、不回显模拟状态。

配合 slim 的三个工具(src/tools/slim/tools.ts),整个 server 就退化成一个极小的浏览器操作面。选择在 ToolHandler 里(src/ToolHandler.ts:299-301)。


4.10 原理演示

// 示意,非源码
class Response {
#lines = []; #wantSnapshot = false; #wantNetwork = null;
line(s) { this.#lines.push(s); } // 打标记
includeSnapshot() { this.#wantSnapshot = true; }
includeNetwork(opts) { this.#wantNetwork = opts; }

async render(ctx) {
// 只取被要的数据,且并行
const [snap, net] = await Promise.all([
this.#wantSnapshot ? takeSnapshot(ctx) : undefined,
this.#wantNetwork ? listRequests(ctx) : undefined,
]);
const out = [...this.#lines]; // 顺序即渲染顺序
if (snap) out.push('## Latest page snapshot', snap.toString());
if (net) out.push('## Network requests', ...paginate(net, this.#wantNetwork));
return out.join('\n');
}
}

重点看:标记与渲染分离,以及渲染顺序是写死的、不由 handler 决定


4.11 关键细节与坑

  • setIncludePages(true) 会连带打开扩展相关的两个开关(src/McpResponse.ts:161-168),但只在 --categoryExtensions 打开时。一个 setter 影响三段输出,读代码时容易漏。
  • 稳定 id 存在 Symbol 上。 网络请求和控制台消息的 reqid/msgid 通过 stableIdSymbol(src/utils/id.ts)挂在对象本身,取用是 getNetworkRequestStableId(:731),取不到返回 -1。用 Symbol 是为了不污染 Puppeteer 对象的可枚举属性。
  • 控制台带堆栈时会追加一句说明: "stack trace line and column numbers use 1-based indexing"(:1386-1388)。模型很容易按 0-based 去读源码,这一句直接消除歧义。
  • #deviceScope 影响性能结论。 视口标了 isMobile 就按 PHONE 取 CrUX 字段数据,否则 DESKTOP(:129-131)。同一条 trace 在两种设备口径下的结论不同。

4.12 代码地图

主题文件路径符号名
响应装配主体src/McpResponse.tsMcpResponsehandleformat#dataWithPagination
各类数据的取数src/McpResponse.ts#handleSnapshot#handleConsoleList#handleNetworkRequestList
分页算法src/utils/pagination.tspaginateDEFAULT_PAGE_SIZEresolvePageIndex
稳定 idsrc/utils/id.tscreateIdGeneratorstableIdSymbolWithSymbolId
网络格式化与脱敏src/formatters/NetworkFormatter.tsNetworkFormatter#redactNetworkHeadersBODY_CONTEXT_SIZE_LIMIT
控制台格式化与折叠src/formatters/ConsoleFormatter.tsConsoleFormattergroupConsecutiveGroupedConsoleFormatter
快照渲染src/formatters/SnapshotFormatter.tsSnapshotFormatter
压缩编码入口src/third_party/index.tsgetToonEncodegetGcfEncode
slim 响应src/SlimMcpResponse.tsSlimMcpResponse
大资产落盘src/tools/screenshot.tsscreenshotcomputeDownscaleClipgetSourceBox