Skip to content

Commit 0331f6a

Browse files
chore: implement memory snapshot information (#1874)
WIP of exposing a tool for debugging memory issue directly with the MCP
1 parent ea57e86 commit 0331f6a

17 files changed

Lines changed: 494 additions & 4 deletions

rollup.config.mjs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,4 +296,11 @@ export default [
296296
},
297297
(_source, _importer, _isResolved) => false,
298298
),
299+
bundleDependency(
300+
'devtools-heap-snapshot-worker.js',
301+
{
302+
inlineDynamicImports: true,
303+
},
304+
(_source, _importer, _isResolved) => false,
305+
),
299306
];

src/HeapSnapshotManager.ts

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import fsSync from 'node:fs';
8+
import path from 'node:path';
9+
10+
import {DevTools} from './third_party/index.js';
11+
12+
export class HeapSnapshotManager {
13+
#snapshots = new Map<
14+
string,
15+
{
16+
snapshot: DevTools.HeapSnapshotModel.HeapSnapshotProxy.HeapSnapshotProxy;
17+
worker: DevTools.HeapSnapshotModel.HeapSnapshotProxy.HeapSnapshotWorkerProxy;
18+
}
19+
>();
20+
21+
async getSnapshot(
22+
filePath: string,
23+
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotProxy.HeapSnapshotProxy> {
24+
const absolutePath = path.resolve(filePath);
25+
const cached = this.#snapshots.get(absolutePath);
26+
if (cached) {
27+
return cached.snapshot;
28+
}
29+
30+
const {snapshot, worker} = await this.#loadSnapshot(absolutePath);
31+
this.#snapshots.set(absolutePath, {snapshot, worker});
32+
33+
return snapshot;
34+
}
35+
36+
async getAggregates(
37+
filePath: string,
38+
): Promise<
39+
Record<string, DevTools.HeapSnapshotModel.HeapSnapshotModel.AggregatedInfo>
40+
> {
41+
const snapshot = await this.getSnapshot(filePath);
42+
const filter =
43+
new DevTools.HeapSnapshotModel.HeapSnapshotModel.NodeFilter();
44+
return await snapshot.aggregatesWithFilter(filter);
45+
}
46+
47+
async getStats(
48+
filePath: string,
49+
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.Statistics> {
50+
const snapshot = await this.getSnapshot(filePath);
51+
return await snapshot.getStatistics();
52+
}
53+
54+
async getStaticData(
55+
filePath: string,
56+
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.StaticData | null> {
57+
const snapshot = await this.getSnapshot(filePath);
58+
return snapshot.staticData;
59+
}
60+
61+
async #loadSnapshot(absolutePath: string): Promise<{
62+
snapshot: DevTools.HeapSnapshotModel.HeapSnapshotProxy.HeapSnapshotProxy;
63+
worker: DevTools.HeapSnapshotModel.HeapSnapshotProxy.HeapSnapshotWorkerProxy;
64+
}> {
65+
const workerProxy =
66+
new DevTools.HeapSnapshotModel.HeapSnapshotProxy.HeapSnapshotWorkerProxy(
67+
() => {
68+
/* noop */
69+
},
70+
import.meta.resolve('./third_party/devtools-heap-snapshot-worker.js'),
71+
);
72+
73+
const {promise: snapshotPromise, resolve: resolveSnapshot} =
74+
Promise.withResolvers<DevTools.HeapSnapshotModel.HeapSnapshotProxy.HeapSnapshotProxy>();
75+
76+
const loaderProxy = workerProxy.createLoader(1, snapshotProxy => {
77+
resolveSnapshot(snapshotProxy);
78+
});
79+
80+
const fileStream = fsSync.createReadStream(absolutePath, {
81+
encoding: 'utf-8',
82+
highWaterMark: 1024 * 1024,
83+
});
84+
85+
for await (const chunk of fileStream) {
86+
await loaderProxy.write(chunk);
87+
}
88+
89+
await loaderProxy.close();
90+
91+
const snapshot = await snapshotPromise;
92+
return {snapshot, worker: workerProxy};
93+
}
94+
95+
dispose(filePath: string): void {
96+
const absolutePath = path.resolve(filePath);
97+
const cached = this.#snapshots.get(absolutePath);
98+
if (cached) {
99+
cached.worker.dispose();
100+
this.#snapshots.delete(absolutePath);
101+
}
102+
}
103+
}

src/McpContext.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,14 @@ import path from 'node:path';
99

1010
import type {TargetUniverse} from './DevtoolsUtils.js';
1111
import {UniverseManager} from './DevtoolsUtils.js';
12+
import {HeapSnapshotManager} from './HeapSnapshotManager.js';
1213
import {McpPage} from './McpPage.js';
1314
import {
1415
NetworkCollector,
1516
ConsoleCollector,
1617
type ListenerMap,
1718
type UncaughtError,
1819
} from './PageCollector.js';
19-
import type {DevTools} from './third_party/index.js';
2020
import type {
2121
Browser,
2222
BrowserContext,
@@ -29,6 +29,7 @@ import type {
2929
Viewport,
3030
Target,
3131
} from './third_party/index.js';
32+
import type {DevTools} from './third_party/index.js';
3233
import {Locator} from './third_party/index.js';
3334
import {PredefinedNetworkConditions} from './third_party/index.js';
3435
import {listPages} from './tools/pages.js';
@@ -99,6 +100,7 @@ export class McpContext implements Context {
99100

100101
#locatorClass: typeof Locator;
101102
#options: McpContextOptions;
103+
#heapSnapshotManager = new HeapSnapshotManager();
102104

103105
private constructor(
104106
browser: Browser,
@@ -913,4 +915,24 @@ export class McpContext implements Context {
913915
getExtension(id: string): InstalledExtension | undefined {
914916
return this.#extensionRegistry.getById(id);
915917
}
918+
919+
async getHeapSnapshotAggregates(
920+
filePath: string,
921+
): Promise<
922+
Record<string, DevTools.HeapSnapshotModel.HeapSnapshotModel.AggregatedInfo>
923+
> {
924+
return await this.#heapSnapshotManager.getAggregates(filePath);
925+
}
926+
927+
async getHeapSnapshotStats(
928+
filePath: string,
929+
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.Statistics> {
930+
return await this.#heapSnapshotManager.getStats(filePath);
931+
}
932+
933+
async getHeapSnapshotStaticData(
934+
filePath: string,
935+
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.StaticData | null> {
936+
return await this.#heapSnapshotManager.getStaticData(filePath);
937+
}
916938
}

src/McpResponse.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type {WebMCPTool} from 'puppeteer-core';
88

99
import type {ParsedArguments} from './bin/chrome-devtools-mcp-cli-options.js';
1010
import {ConsoleFormatter} from './formatters/ConsoleFormatter.js';
11+
import {HeapSnapshotFormatter} from './formatters/HeapSnapshotFormatter.js';
1112
import {IssueFormatter} from './formatters/IssueFormatter.js';
1213
import {NetworkFormatter} from './formatters/NetworkFormatter.js';
1314
import {SnapshotFormatter} from './formatters/SnapshotFormatter.js';
@@ -168,6 +169,16 @@ export class McpResponse implements Response {
168169
#attachedLighthouseResult?: LighthouseData;
169170
#textResponseLines: string[] = [];
170171
#images: ImageContentData[] = [];
172+
#heapSnapshotOptions?: {
173+
include: boolean;
174+
aggregates?: Record<
175+
string,
176+
DevTools.HeapSnapshotModel.HeapSnapshotModel.AggregatedInfo
177+
>;
178+
pagination?: PaginationOptions;
179+
stats?: DevTools.HeapSnapshotModel.HeapSnapshotModel.Statistics;
180+
staticData?: DevTools.HeapSnapshotModel.HeapSnapshotModel.StaticData | null;
181+
};
171182
#networkRequestsOptions?: {
172183
include: boolean;
173184
pagination?: PaginationOptions;
@@ -365,6 +376,33 @@ export class McpResponse implements Response {
365376
this.#textResponseLines.push(value);
366377
}
367378

379+
setHeapSnapshotAggregates(
380+
aggregates: Record<
381+
string,
382+
DevTools.HeapSnapshotModel.HeapSnapshotModel.AggregatedInfo
383+
>,
384+
options?: PaginationOptions,
385+
) {
386+
this.#heapSnapshotOptions = {
387+
...this.#heapSnapshotOptions,
388+
include: true,
389+
aggregates,
390+
pagination: options,
391+
};
392+
}
393+
394+
setHeapSnapshotStats(
395+
stats: DevTools.HeapSnapshotModel.HeapSnapshotModel.Statistics,
396+
staticData: DevTools.HeapSnapshotModel.HeapSnapshotModel.StaticData | null,
397+
) {
398+
this.#heapSnapshotOptions = {
399+
...this.#heapSnapshotOptions,
400+
include: true,
401+
stats,
402+
staticData,
403+
};
404+
}
405+
368406
attachImage(value: ImageContentData): void {
369407
this.#images.push(value);
370408
}
@@ -661,6 +699,11 @@ export class McpResponse implements Response {
661699
};
662700
pages?: object[];
663701
pagination?: object;
702+
heapSnapshot?: {
703+
stats?: object;
704+
staticData?: object;
705+
};
706+
heapSnapshotData?: object[];
664707
extensionServiceWorkers?: object[];
665708
extensionPages?: object[];
666709
} = {};
@@ -857,6 +900,40 @@ Call ${handleDialog.name} to handle it before continuing.`);
857900
}
858901
}
859902

903+
if (this.#heapSnapshotOptions?.include) {
904+
response.push('## Heap Snapshot Data');
905+
const stats = this.#heapSnapshotOptions.stats;
906+
const staticData = this.#heapSnapshotOptions.staticData;
907+
if (stats) {
908+
response.push(`Statistics: ${JSON.stringify(stats, null, 2)}`);
909+
structuredContent.heapSnapshot = structuredContent.heapSnapshot || {};
910+
structuredContent.heapSnapshot.stats = stats;
911+
}
912+
if (staticData) {
913+
response.push(`Static Data: ${JSON.stringify(staticData, null, 2)}`);
914+
structuredContent.heapSnapshot = structuredContent.heapSnapshot || {};
915+
structuredContent.heapSnapshot.staticData = staticData;
916+
}
917+
const aggregates = this.#heapSnapshotOptions.aggregates;
918+
if (aggregates) {
919+
const sortedEntries = HeapSnapshotFormatter.sort(aggregates);
920+
921+
const paginationData = this.#dataWithPagination(
922+
sortedEntries,
923+
this.#heapSnapshotOptions.pagination,
924+
);
925+
926+
structuredContent.pagination = paginationData.pagination;
927+
response.push(...paginationData.info);
928+
929+
const paginatedRecord = Object.fromEntries(paginationData.items);
930+
const formatter = new HeapSnapshotFormatter(paginatedRecord);
931+
932+
response.push(formatter.toString());
933+
structuredContent.heapSnapshotData = formatter.toJSON();
934+
}
935+
}
936+
860937
if (data.detailedNetworkRequest) {
861938
response.push(data.detailedNetworkRequest.toStringDetailed());
862939
structuredContent.networkRequest =

src/bin/chrome-devtools-mcp-cli-options.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,11 @@ export const cliOptions = {
164164
'Whether to enable coordinate-based tools such as click_at(x,y). Usually requires a computer-use model able to produce accurate coordinates by looking at screenshots.',
165165
hidden: false,
166166
},
167+
experimentalMemory: {
168+
type: 'boolean',
169+
describe: 'Whether to enable experimental memory tools.',
170+
hidden: true,
171+
},
167172
experimentalStructuredContent: {
168173
type: 'boolean',
169174
describe: 'Whether to output structured formatted content.',
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import type {DevTools} from '../third_party/index.js';
8+
9+
export interface FormattedSnapshotEntry {
10+
className: string;
11+
count: number;
12+
selfSize: number;
13+
retainedSize: number;
14+
}
15+
16+
export class HeapSnapshotFormatter {
17+
#aggregates: Record<
18+
string,
19+
DevTools.HeapSnapshotModel.HeapSnapshotModel.AggregatedInfo
20+
>;
21+
22+
constructor(
23+
aggregates: Record<
24+
string,
25+
DevTools.HeapSnapshotModel.HeapSnapshotModel.AggregatedInfo
26+
>,
27+
) {
28+
this.#aggregates = aggregates;
29+
}
30+
31+
#getSortedAggregates(): DevTools.HeapSnapshotModel.HeapSnapshotModel.AggregatedInfo[] {
32+
return Object.values(this.#aggregates).sort((a, b) => b.self - a.self);
33+
}
34+
35+
toString(): string {
36+
const sorted = this.#getSortedAggregates();
37+
const lines: string[] = [];
38+
lines.push('className,count,selfSize,maxRetainedSize');
39+
40+
for (const info of sorted) {
41+
lines.push(`"${info.name}",${info.count},${info.self},${info.maxRet}`);
42+
}
43+
44+
return lines.join('\n');
45+
}
46+
47+
toJSON(): FormattedSnapshotEntry[] {
48+
const sorted = this.#getSortedAggregates();
49+
return sorted.map(info => ({
50+
className: info.name,
51+
count: info.count,
52+
selfSize: info.self,
53+
retainedSize: info.maxRet,
54+
}));
55+
}
56+
57+
static sort(
58+
aggregates: Record<
59+
string,
60+
DevTools.HeapSnapshotModel.HeapSnapshotModel.AggregatedInfo
61+
>,
62+
): Array<
63+
[string, DevTools.HeapSnapshotModel.HeapSnapshotModel.AggregatedInfo]
64+
> {
65+
return Object.entries(aggregates).sort((a, b) => b[1].self - a[1].self);
66+
}
67+
}

src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,12 @@ export async function createMcpServer(
152152
) {
153153
return;
154154
}
155+
if (
156+
tool.annotations.conditions?.includes('experimentalMemory') &&
157+
!serverArgs.experimentalMemory
158+
) {
159+
return;
160+
}
155161
if (
156162
tool.annotations.conditions?.includes('experimentalInteropTools') &&
157163
!serverArgs.experimentalInteropTools

src/telemetry/tool_call_metrics.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -556,5 +556,14 @@
556556
"argType": "number"
557557
}
558558
]
559+
},
560+
{
561+
"name": "load_memory_snapshot",
562+
"args": [
563+
{
564+
"name": "file_path_length",
565+
"argType": "number"
566+
}
567+
]
559568
}
560569
]

0 commit comments

Comments
 (0)