跳到主要内容

声明式响应组装与数据采集:McpResponse 与 PageCollector

30 秒导读: 每个工具 handler 只做一件事——声明这次要回给 agent 什么(「带上快照」「带上网络请求列表」「附上第 12 号请求的详情」),不亲手拼字符串。真正的计算、格式化、拼装,统一推迟到 McpResponse.handle() 那一刻发生。与此并行,一套常驻的 PageCollector 从页面开着的第一秒起就在后台收网络请求和控制台消息,并按「导航」分桶、给每条数据一个稳定 id——于是 agent 上一轮看到的 reqid=12,下一轮还能精确回指同一条请求。

本章讲清两件事,以及它们为什么这么设计:

  1. 响应层(McpResponse)——工具怎么用「声明 + 延迟计算」把「要什么」和「怎么呈现」解耦。
  2. 采集层(PageCollector 家族)——网络/控制台数据怎么被持续采集、按导航分桶、稳定编号。

快照的内部细节(uid、可见性判定)已在 03-snapshot-uid.md;性能 trace、Lighthouse、堆快照的 DevTools 引擎在 06-devtools-power.md。本章只讲这些能力如何被响应层调度和拼装,不重复它们的内部实现。


1. 这是什么(零基础也能懂)

先看一个工具 handler 长什么样

一个 MCP 工具(比如「列出网络请求」)的 handler 出人意料地短。它不返回任何文本,而是往一个 response 对象上「点单」:

// src/tools/network.ts:75 handler —— 示意精简,非逐字源码
handler: async (request, response) => {
const data = await request.page.getDevToolsData(); // 拿到 DevTools 面板当前选中项
response.attachDevToolsData(data);
response.setIncludeNetworkRequests(true, { // ← 只是「声明」:这次要带网络请求
pageSize: request.params.pageSize,
pageIdx: request.params.pageIdx,
resourceTypes: request.params.resourceTypes,
});
}

注意:handler 里没有任何「拼接请求列表字符串」的代码。它只调了一个 setIncludeNetworkRequests(true, …)——把「我要网络请求」这个意图记在 response 上,就结束了。真正去取请求、过滤、分页、格式化、编码,全都发生在之后

真实证据:src/tools/network.ts:81listNetworkRequests handler 通篇就是几个 response.set* / response.attach* 调用。

一句话定义

  • McpResponse = 一张「点单卡 + 出餐窗口」二合一的对象。 handler 期间它是点单卡(只记意图);handle() 被调用时它变成出餐窗口(把所有点过的单一次性做好、摆盘、递出)。
  • PageCollector = 一个从页面诞生起就常驻的「记录员」。 页面每发一个网络请求、每打一条 console,它都记下来并编号,等着响应层来取。

解决谁的什么问题

想象你在写一个浏览器工具:用户点一次「导航到 URL」,你可能想同时回给 agent:导航结果 + 新页面的快照 + 期间的网络请求 + 当前的模拟状态(视口/网速)+ 是否弹了对话框。如果每个工具都自己拼这一大坨,几十个工具会把同样的拼装逻辑抄几十遍,而且格式各不相同,agent 很难稳定解析。

这套设计把「呈现」从「工具逻辑」里彻底抽走:工具只管声明,McpResponse 统一负责把所有东西按固定顺序、固定格式拼出来,并且同时产出「给人读的文本」和「给机器解析的 JSON」两份。

一句话直觉

把 handler 想成餐厅点菜:你(工具)对服务员说「一份快照、一份网络请求、加一句备注」,你不进厨房。厨房(handle())按标准流程一次性做齐、摆成固定的盘式端出来。这样每桌客人拿到的摆盘都一致,agent 每次都能在「同一个位置」找到「同一类信息」。


2. 顶层全景(它大概怎么转)

两条并行的时间线

采集层和响应层的生命周期完全不同,这是理解全章的关键:

  • 采集层是「长命」的:PageCollector 跟着 McpPage 活,页面在它就在,后台一直收数据。
  • 响应层是「一次性」的:每次工具调用 new 一个 McpResponse,用完即弃。
