Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions rollup.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -296,4 +296,11 @@ export default [
},
(_source, _importer, _isResolved) => false,
),
bundleDependency(
'devtools-heap-snapshot-worker.js',
{
inlineDynamicImports: true,
},
(_source, _importer, _isResolved) => false,
),
];
103 changes: 103 additions & 0 deletions src/HeapSnapshotManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import fsSync from 'node:fs';
import path from 'node:path';

import {DevTools} from './third_party/index.js';

export class HeapSnapshotManager {
#snapshots = new Map<
string,
{
snapshot: DevTools.HeapSnapshotModel.HeapSnapshotProxy.HeapSnapshotProxy;
worker: DevTools.HeapSnapshotModel.HeapSnapshotProxy.HeapSnapshotWorkerProxy;
}
>();

async getSnapshot(
filePath: string,
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotProxy.HeapSnapshotProxy> {
const absolutePath = path.resolve(filePath);
const cached = this.#snapshots.get(absolutePath);
if (cached) {
return cached.snapshot;
}

const {snapshot, worker} = await this.#loadSnapshot(absolutePath);
this.#snapshots.set(absolutePath, {snapshot, worker});

return snapshot;
}

async getAggregates(
filePath: string,
): Promise<
Record<string, DevTools.HeapSnapshotModel.HeapSnapshotModel.AggregatedInfo>
> {
const snapshot = await this.getSnapshot(filePath);
const filter =
new DevTools.HeapSnapshotModel.HeapSnapshotModel.NodeFilter();
return await snapshot.aggregatesWithFilter(filter);
}

async getStats(
filePath: string,
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.Statistics> {
const snapshot = await this.getSnapshot(filePath);
return await snapshot.getStatistics();
}

async getStaticData(
filePath: string,
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.StaticData | null> {
const snapshot = await this.getSnapshot(filePath);
return snapshot.staticData;
}

async #loadSnapshot(absolutePath: string): Promise<{
snapshot: DevTools.HeapSnapshotModel.HeapSnapshotProxy.HeapSnapshotProxy;
worker: DevTools.HeapSnapshotModel.HeapSnapshotProxy.HeapSnapshotWorkerProxy;
}> {
const workerProxy =
new DevTools.HeapSnapshotModel.HeapSnapshotProxy.HeapSnapshotWorkerProxy(
() => {
/* noop */
},
import.meta.resolve('./third_party/devtools-heap-snapshot-worker.js'),
);

const {promise: snapshotPromise, resolve: resolveSnapshot} =
Promise.withResolvers<DevTools.HeapSnapshotModel.HeapSnapshotProxy.HeapSnapshotProxy>();

const loaderProxy = workerProxy.createLoader(1, snapshotProxy => {
resolveSnapshot(snapshotProxy);
});

const fileStream = fsSync.createReadStream(absolutePath, {
encoding: 'utf-8',
highWaterMark: 1024 * 1024,
});

for await (const chunk of fileStream) {
await loaderProxy.write(chunk);
}

await loaderProxy.close();

const snapshot = await snapshotPromise;
return {snapshot, worker: workerProxy};
}

dispose(filePath: string): void {
const absolutePath = path.resolve(filePath);
const cached = this.#snapshots.get(absolutePath);
if (cached) {
cached.worker.dispose();
this.#snapshots.delete(absolutePath);
}
}
}
24 changes: 23 additions & 1 deletion src/McpContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,14 @@ import path from 'node:path';

import type {TargetUniverse} from './DevtoolsUtils.js';
import {UniverseManager} from './DevtoolsUtils.js';
import {HeapSnapshotManager} from './HeapSnapshotManager.js';
import {McpPage} from './McpPage.js';
import {
NetworkCollector,
ConsoleCollector,
type ListenerMap,
type UncaughtError,
} from './PageCollector.js';
import type {DevTools} from './third_party/index.js';
import type {
Browser,
BrowserContext,
Expand All @@ -29,6 +29,7 @@ import type {
Viewport,
Target,
} from './third_party/index.js';
import type {DevTools} from './third_party/index.js';
import {Locator} from './third_party/index.js';
import {PredefinedNetworkConditions} from './third_party/index.js';
import {listPages} from './tools/pages.js';
Expand Down Expand Up @@ -99,6 +100,7 @@ export class McpContext implements Context {

#locatorClass: typeof Locator;
#options: McpContextOptions;
#heapSnapshotManager = new HeapSnapshotManager();

private constructor(
browser: Browser,
Expand Down Expand Up @@ -913,4 +915,24 @@ export class McpContext implements Context {
getExtension(id: string): InstalledExtension | undefined {
return this.#extensionRegistry.getById(id);
}

async getHeapSnapshotAggregates(
filePath: string,
): Promise<
Record<string, DevTools.HeapSnapshotModel.HeapSnapshotModel.AggregatedInfo>
> {
return await this.#heapSnapshotManager.getAggregates(filePath);
}

async getHeapSnapshotStats(
filePath: string,
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.Statistics> {
return await this.#heapSnapshotManager.getStats(filePath);
}

async getHeapSnapshotStaticData(
filePath: string,
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.StaticData | null> {
return await this.#heapSnapshotManager.getStaticData(filePath);
}
}
77 changes: 77 additions & 0 deletions src/McpResponse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {WebMCPTool} from 'puppeteer-core';

import type {ParsedArguments} from './bin/chrome-devtools-mcp-cli-options.js';
import {ConsoleFormatter} from './formatters/ConsoleFormatter.js';
import {HeapSnapshotFormatter} from './formatters/HeapSnapshotFormatter.js';
import {IssueFormatter} from './formatters/IssueFormatter.js';
import {NetworkFormatter} from './formatters/NetworkFormatter.js';
import {SnapshotFormatter} from './formatters/SnapshotFormatter.js';
Expand Down Expand Up @@ -168,6 +169,16 @@ export class McpResponse implements Response {
#attachedLighthouseResult?: LighthouseData;
#textResponseLines: string[] = [];
#images: ImageContentData[] = [];
#heapSnapshotOptions?: {
include: boolean;
aggregates?: Record<
string,
DevTools.HeapSnapshotModel.HeapSnapshotModel.AggregatedInfo
>;
pagination?: PaginationOptions;
stats?: DevTools.HeapSnapshotModel.HeapSnapshotModel.Statistics;
staticData?: DevTools.HeapSnapshotModel.HeapSnapshotModel.StaticData | null;
};
#networkRequestsOptions?: {
include: boolean;
pagination?: PaginationOptions;
Expand Down Expand Up @@ -365,6 +376,33 @@ export class McpResponse implements Response {
this.#textResponseLines.push(value);
}

setHeapSnapshotAggregates(
aggregates: Record<
string,
DevTools.HeapSnapshotModel.HeapSnapshotModel.AggregatedInfo
>,
options?: PaginationOptions,
) {
this.#heapSnapshotOptions = {
...this.#heapSnapshotOptions,
include: true,
aggregates,
pagination: options,
};
}

setHeapSnapshotStats(
stats: DevTools.HeapSnapshotModel.HeapSnapshotModel.Statistics,
staticData: DevTools.HeapSnapshotModel.HeapSnapshotModel.StaticData | null,
) {
this.#heapSnapshotOptions = {
...this.#heapSnapshotOptions,
include: true,
stats,
staticData,
};
}

attachImage(value: ImageContentData): void {
this.#images.push(value);
}
Expand Down Expand Up @@ -661,6 +699,11 @@ export class McpResponse implements Response {
};
pages?: object[];
pagination?: object;
heapSnapshot?: {
stats?: object;
staticData?: object;
};
heapSnapshotData?: object[];
extensionServiceWorkers?: object[];
extensionPages?: object[];
} = {};
Expand Down Expand Up @@ -857,6 +900,40 @@ Call ${handleDialog.name} to handle it before continuing.`);
}
}

if (this.#heapSnapshotOptions?.include) {
response.push('## Heap Snapshot Data');
const stats = this.#heapSnapshotOptions.stats;
const staticData = this.#heapSnapshotOptions.staticData;
if (stats) {
response.push(`Statistics: ${JSON.stringify(stats, null, 2)}`);
structuredContent.heapSnapshot = structuredContent.heapSnapshot || {};
structuredContent.heapSnapshot.stats = stats;
}
if (staticData) {
response.push(`Static Data: ${JSON.stringify(staticData, null, 2)}`);
structuredContent.heapSnapshot = structuredContent.heapSnapshot || {};
structuredContent.heapSnapshot.staticData = staticData;
}
const aggregates = this.#heapSnapshotOptions.aggregates;
if (aggregates) {
const sortedEntries = HeapSnapshotFormatter.sort(aggregates);

const paginationData = this.#dataWithPagination(
sortedEntries,
this.#heapSnapshotOptions.pagination,
);

structuredContent.pagination = paginationData.pagination;
response.push(...paginationData.info);

const paginatedRecord = Object.fromEntries(paginationData.items);
const formatter = new HeapSnapshotFormatter(paginatedRecord);

response.push(formatter.toString());
structuredContent.heapSnapshotData = formatter.toJSON();
}
}

if (data.detailedNetworkRequest) {
response.push(data.detailedNetworkRequest.toStringDetailed());
structuredContent.networkRequest =
Expand Down
5 changes: 5 additions & 0 deletions src/bin/chrome-devtools-mcp-cli-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,11 @@ export const cliOptions = {
'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.',
hidden: false,
},
experimentalMemory: {
type: 'boolean',
describe: 'Whether to enable experimental memory tools.',
hidden: true,
},
experimentalStructuredContent: {
type: 'boolean',
describe: 'Whether to output structured formatted content.',
Expand Down
67 changes: 67 additions & 0 deletions src/formatters/HeapSnapshotFormatter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/**
Comment thread
Lightning00Blade marked this conversation as resolved.
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import type {DevTools} from '../third_party/index.js';

export interface FormattedSnapshotEntry {
className: string;
count: number;
selfSize: number;
retainedSize: number;
}

export class HeapSnapshotFormatter {
#aggregates: Record<
string,
DevTools.HeapSnapshotModel.HeapSnapshotModel.AggregatedInfo
>;

constructor(
aggregates: Record<
string,
DevTools.HeapSnapshotModel.HeapSnapshotModel.AggregatedInfo
>,
) {
this.#aggregates = aggregates;
}

#getSortedAggregates(): DevTools.HeapSnapshotModel.HeapSnapshotModel.AggregatedInfo[] {
return Object.values(this.#aggregates).sort((a, b) => b.self - a.self);
}

toString(): string {
const sorted = this.#getSortedAggregates();
const lines: string[] = [];
lines.push('className,count,selfSize,maxRetainedSize');

for (const info of sorted) {
lines.push(`"${info.name}",${info.count},${info.self},${info.maxRet}`);
}

return lines.join('\n');
}

toJSON(): FormattedSnapshotEntry[] {
const sorted = this.#getSortedAggregates();
return sorted.map(info => ({
className: info.name,
count: info.count,
selfSize: info.self,
retainedSize: info.maxRet,
}));
}

static sort(
aggregates: Record<
string,
DevTools.HeapSnapshotModel.HeapSnapshotModel.AggregatedInfo
>,
): Array<
[string, DevTools.HeapSnapshotModel.HeapSnapshotModel.AggregatedInfo]
> {
return Object.entries(aggregates).sort((a, b) => b[1].self - a[1].self);
}
}
6 changes: 6 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,12 @@ export async function createMcpServer(
) {
return;
}
if (
tool.annotations.conditions?.includes('experimentalMemory') &&
!serverArgs.experimentalMemory
) {
return;
}
if (
tool.annotations.conditions?.includes('experimentalInteropTools') &&
!serverArgs.experimentalInteropTools
Expand Down
9 changes: 9 additions & 0 deletions src/telemetry/tool_call_metrics.json
Original file line number Diff line number Diff line change
Expand Up @@ -556,5 +556,14 @@
"argType": "number"
}
]
},
{
"name": "load_memory_snapshot",
"args": [
{
"name": "file_path_length",
"argType": "number"
}
]
}
]
Loading
Loading