feat: Add retaining paths MCP tool (#2187)

This PR adds the `get_heapsnapshot_retaining_paths` MCP tool. This can
be used to find the paths from the target object to the GC roots which
keep that object alive.

Co-authored-by: Dominik Inführ <dinfuehr@chromium.org>
This commit is contained in:
Dominik Inführ
2026-06-09 14:53:30 +02:00
committed by GitHub
parent bede4fbdfa
commit a97c642d43
13 changed files with 343 additions and 2 deletions
+2 -1
View File
@@ -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)
+16 -1
View File
@@ -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)
@@ -504,6 +505,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)
+20
View File
@@ -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<DevTools.HeapSnapshotModel.HeapSnapshotModel.RetainingPaths> {
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);
+16
View File
@@ -924,4 +924,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<DevTools.HeapSnapshotModel.HeapSnapshotModel.RetainingPaths> {
return await this.#heapSnapshotManager.getRetainingPaths(
filePath,
nodeId,
maxDepth,
maxNodes,
maxSiblings,
);
}
}
+32
View File
@@ -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;
@@ -475,6 +476,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);
}
@@ -801,6 +812,7 @@ export class McpResponse implements Response {
};
heapSnapshotData?: object[];
heapSnapshotNodes?: readonly object[];
heapSnapshotRetainingPaths?: object;
extensionServiceWorkers?: object[];
extensionPages?: object[];
errorMessage?: string;
@@ -1085,6 +1097,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) {
+37
View File
@@ -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)',
+25
View File
@@ -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);
}
+25
View File
@@ -751,5 +751,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"
}
]
}
]
+10
View File
@@ -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,
@@ -250,6 +253,13 @@ export type Context = Readonly<{
nodeId: number,
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.ItemsRange>;
closeHeapSnapshot(filePath: string): Promise<boolean>;
getHeapSnapshotRetainingPaths(
filePath: string,
nodeId: number,
maxDepth?: number,
maxNodes?: number,
maxSiblings?: number,
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.RetainingPaths>;
}>;
/**
+40
View File
@@ -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);
},
});
@@ -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);
});
});
});
+26
View File
@@ -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 <symbol extensions::SafeBuiltins::Promise> (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 <symbol extensions::SafeBuiltins::Error> (distance: 2)
<- @34197 safe via internal context (distance: 3)
<- @27635 Window (global*) / https://example.com via property <symbol extensions::SafeBuiltins::RegExp> (distance: 2)
<- @34185 safe via internal context (distance: 3)
<- @27635 Window (global*) / https://example.com via property <symbol extensions::SafeBuiltins::String> (distance: 2)
<- @34145 safe via internal context (distance: 3)
<- @27635 Window (global*) / https://example.com via property <symbol extensions::SafeBuiltins::Array> (distance: 2)
<- @34069 safe via internal context (distance: 3)
<- @27635 Window (global*) / https://example.com via property <symbol extensions::SafeBuiltins::Function> (distance: 2)
<- @34047 safe via internal context (distance: 3)
<- @27635 Window (global*) / https://example.com via property <symbol extensions::SafeBuiltins::Object> (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 <symbol extensions::SafeBuiltins::JSON> (distance: 2)
`;
exports[`memory > get_heapsnapshot_summary > with default options 1`] = `
## Heap Snapshot Data
Statistics: {
+57
View File
@@ -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\./,
);
});
});
});
});