From 4dc723a44c2aecfcf1e79ad59e82ae36dfbe8b4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Inf=C3=BChr?= Date: Tue, 2 Jun 2026 16:51:20 +0200 Subject: [PATCH] Add retaining paths MCP tool --- README.md | 3 +- docs/tool-reference.md | 17 +++++- src/HeapSnapshotManager.ts | 20 +++++++ src/McpContext.ts | 16 ++++++ src/McpResponse.ts | 32 +++++++++++ src/bin/chrome-devtools-cli-options.ts | 37 ++++++++++++ src/formatters/HeapSnapshotFormatter.ts | 25 ++++++++ src/telemetry/tool_call_metrics.json | 25 ++++++++ src/tools/ToolDefinition.ts | 10 ++++ src/tools/memory.ts | 40 +++++++++++++ .../formatters/HeapSnapshotFormatter.test.ts | 37 ++++++++++++ tests/tools/memory.test.js.snapshot | 26 +++++++++ tests/tools/memory.test.ts | 57 +++++++++++++++++++ 13 files changed, 343 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ea3f4e962..d92c80d77 100644 --- a/README.md +++ b/README.md @@ -514,12 +514,13 @@ If you run into any issues, checkout our [troubleshooting guide](./docs/troubles - [`take_snapshot`](docs/tool-reference.md#take_snapshot) - [`screencast_start`](docs/tool-reference.md#screencast_start) - [`screencast_stop`](docs/tool-reference.md#screencast_stop) -- **Memory** (6 tools) +- **Memory** (7 tools) - [`take_heapsnapshot`](docs/tool-reference.md#take_heapsnapshot) - [`close_heapsnapshot`](docs/tool-reference.md#close_heapsnapshot) - [`get_heapsnapshot_class_nodes`](docs/tool-reference.md#get_heapsnapshot_class_nodes) - [`get_heapsnapshot_details`](docs/tool-reference.md#get_heapsnapshot_details) - [`get_heapsnapshot_retainers`](docs/tool-reference.md#get_heapsnapshot_retainers) + - [`get_heapsnapshot_retaining_paths`](docs/tool-reference.md#get_heapsnapshot_retaining_paths) - [`get_heapsnapshot_summary`](docs/tool-reference.md#get_heapsnapshot_summary) - **Extensions** (5 tools) - [`install_extension`](docs/tool-reference.md#install_extension) diff --git a/docs/tool-reference.md b/docs/tool-reference.md index 91380a00b..72bb5ad5a 100644 --- a/docs/tool-reference.md +++ b/docs/tool-reference.md @@ -39,12 +39,13 @@ - [`take_snapshot`](#take_snapshot) - [`screencast_start`](#screencast_start) - [`screencast_stop`](#screencast_stop) -- **[Memory](#memory)** (6 tools) +- **[Memory](#memory)** (7 tools) - [`take_heapsnapshot`](#take_heapsnapshot) - [`close_heapsnapshot`](#close_heapsnapshot) - [`get_heapsnapshot_class_nodes`](#get_heapsnapshot_class_nodes) - [`get_heapsnapshot_details`](#get_heapsnapshot_details) - [`get_heapsnapshot_retainers`](#get_heapsnapshot_retainers) + - [`get_heapsnapshot_retaining_paths`](#get_heapsnapshot_retaining_paths) - [`get_heapsnapshot_summary`](#get_heapsnapshot_summary) - **[Extensions](#extensions)** (5 tools) - [`install_extension`](#install_extension) @@ -503,6 +504,20 @@ in the DevTools Elements panel (if any). --- +### `get_heapsnapshot_retaining_paths` + +**Description:** Loads a memory heapsnapshot and returns retaining paths for a specific node ID. This helps to understand why a node is not being garbage collected. (requires flag: --memoryDebugging=true) + +**Parameters:** + +- **filePath** (string) **(required)**: A path to a .heapsnapshot file to read. +- **nodeId** (number) **(required)**: The node ID to get retaining paths for. +- **maxDepth** (number) _(optional)_: The maximum depth to search for retaining paths. +- **maxNodes** (number) _(optional)_: The maximum number of nodes to return. +- **maxSiblings** (number) _(optional)_: The maximum number of siblings to return. + +--- + ### `get_heapsnapshot_summary` **Description:** Loads a memory heapsnapshot and returns snapshot summary stats. (requires flag: --memoryDebugging=true) diff --git a/src/HeapSnapshotManager.ts b/src/HeapSnapshotManager.ts index 750a6c947..772f431c4 100644 --- a/src/HeapSnapshotManager.ts +++ b/src/HeapSnapshotManager.ts @@ -150,6 +150,26 @@ export class HeapSnapshotManager { return await provider.serializeItemsRange(0, Infinity); } + async getRetainingPaths( + filePath: string, + nodeId: number, + maxDepth?: number, + maxNodes?: number, + maxSiblings?: number, + ): Promise { + const nodeIndex = await this.findNodeIndexById(filePath, nodeId); + if (nodeIndex === undefined) { + throw new Error(`Node with ID ${nodeId} not found`); + } + const snapshot = await this.getSnapshot(filePath); + return await snapshot.getRetainingPaths( + nodeIndex, + maxDepth, + maxNodes, + maxSiblings, + ); + } + #getCachedSnapshot(filePath: string) { const absolutePath = path.resolve(filePath); const cached = this.#snapshots.get(absolutePath); diff --git a/src/McpContext.ts b/src/McpContext.ts index 4346070d2..0861f0e3e 100644 --- a/src/McpContext.ts +++ b/src/McpContext.ts @@ -906,4 +906,20 @@ export class McpContext implements Context { hasHeapSnapshots(): boolean { return this.#heapSnapshotManager.hasSnapshots(); } + + async getHeapSnapshotRetainingPaths( + filePath: string, + nodeId: number, + maxDepth?: number, + maxNodes?: number, + maxSiblings?: number, + ): Promise { + return await this.#heapSnapshotManager.getRetainingPaths( + filePath, + nodeId, + maxDepth, + maxNodes, + maxSiblings, + ); + } } diff --git a/src/McpResponse.ts b/src/McpResponse.ts index 9dfea8be2..00baad2ac 100644 --- a/src/McpResponse.ts +++ b/src/McpResponse.ts @@ -219,6 +219,7 @@ export class McpResponse implements Response { stats?: DevTools.HeapSnapshotModel.HeapSnapshotModel.Statistics; staticData?: DevTools.HeapSnapshotModel.HeapSnapshotModel.StaticData | null; nodes?: DevTools.HeapSnapshotModel.HeapSnapshotModel.ItemsRange; + retainingPaths?: DevTools.HeapSnapshotModel.HeapSnapshotModel.RetainingPaths; }; #networkRequestsOptions?: { include: boolean; @@ -472,6 +473,16 @@ export class McpResponse implements Response { }; } + setHeapSnapshotRetainingPaths( + retainingPaths: DevTools.HeapSnapshotModel.HeapSnapshotModel.RetainingPaths, + ) { + this.#heapSnapshotOptions = { + ...this.#heapSnapshotOptions, + include: true, + retainingPaths, + }; + } + attachImage(value: ImageContentData): void { this.#images.push(value); } @@ -780,6 +791,7 @@ export class McpResponse implements Response { }; heapSnapshotData?: object[]; heapSnapshotNodes?: readonly object[]; + heapSnapshotRetainingPaths?: object; extensionServiceWorkers?: object[]; extensionPages?: object[]; errorMessage?: string; @@ -1056,6 +1068,26 @@ Call ${handleDialog.name} to handle it before continuing.`); structuredContent.heapSnapshotNodes = paginationData.items; } + const retainingPaths = this.#heapSnapshotOptions.retainingPaths; + if (retainingPaths) { + response.push('### Retaining Paths'); + const {paths, limitsReached} = retainingPaths; + if (paths.length === 0) { + response.push('No retaining paths found.'); + } else { + response.push(HeapSnapshotFormatter.formatRetainingPaths(paths)); + } + const reached = Object.entries(limitsReached) + .filter(([, hit]) => hit) + .map(([limit]) => limit); + if (reached.length > 0) { + response.push( + `Note: results are truncated, the following limits were reached: ${reached.join(', ')}.`, + ); + } + structuredContent.heapSnapshotRetainingPaths = + retainingPaths as unknown as object; + } } if (data.detailedNetworkRequest) { diff --git a/src/bin/chrome-devtools-cli-options.ts b/src/bin/chrome-devtools-cli-options.ts index 235c02b31..bbe1f1150 100644 --- a/src/bin/chrome-devtools-cli-options.ts +++ b/src/bin/chrome-devtools-cli-options.ts @@ -391,6 +391,43 @@ export const commands: Commands = { }, }, }, + get_heapsnapshot_retaining_paths: { + description: + 'Loads a memory heapsnapshot and returns retaining paths for a specific node ID. This helps to understand why a node is not being garbage collected. (requires flag: --memoryDebugging=true)', + category: 'Memory', + args: { + filePath: { + name: 'filePath', + type: 'string', + description: 'A path to a .heapsnapshot file to read.', + required: true, + }, + nodeId: { + name: 'nodeId', + type: 'number', + description: 'The node ID to get retaining paths for.', + required: true, + }, + maxDepth: { + name: 'maxDepth', + type: 'number', + description: 'The maximum depth to search for retaining paths.', + required: false, + }, + maxNodes: { + name: 'maxNodes', + type: 'number', + description: 'The maximum number of nodes to return.', + required: false, + }, + maxSiblings: { + name: 'maxSiblings', + type: 'number', + description: 'The maximum number of siblings to return.', + required: false, + }, + }, + }, get_heapsnapshot_summary: { description: 'Loads a memory heapsnapshot and returns snapshot summary stats. (requires flag: --memoryDebugging=true)', diff --git a/src/formatters/HeapSnapshotFormatter.ts b/src/formatters/HeapSnapshotFormatter.ts index e5f0a7702..9e04de4ff 100644 --- a/src/formatters/HeapSnapshotFormatter.ts +++ b/src/formatters/HeapSnapshotFormatter.ts @@ -79,6 +79,31 @@ export class HeapSnapshotFormatter { return lines.join('\n'); } + static formatRetainingPaths( + retainingPaths: readonly DevTools.HeapSnapshotModel.HeapSnapshotModel.RetainingEdge[], + ): string { + const lines: string[] = []; + + function formatEdge( + edge: DevTools.HeapSnapshotModel.HeapSnapshotModel.RetainingEdge, + depth: number, + ) { + const indent = ' '.repeat(depth); + lines.push( + `${indent}<- @${edge.nodeId} ${edge.nodeName} via ${edge.edgeType} ${edge.edgeName} (distance: ${edge.distance})`, + ); + for (const child of edge.children) { + formatEdge(child, depth + 1); + } + } + + for (const path of retainingPaths) { + formatEdge(path, 0); + } + + return lines.join('\n'); + } + #getSortedAggregates(): AggregatedInfoWithId[] { return Object.values(this.#aggregates).sort((a, b) => b.maxRet - a.maxRet); } diff --git a/src/telemetry/tool_call_metrics.json b/src/telemetry/tool_call_metrics.json index 90a99753e..a02f27221 100644 --- a/src/telemetry/tool_call_metrics.json +++ b/src/telemetry/tool_call_metrics.json @@ -747,5 +747,30 @@ "argType": "number" } ] + }, + { + "name": "get_heapsnapshot_retaining_paths", + "args": [ + { + "name": "file_path_length", + "argType": "number" + }, + { + "name": "node_id", + "argType": "number" + }, + { + "name": "max_depth", + "argType": "number" + }, + { + "name": "max_nodes", + "argType": "number" + }, + { + "name": "max_siblings", + "argType": "number" + } + ] } ] diff --git a/src/tools/ToolDefinition.ts b/src/tools/ToolDefinition.ts index f2db6ca15..b0ca8bc57 100644 --- a/src/tools/ToolDefinition.ts +++ b/src/tools/ToolDefinition.ts @@ -116,6 +116,9 @@ export interface Response { nodes: DevTools.HeapSnapshotModel.HeapSnapshotModel.ItemsRange, options?: PaginationOptions, ): void; + setHeapSnapshotRetainingPaths( + retainingPaths: DevTools.HeapSnapshotModel.HeapSnapshotModel.RetainingPaths, + ): void; setIncludePages(value: boolean): void; setIncludeNetworkRequests( value: boolean, @@ -249,6 +252,13 @@ export type Context = Readonly<{ nodeId: number, ): Promise; closeHeapSnapshot(filePath: string): Promise; + getHeapSnapshotRetainingPaths( + filePath: string, + nodeId: number, + maxDepth?: number, + maxNodes?: number, + maxSiblings?: number, + ): Promise; }>; /** diff --git a/src/tools/memory.ts b/src/tools/memory.ts index 3a0519d3d..c4448b0c0 100644 --- a/src/tools/memory.ts +++ b/src/tools/memory.ts @@ -183,3 +183,43 @@ export const closeHeapSnapshot = defineTool({ ); }, }); + +export const getHeapSnapshotRetainingPaths = defineTool({ + name: 'get_heapsnapshot_retaining_paths', + description: + 'Loads a memory heapsnapshot and returns retaining paths for a specific node ID. This helps to understand why a node is not being garbage collected.', + annotations: { + category: ToolCategory.MEMORY, + readOnlyHint: true, + conditions: ['memoryDebugging'], + }, + verifyFilesSchema: ['filePath'], + blockedByDialog: false, + schema: { + filePath: zod.string().describe('A path to a .heapsnapshot file to read.'), + nodeId: zod.number().describe('The node ID to get retaining paths for.'), + maxDepth: zod + .number() + .optional() + .describe('The maximum depth to search for retaining paths.'), + maxNodes: zod + .number() + .optional() + .describe('The maximum number of nodes to return.'), + maxSiblings: zod + .number() + .optional() + .describe('The maximum number of siblings to return.'), + }, + handler: async (request, response, context) => { + const retainingPaths = await context.getHeapSnapshotRetainingPaths( + request.params.filePath, + request.params.nodeId, + request.params.maxDepth, + request.params.maxNodes, + request.params.maxSiblings, + ); + + response.setHeapSnapshotRetainingPaths(retainingPaths); + }, +}); diff --git a/tests/formatters/HeapSnapshotFormatter.test.ts b/tests/formatters/HeapSnapshotFormatter.test.ts index 11d0e0208..c1e3f412d 100644 --- a/tests/formatters/HeapSnapshotFormatter.test.ts +++ b/tests/formatters/HeapSnapshotFormatter.test.ts @@ -157,4 +157,41 @@ describe('HeapSnapshotFormatter', () => { assert.strictEqual(result[1][0], 'ObjectB'); }); }); + + describe('formatRetainingPaths', () => { + it('formats retaining paths correctly', () => { + const mockRetainingPaths = [ + { + edgeIndex: 0, + edgeName: 'foo', + edgeType: 'property', + nodeId: 10, + nodeIndex: 1, + nodeName: 'ClassA', + distance: 2, + children: [ + { + edgeIndex: 0, + edgeName: 'bar', + edgeType: 'element', + nodeId: 20, + nodeIndex: 2, + nodeName: 'ClassB', + distance: 1, + children: [], + }, + ], + }, + ] as unknown as DevTools.HeapSnapshotModel.HeapSnapshotModel.RetainingEdge[]; + + const result = + HeapSnapshotFormatter.formatRetainingPaths(mockRetainingPaths); + const expected = [ + '<- @10 ClassA via property foo (distance: 2)', + ' <- @20 ClassB via element bar (distance: 1)', + ].join('\n'); + + assert.strictEqual(result, expected); + }); + }); }); diff --git a/tests/tools/memory.test.js.snapshot b/tests/tools/memory.test.js.snapshot index 1a9189d8f..0d700ed0c 100644 --- a/tests/tools/memory.test.js.snapshot +++ b/tests/tools/memory.test.js.snapshot @@ -184,6 +184,32 @@ initial_string_prototype,internal,7199,system / NativeContext Showing 1-3 of 3 (Page 1 of 1). `; +exports[`memory > get_heapsnapshot_retaining_paths > with valid nodeId 1`] = ` +## Heap Snapshot Data +### Retaining Paths +<- @45891 system / Context via context builtinToJSONs (distance: 4) + <- @34237 safe via internal context (distance: 3) + <- @27635 Window (global*) / https://example.com via property (distance: 2) + <- @34235 system / PropertyCell via internal value (distance: 4) + <- @50423 (object properties) via internal 1912 (distance: 3) + <- @27635 Window (global*) / https://example.com via internal properties (distance: 2) + <- @34213 safe via internal context (distance: 3) + <- @27635 Window (global*) / https://example.com via property (distance: 2) + <- @34197 safe via internal context (distance: 3) + <- @27635 Window (global*) / https://example.com via property (distance: 2) + <- @34185 safe via internal context (distance: 3) + <- @27635 Window (global*) / https://example.com via property (distance: 2) + <- @34145 safe via internal context (distance: 3) + <- @27635 Window (global*) / https://example.com via property (distance: 2) + <- @34069 safe via internal context (distance: 3) + <- @27635 Window (global*) / https://example.com via property (distance: 2) + <- @34047 safe via internal context (distance: 3) + <- @27635 Window (global*) / https://example.com via property (distance: 2) + <- @46063 stringify via internal context (distance: 4) + <- @34247 {parse, stringify} via property stringify (distance: 3) + <- @27635 Window (global*) / https://example.com via property (distance: 2) +`; + exports[`memory > get_heapsnapshot_summary > with default options 1`] = ` ## Heap Snapshot Data Statistics: { diff --git a/tests/tools/memory.test.ts b/tests/tools/memory.test.ts index dcb32d2d8..4c46d95b0 100644 --- a/tests/tools/memory.test.ts +++ b/tests/tools/memory.test.ts @@ -18,6 +18,7 @@ import { getHeapSnapshotClassNodes, getHeapSnapshotRetainers, closeHeapSnapshot, + getHeapSnapshotRetainingPaths, } from '../../src/tools/memory.js'; import {withMcpContext} from '../utils.js'; @@ -223,4 +224,60 @@ describe('memory', () => { }); }); }); + + describe('get_heapsnapshot_retaining_paths', () => { + it('with valid nodeId', async t => { + await withMcpContext(async (response, context) => { + const filePath = join( + process.cwd(), + 'tests/fixtures/example.heapsnapshot', + ); + + await getHeapSnapshotRetainingPaths.handler( + {params: {filePath, nodeId: 45901}}, + response, + context, + ); + + const responseData = await response.handle( + getHeapSnapshotRetainingPaths.name, + context, + ); + const output = responseData.content + .map(c => (c.type === 'text' ? c.text : '')) + .join('\n'); + + t.assert.snapshot(output); + }); + }); + + it('reports when limits are reached', async () => { + await withMcpContext(async (response, context) => { + const filePath = join( + process.cwd(), + 'tests/fixtures/example.heapsnapshot', + ); + + await getHeapSnapshotRetainingPaths.handler( + {params: {filePath, nodeId: 45901, maxDepth: 1}}, + response, + context, + ); + + const responseData = await response.handle( + getHeapSnapshotRetainingPaths.name, + context, + ); + const output = responseData.content + .map(c => (c.type === 'text' ? c.text : '')) + .join('\n'); + + assert.match(output, /No retaining paths found\./); + assert.match( + output, + /Note: results are truncated, the following limits were reached: depth\./, + ); + }); + }); + }); });