页面生命周期(长)
════════════════════════════════════════════════════▶ 时间
McpPage 创建
└─ NetworkCollector ─┐
└─ ConsoleCollector ─┤ 后台持续收数据 + 编号 + 按导航分桶

┌── 工具调用 A ───┐ │ ┌── 工具调用 B ───┐
│ new McpResponse │ │ │ new McpResponse │ 每次调用一个新响应对象
│ handler 声明 │ │ │ handler 声明 │
│ handle() 出餐 │◀──┘ │ handle() 出餐 │◀── 出餐时才来 Collector 取数
└─────────────────┘ └─────────────────┘

一次工具调用的三段式

ToolHandlersrc/ToolHandler.ts:217 处按是否 --slim 决定 new McpResponse 还是 new SlimMcpResponse,然后:

阶段① handler 执行(声明期)
response.includeSnapshot() ┐
response.setIncludeNetworkRequests() │ 只写「意图字段」,不算任何东西
response.appendResponseLine("...") ┘


阶段② response.handle(toolName, context, dataFormat) ← ToolHandler.ts:273
看哪些意图被打开了,才真正去「算」:
createPagesSnapshot / TextSnapshot.create
NetworkFormatter / ConsoleFormatter / IssueFormatter
trace summary·insight / lighthouse / heap …


阶段③ this.format(...) 把所有算好的东西按固定顺序拼成
{ content: [文本, ...图片], structuredContent: {…JSON…} }

一句话:声明散落在 handler 里,计算集中在 handle(),拼装集中在 format()

部件一句话职责

部件干什么在哪
McpResponse记意图 → 延迟计算 → 拼双通道输出src/McpResponse.ts:219
SlimMcpResponseslim 模式:跳过一切计算,只回纯文本src/SlimMcpResponse.ts:15
*Formatter把一条网络/控制台/issue/快照数据转成文本+JSONsrc/formatters/
PageCollector<T>通用采集基类:监听页面事件、分桶、编号、按 id 查src/PageCollector.ts:48
NetworkCollector收 HTTP 请求,导航时保留「本次导航起的请求」src/PageCollector.ts:285
ConsoleCollector收 console/未捕获异常/聚合后的 DevTools issuesrc/PageCollector.ts:144
createIdGenerator / stableIdSymbol给每条数据打稳定自增 idsrc/utils/id.ts:7,17
paginate把长列表切页,默认每页 20src/utils/pagination.ts:22

3. 核心原理

3.1 为什么用「声明 + 延迟计算」

它要解决的小问题

同一个响应里可能要拼十几种东西(快照、网络、控制台、trace、lighthouse、堆、扩展、第三方工具、模拟状态、对话框、分页……)。若每个工具自己拼,既重复又易乱序;而且很多东西的计算是昂贵的(取网络响应体、格式化堆快照),只有真被要到时才该算。

思路

把「要什么」变成一堆布尔/参数字段先存下来(便宜),把「怎么算怎么摆」收到一个中心方法里,只对被打开的意图做计算。

McpResponse 的私有字段就是这张「点单卡」:

// src/McpResponse.ts:220 —— 意图字段(节选)
#includePages = false;
#snapshotParams?: SnapshotParams;
#attachedNetworkRequestId?: number;
#attachedConsoleMessageId?: number;
#attachedTraceSummary?: TraceResult;
#attachedLighthouseResult?: LighthouseData;
#networkRequestsOptions?: { include: boolean; pagination?:; resourceTypes?:};
#consoleDataOptions?: { include: boolean;};
#textResponseLines: string[] = [];

handler 期间能调的「点单」方法都是纯赋值,不做计算:

方法声明的意图源码
appendResponseLine(s)追加一行自由文本McpResponse.ts:465
includeSnapshot(params?)带上页面文本快照McpResponse.ts:315
setIncludeNetworkRequests(v, opts?)带上网络请求列表McpResponse.ts:333
setIncludeConsoleData(v, opts?)带上控制台消息列表McpResponse.ts:361
attachNetworkRequest(reqid, opts?)附上某条请求的详情McpResponse.ts:393
attachConsoleMessage(msgid)附上某条消息的详情McpResponse.ts:401
setIncludePages(v)带上打开的页面列表McpResponse.ts:306
attachTraceSummary/Insight附上 trace 摘要/洞察McpResponse.ts:405,409
attachLighthouseResult附上 Lighthouse 报告McpResponse.ts:421

这些方法几乎全是「把参数记到 #xxx 字段」。例如 setIncludeNetworkRequests 只是把分页/过滤参数装进 #networkRequestsOptions(McpResponse.ts:346),valuefalse 时干脆把整个意图清空。

延迟计算发生在 handle()

真正「干活」全在 handle()(src/McpResponse.ts:579)。它挨个检查意图字段,只对打开的那些去算:

// src/McpResponse.ts:587 —— handle() 的判定式结构(示意)
if (this.#includePages) await context.createPagesSnapshot(); // :588
if (this.#snapshotParams) …TextSnapshot.create() → SnapshotFormatter // :600
if (this.#attachedNetworkRequestId) …NetworkFormatter.from(, {fetchData:true}) // :628
if (this.#attachedConsoleMessageId) …ConsoleFormatter.from / IssueFormatter // :655,:661
if (this.#consoleDataOptions?.include) …取 + 过滤 + 逐条 ConsoleFormatter // :704
if (this.#networkRequestsOptions?.include) …取 + 过滤 + 逐条 NetworkFormatter // :766
return this.format(toolName, context, { …算好的东西… }, dataFormat); // :803

关键细节:「详情」比「列表」多取一次数据。 列表用 fetchData:false(不取响应体,McpResponse.ts:793),而 attachNetworkRequest 触发的详情用 fetchData:true(McpResponse.ts:632)——这正是「延迟计算」省钱的地方:昂贵的响应体只在 agent 点名要某条详情时才拉。

3.2 双通道输出:文本 + structuredContent

它要解决的小问题

同一份响应,要读顺畅的 Markdown 文本,机器要好解析的 JSON。两者不能二选一,也不该让工具作者拼两遍。

思路

format()(src/McpResponse.ts:834)一趟遍历,同时往两个容器里写:

  • response: string[] —— 攒文本行,最后 join('\n')
  • structuredContent: {…} —— 攒结构化字段。

几乎每个板块都是「一份数据、两处写入」的对称结构。以网络请求为例:

// src/McpResponse.ts:1405 —— 同一批数据,同时写两通道(示意)
structuredContent.networkRequests = paginationData.items.map(i => i.toJSON()); // 机器
response.push(...paginationData.items.map(i => i.toString())); // 人

toJSON() / toString() 是每个 Formatter 的一对孪生方法(见 3.4)。最后 format() 把两者打包:

// src/McpResponse.ts:1450
const text: TextContent = { type: 'text', text: response.join('\n') };
return { content: [text, ...images], structuredContent };

structuredContent 是否真被塞进最终结果,取决于 --experimentalStructuredContent(ToolHandler.ts:287);文本通道则总是返回。

format() 拼装的固定顺序

format() 的价值在于顺序是写死的——不管工具以什么次序点单,出餐摆盘永远一致。板块顺序(节选,均在 format() 内):

顺序板块触发条件源码
1reconnect 提示#reconnectNoticeMcpResponse.ts:934
2自由文本行#textResponseLines:940
3模拟状态行(网速/地理/视口/UA/CPU/配色)对应 #page 字段存在:955:994
4打开的对话框提示page.getDialog():996
5页面列表 / 扩展页面#includePages:1012
6trace 摘要 + insightsdata.traceSummary:1112
7Lighthouse 报告data.lighthouseResult:1141
8页面快照data.snapshot:1164
9堆快照各段#heapSnapshotOptions.include:1179
10网络请求详情/列表对应意图:1324,:1394
11控制台详情/列表对应意图:1330,:1420
12错误行data.errorMessage:1445

模拟状态行 = 每次都提醒「当前世界长什么样」

一个巧妙处:只要页面上开着任何模拟(限速、改视口、CPU 降频、改配色),format()自动在响应里加一行提醒,并写进 structuredContent。工具不用管:

// src/McpResponse.ts:972 —— 视口模拟(示意)
const viewport = this.#page?.viewport;
if (viewport) {
response.push(`Emulating viewport: ${JSON.stringify(viewport)}`);
structuredContent.viewport = viewport;
}

这让 agent 每一轮都能「看见」自己处在什么模拟环境里,避免拿桌面视口的结论去解释手机视口的页面。

对话框会「拦路」

若页面弹了 alert/confirm/prompt,format() 会推一段醒目提示并附上处理方法名(McpResponse.ts:996):

# Open dialog
prompt: 请输入名字 (default value: "…").
Call handle_dialog to handle it before continuing.

同时结构化成 structuredContent.dialog = {type, message, defaultValue}。这是「响应层顺手做的护栏」,不需要每个工具自己判断。

3.3 compactEncode:toon / gcf 紧凑编码

它要解决的小问题

大列表(网络请求、快照、堆数据)转成朴素 JSON 字符串很占 token。项目支持把这些结构化数据用更省的编码(TOON、GCF)渲染进文本通道。

思路

format() 开头按 dataFormat 解析出一个可选的编码函数 compactEncode(src/McpResponse.ts:908):

// src/McpResponse.ts:909 (示意)
if (dataFormat === 'toon') compactEncode = await getToonEncode(); // 动态 import @toon-format/toon
else if (dataFormat === 'gcf') compactEncode = await getGcfEncode(); // 动态 import @blackwell-systems/gcf

两个编码器都在 src/third_party/index.ts:59,63懒加载——是可选 peer 依赖,没装就在切到该格式时抛出「请安装」的友好错误(McpResponse.ts:913)。dataFormat--experimentalDataFormat 决定,并兼容老的 --experimentalToonFormat(ToolHandler.ts:266)。

之后各板块二选一:有 compactEncode 就编码结构化对象,否则退回 Formatter 的人读 toString()。例如网络列表:

// src/McpResponse.ts:1409 (示意)
response.push(
...(compactEncode
? [compactEncode(structuredContent.networkRequests)] // 紧凑
: paginationData.items.map(i => i.toString())), // 人读
);

注意:structuredContent(机器通道)永远是普通 JSON,紧凑编码只影响塞进文本通道的那份。

3.4 Formatter:只提职责与被调用点

Formatter 是「一条数据 → 文本+JSON」的转换器,McpResponse 只负责调度它们。四个 Formatter 各管一类:

Formatter职责handle() 调用点
NetworkFormatter一条 HTTP 请求 → 简洁/详情两档McpResponse.ts:628(详情)、:788(列表)
ConsoleFormatter一条 console/异常 → 简洁/详情;还负责合并连续重复:655:743
IssueFormatter一条聚合后的 DevTools issue → 文本+JSON:661:750
SnapshotFormatter文本快照 → 字符串/JSON(细节见 03):606

它们共享一套孪生方法约定,正是 3.2 双通道的基础:

  • toString() / toJSON() —— 简洁档(列表用)。
  • toStringDetailed() / toJSONDetailed() —— 详情档(单条 attach 用)。

真实证据:NetworkFormatter 的四个方法在 src/formatters/NetworkFormatter.ts:146,150,154,178,并用 NetworkRequestConcise / NetworkRequestDetailed 两个接口区分详略(NetworkFormatter.ts:31,38)。

一个 DevTools 式细节:合并连续重复。 控制台列表在渲染前会调 ConsoleFormatter.groupConsecutive(messages)(McpResponse.ts:1425),把「类型/文本/参数数」都相同的相邻消息折叠成一条并计数(ConsoleFormatter.ts:211),复刻 Chrome 控制台的合并行为,省 token 也更好读。

3.5 slim 模式:同一接口,砍掉一切计算

SlimMcpResponse 继承 McpResponse,但重写了 handle(),直接把攒的文本行拼出来,完全跳过快照/网络/控制台/格式化/结构化那一整套(src/SlimMcpResponse.ts:16):

// src/SlimMcpResponse.ts:16 (逐字)
override async handle(_toolName, _context) {
const text: TextContent = { type: 'text', text: this.responseLines.join('\n') };
return { content: [text], structuredContent: text };
}

因为 handler 拿到的是同一套「点单」接口(includeSnapshot 等照样能调,只是不生效),工具代码一行都不用改就能在 slim 与完整模式间切换——差异被封在 handle() 一个方法里。ToolHandler.ts:217--slim 决定 new 哪个。


4. 采集层:PageCollector 家族

响应层能「随手取到网络/控制台数据」,前提是有人在后台一直收。这就是 PageCollector

4.1 通用基类:监听、分桶、编号

PageCollector<T>(src/PageCollector.ts:48)是个泛型采集器。构造时,调用方传入「哪些页面事件喂给收集函数」,基类负责三件事:

  1. 监听页面事件并把每个事件对象推进存储;
  2. 给每条数据打稳定 id(stableIdSymbol);
  3. 在主帧导航时给存储分桶
// src/PageCollector.ts:60 —— 构造函数核心(示意精简)
const idGenerator = createIdGenerator();
const listenerMap = listeners(value => {
const withId = value as WithSymbolId<T>;
withId[stableIdSymbol] = idGenerator(); // ← 每条数据一个自增稳定 id
this.storage[0].push(withId); // ← 推进「当前导航」这个桶
});
listenerMap['framenavigated'] = frame => { // ← 只在主帧导航时分桶
if (frame !== this.pptrPage.mainFrame()) return;
this.splitAfterNavigation();
};

存储 = 一叠桶,新导航在最前

storage 是「数组的数组」:每个内层数组是一次导航期间收集的数据,新导航排在最前(PageCollector.ts:58)。默认最多保留最近 maxNavigationSaved = 3 次导航(PageCollector.ts:51)。

storage 结构(越靠前越新):
┌─ storage[0] ─ 当前导航: [ req#31, req#32, log#33, … ] ← 新数据 push 到这里
├─ storage[1] ─ 上一次导航:[ req#20, … ]
├─ storage[2] ─ 再上一次: [ req#12, … ]
└─ (更旧的被 splice 丢弃,只留最近 3 次)

分桶发生在 splitAfterNavigation()(PageCollector.ts:97):往最前 unshift 一个新空桶,再 splice(maxNavigationSaved) 砍掉超出的旧桶。

取数:默认只给当前导航

// src/PageCollector.ts:103 getData —— 语义
getData(includePreservedData?) {
if (!includePreservedData) return this.storage[0]; // 默认:只当前导航
// 否则:把最近几个桶从旧到新拼起来
}

这解释了工具里那个 includePreservedRequests「返回最近 3 次导航的保留请求」参数(src/tools/network.ts:70)——它就是这里的 includePreservedData

4.2 稳定 id:为什么 reqid / msgid 能跨轮回指

它要解决的小问题

agent 上一轮拿到 reqid=12,这一轮想说「给我 12 号请求的响应体」。要让这句话可靠,同一条请求在它整个存活期内必须始终是 12,哪怕中间又来了几十个新请求、翻了页。

思路

id 不是「列表下标」(那会随过滤/分页/新增而漂移),而是数据诞生那一刻就烙在对象上的一个符号属性,之后永不变。

  • createIdGenerator()(src/utils/id.ts:7)返回一个从 1 开始的自增闭包(到 MAX_SAFE_INTEGER 归零)。
  • stableIdSymbol(src/utils/id.ts:17)是个 Symbol,作为「隐藏字段」挂到数据对象上(WithSymbolId<T>),不污染对象的正常键。

采集时一步到位:withId[stableIdSymbol] = idGenerator()(PageCollector.ts:70)。

回指靠符号查找,不靠下标

getById(stableId)(PageCollector.ts:121)遍历所有桶find 按符号匹配:

// src/PageCollector.ts:131 find —— 跨所有导航桶找
find(filter) {
for (const navigation of this.storage) {
const item = navigation.find(filter);
if (item) return item;
}
}

响应层读回这个 id 时,也是从符号取:getNetworkRequestStableId / getConsoleMessageStableId 都读 [stableIdSymbol] ?? -1(McpResponse.ts:830,824)。于是渲染列表时每条带上自己的稳定 id,agent 下一轮拿这个 id 调 getNetworkRequestById(McpPage.ts:224)就能精确命中——即使这条请求早已不在当前页、被翻到第 3 页、或前面插了新请求

顺带:CDP 层的原始请求 id 是字符串,resolveCdpRequestId(McpPage.ts:178)负责把它翻译回这套稳定数字 id,用于「DevTools 面板里当前选中的那条请求」这类跨系统对齐。

4.3 ConsoleCollector:三种「坏消息」归一

ConsoleCollector(src/PageCollector.ts:144)收的不是单一类型,而是四类之一:普通 ConsoleMessageError、聚合后的 AggregatedIssueUncaughtError。它在基类之上多挂了一个 PageEventSubscriber(PageCollector.ts:158)来把两类「不是普通 console」的信号也变成页面事件喂进来。

McpPage 里的接线说明它收哪些事件(src/McpPage.ts:109):consoleuncaughtErrordevtoolsAggregatedIssue 三个事件都进同一个收集函数。

PageEventSubscriber(PageCollector.ts:168)干两件把「底层信号」升级成「页面事件」的活:

  • 未捕获异常 —— 直接订阅 CDP 的 Runtime.exceptionThrown,包成 UncaughtErroremit('uncaughtError', …)(PageCollector.ts:204,231)。
  • DevTools issue 聚合 —— 用 DevTools 的 IssueAggregator 把零散的 protocol issue 聚合去重(PageCollector.ts:170,193),聚合结果 emit('devtoolsAggregatedIssue', …)(PageCollector.ts:228)。它还用 #seenKeys / #seenIssues 两个 Set 去重(PageCollector.ts:171),并在主帧导航时清空、重建聚合器(PageCollector.ts:239)——保证 issue 的生命周期和「当前页」对齐。
底层信号 ──────────────► ConsoleCollector 存储
CDP Runtime.exceptionThrown → UncaughtError ─┐
page 'issue'(protocol) │
→ IssueAggregator 聚合去重 → AggregatedIssue ┤→ collect() → 打 id → 入桶
page 'console' → ConsoleMessage ──────────────┘

响应层取回后按类型分派到不同 Formatter:ConsoleMessage/UncaughtErrorConsoleFormatter,AggregatedIssueIssueFormatter(McpResponse.ts:741757)。

4.4 NetworkCollector:导航请求要「跟着新页面走」

NetworkCollector(src/PageCollector.ts:285)默认监听 request 事件收所有 HTTP 请求。它重写了 splitAfterNavigation(),因为网络有个特殊性:触发这次导航的那个请求本身,属于新页面,不该被留在旧桶里。

// src/PageCollector.ts:300 —— NetworkCollector 的分桶(示意)
override splitAfterNavigation() {
// 从当前桶里往回找「主帧的导航请求」
const lastRequestIdx = requests.findLastIndex(r =>
r.frame() === this.pptrPage.mainFrame() ? r.isNavigationRequest() : false);
if (lastRequestIdx !== -1) {
const fromCurrentNavigation = requests.splice(lastRequestIdx); // 连导航请求一起切出
this.storage.unshift(fromCurrentNavigation); // 归到新桶
} else {
this.storage.unshift([]);
}
this.storage.splice(this.maxNavigationSaved);
}

对比基类的朴素分桶(直接开个空新桶),NetworkCollector 会把「从导航请求开始的这一段」整体挪进新桶。好处:agent 在新页面上看网络请求时,能看到那条把它带到这里的导航请求本身,时间线不断裂,reqid 也随之落在正确的导航里保持稳定。

4.5 分页:让稳定 id 在长列表里依然可回指

列表可能很长,McpResponse#dataWithPagination(McpResponse.ts:1467)包一层 paginate(src/utils/pagination.ts:22,默认每页 20,pagination.ts:20)。它同时产出:

  • 人读:Showing 1-20 of 57 (Page 1 of 3). + Next page: 1 之类提示;
  • 机器:structuredContent.pagination = {currentPage, totalPages, hasNextPage, …}

关键:分页只切「展示哪一片」,不改数据的稳定 id。第 3 页的某条请求,它的 reqid 还是采集时烙的那个数,agent 直接拿 id 回指即可,不必先翻回它所在的页。非法页码会退回第一页并提示(pagination.ts:79McpResponse.ts:1470)。


5. 一条数据的完整旅程(把两层串起来)

以「agent 想看某条请求的响应体」为例,端到端走一遍:

① 页面早先发出请求
NetworkCollector 收到 'request' → 打 id=12 → 入当前导航桶 (PageCollector.ts:70)

② agent 先调 list_network_requests
handler: response.setIncludeNetworkRequests(true, {pageSize…}) (tools/network.ts:81)
handle(): 取桶数据 → 每条 NetworkFormatter.from(fetchData:false) (McpResponse.ts:788)
format(): 分页 + 双通道渲染,每条带上 reqid=12 (McpResponse.ts:1394)
→ agent 看到 "reqid=12 GET https://… 200"

③ agent 再调 get_network_request 传 reqid=12
handler: response.attachNetworkRequest(12, {responseFilePath…}) (tools/network.ts:122)
handle(): page.getNetworkRequestById(12) → 跨桶按符号命中同一对象 (McpResponse.ts:625)
NetworkFormatter.from(fetchData:true) 这次才拉响应体 (McpResponse.ts:628)
format(): 输出 toStringDetailed() + toJSONDetailed() (McpResponse.ts:1324)

整条线体现了本章两个主题:id 让第 ③ 步能精确回指第 ② 步的同一对象;延迟计算让昂贵的响应体只在第 ③ 步才拉


6. 巧妙之处(可借鉴)

  • 意图与呈现解耦。 工具只 set*/attach*/append*,呈现全在 handle()+format()。新增一种输出只改响应层一处,几十个工具零改动(McpResponse.ts:465421 一族 setter)。
  • 一份数据、两处写入的对称结构。 format() 里每个板块几乎都是 structuredContent.X = data.toJSON() 紧挨 response.push(data.toString()),双通道天然同步、不会漂(McpResponse.ts:1405)。
  • 固定摆盘顺序 = agent 的可预测性。 无论点单次序,输出板块顺序恒定,agent 每次在同一位置找同类信息(format() 全程)。
  • 稳定 id 用 Symbol 挂载。 id 烙在对象上而非靠下标,Symbol 键不污染对象正常字段(utils/id.ts:17);过滤/分页/新增都不改 id,跨轮回指才成立。
  • 导航分桶让「本次导航」自然成为默认视野。 默认只回当前导航的数据,想看历史显式要 preserved;导航请求跟着新页面走,时间线不断裂(PageCollector.ts:300)。
  • slim 差异封在一个 override 里。 完整/精简模式共享点单接口,仅 handle() 不同,切换零成本(SlimMcpResponse.ts:16)。
  • 模拟状态与对话框自动回显。 响应层顺手把「当前模拟环境」和「拦路对话框」加进每次输出,agent 不会在错误的世界模型下推理(McpResponse.ts:972,996)。
  • 第三方工具的运行时发现。 getToolGroups 通过在页面上派发 devtoolstooldiscovery 自定义事件、用 window.__dtmcp 收集页面自带的工具组,并把其 schema 里的 HTML 元素替换成 uid(replaceHtmlElementsWithUids, McpResponse.ts:70)——让页面能向 agent「自荐」工具(McpResponse.ts:121,688)。

7. 边界与局限

  • 只保留最近 3 次导航。 maxNavigationSaved = 3(PageCollector.ts:51),更早导航的网络/控制台数据被 splice 丢弃,无法回指。
  • id 会绕回(理论上)。 自增到 Number.MAX_SAFE_INTEGER 会归零(utils/id.ts:10);现实中几乎不可能触及,但极端长会话下 id 唯一性不再绝对保证。
  • slim 模式丢失一切结构化能力。 快照、网络列表、控制台、分页、structuredContent 全不产出,只剩 appendResponseLine 的纯文本(SlimMcpResponse.ts:16)。
  • 紧凑编码是可选 peer 依赖。 未安装 @toon-format/toon / @blackwell-systems/gcf 时,切到该格式会抛错要求先安装(McpResponse.ts:913,923)。
  • structuredContent 默认不返回。 需显式开 --experimentalStructuredContent(ToolHandler.ts:287),否则只有文本通道。
  • 第三方工具发现依赖内部 Puppeteer API。 getToolGroups 用了 page.pptrPage._client() 等带 @ts-expect-error 的内部接口(McpResponse.ts:123),对 Chrome/Puppeteer 版本敏感。

8. 代码地图(导航索引)

主题文件关键符号
响应主类:意图字段 + 延迟计算 + 双通道拼装src/McpResponse.tsMcpResponsehandleformat
点单方法(声明意图)src/McpResponse.tsappendResponseLineincludeSnapshotsetIncludeNetworkRequestssetIncludeConsoleDataattachNetworkRequestattachConsoleMessageattachTraceSummaryattachLighthouseResultsetIncludePages
读回稳定 idsrc/McpResponse.tsgetNetworkRequestStableIdgetConsoleMessageStableId
分页封装src/McpResponse.ts#dataWithPagination
紧凑编码解析src/McpResponse.tsgetToonEncodegetGcfEncode(via compactEncode)
schema 里 HTML 元素替成 uidsrc/McpResponse.tsreplaceHtmlElementsWithUids
第三方工具运行时发现src/McpResponse.tsgetToolGroupswindow.__dtmcpdevtoolstooldiscovery
slim 精简变体src/SlimMcpResponse.tsSlimMcpResponse.handle
采集基类:监听/分桶/编号/查找src/PageCollector.tsPageCollectorstoragemaxNavigationSavedsplitAfterNavigationgetDatagetByIdfind
控制台采集 + 事件订阅src/PageCollector.tsConsoleCollectorPageEventSubscriberUncaughtErrorIssueAggregator
网络采集:保留导航请求src/PageCollector.tsNetworkCollectorsplitAfterNavigation
稳定 id 生成与符号src/utils/id.tscreateIdGeneratorstableIdSymbolWithSymbolId
分页算法src/utils/pagination.tspaginateDEFAULT_PAGE_SIZE
采集器接入页面src/McpPage.tsnetworkCollectorconsoleCollectorgetNetworkRequestByIdgetConsoleMessageByIdresolveCdpRequestId
Formatter 孪生方法src/formatters/NetworkFormatter.tstoString/toJSON/toStringDetailed/toJSONDetailed
控制台合并连续重复src/formatters/ConsoleFormatter.tsgroupConsecutive
选择 McpResponse/SlimMcpResponse 并驱动src/ToolHandler.tsnew McpResponseresponse.handle

相邻章节:响应背后的工具怎么定义与门控见 01-tool-model.md;页面与上下文见 02-browser-context-pages.md;快照内部见 03-snapshot-uid.md;trace/Lighthouse/堆的引擎见 06-devtools-power.md