chrome-devtools-mcp 0.22.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -591,6 +591,10 @@ The Chrome DevTools MCP server supports the following configuration option:
591
591
  Exposes experimental screencast tools (requires ffmpeg). Install ffmpeg https://www.ffmpeg.org/download.html and ensure it is available in the MCP server PATH.
592
592
  - **Type:** boolean
593
593
 
594
+ - **`--experimentalFfmpegPath`/ `--experimental-ffmpeg-path`**
595
+ Path to ffmpeg executable for screencast recording.
596
+ - **Type:** string
597
+
594
598
  - **`--experimentalWebmcp`/ `--experimental-webmcp`**
595
599
  Set to true to enable debugging WebMCP tools. Requires Chrome 149+ with the following flags: `--enable-features=WebMCPTesting,DevToolsWebMCPSupport`
596
600
  - **Type:** boolean
@@ -37,7 +37,6 @@ export class McpContext {
37
37
  #extensionPages = new WeakMap();
38
38
  #extensionServiceWorkerMap = new WeakMap();
39
39
  #nextExtensionServiceWorkerId = 1;
40
- #nextSnapshotId = 1;
41
40
  #traceResults = [];
42
41
  #locatorClass;
43
42
  #options;
@@ -105,29 +104,6 @@ export class McpContext {
105
104
  }
106
105
  return this.#networkCollector.getIdForResource(request);
107
106
  }
108
- resolveCdpElementId(page, cdpBackendNodeId) {
109
- if (!cdpBackendNodeId) {
110
- this.logger('no cdpBackendNodeId');
111
- return;
112
- }
113
- const snapshot = page.textSnapshot;
114
- if (!snapshot) {
115
- this.logger('no text snapshot');
116
- return;
117
- }
118
- // TODO: index by backendNodeId instead.
119
- const queue = [snapshot.root];
120
- while (queue.length) {
121
- const current = queue.pop();
122
- if (current.backendNodeId === cdpBackendNodeId) {
123
- return current.id;
124
- }
125
- for (const child of current.children) {
126
- queue.push(child);
127
- }
128
- }
129
- return;
130
- }
131
107
  getNetworkRequests(page, includePreservedRequests) {
132
108
  return this.#networkCollector.getData(page.pptrPage, includePreservedRequests);
133
109
  }
@@ -486,107 +462,6 @@ export class McpContext {
486
462
  getIsolatedContextName(page) {
487
463
  return this.#mcpPages.get(page)?.isolatedContextName;
488
464
  }
489
- getDevToolsPage(page) {
490
- return this.#mcpPages.get(page)?.devToolsPage;
491
- }
492
- async getDevToolsData(page) {
493
- try {
494
- this.logger('Getting DevTools UI data');
495
- const devtoolsPage = this.getDevToolsPage(page.pptrPage);
496
- if (!devtoolsPage) {
497
- this.logger('No DevTools page detected');
498
- return {};
499
- }
500
- const { cdpRequestId, cdpBackendNodeId } = await devtoolsPage.evaluate(async () => {
501
- // @ts-expect-error no types
502
- const UI = await import('/bundled/ui/legacy/legacy.js');
503
- // @ts-expect-error no types
504
- const SDK = await import('/bundled/core/sdk/sdk.js');
505
- const request = UI.Context.Context.instance().flavor(SDK.NetworkRequest.NetworkRequest);
506
- const node = UI.Context.Context.instance().flavor(SDK.DOMModel.DOMNode);
507
- return {
508
- cdpRequestId: request?.requestId(),
509
- cdpBackendNodeId: node?.backendNodeId(),
510
- };
511
- });
512
- return { cdpBackendNodeId, cdpRequestId };
513
- }
514
- catch (err) {
515
- this.logger('error getting devtools data', err);
516
- }
517
- return {};
518
- }
519
- /**
520
- * Creates a text snapshot of a page.
521
- */
522
- async createTextSnapshot(page, verbose = false, devtoolsData = undefined) {
523
- const rootNode = await page.pptrPage.accessibility.snapshot({
524
- includeIframes: true,
525
- interestingOnly: !verbose,
526
- });
527
- if (!rootNode) {
528
- return;
529
- }
530
- const { uniqueBackendNodeIdToMcpId } = page;
531
- const snapshotId = this.#nextSnapshotId++;
532
- // Iterate through the whole accessibility node tree and assign node ids that
533
- // will be used for the tree serialization and mapping ids back to nodes.
534
- let idCounter = 0;
535
- const idToNode = new Map();
536
- const seenUniqueIds = new Set();
537
- const assignIds = (node) => {
538
- let id = '';
539
- // @ts-expect-error untyped loaderId & backendNodeId.
540
- const uniqueBackendId = `${node.loaderId}_${node.backendNodeId}`;
541
- if (uniqueBackendNodeIdToMcpId.has(uniqueBackendId)) {
542
- // Re-use MCP exposed ID if the uniqueId is the same.
543
- id = uniqueBackendNodeIdToMcpId.get(uniqueBackendId);
544
- }
545
- else {
546
- // Only generate a new ID if we have not seen the node before.
547
- id = `${snapshotId}_${idCounter++}`;
548
- uniqueBackendNodeIdToMcpId.set(uniqueBackendId, id);
549
- }
550
- seenUniqueIds.add(uniqueBackendId);
551
- const nodeWithId = {
552
- ...node,
553
- id,
554
- children: node.children
555
- ? node.children.map(child => assignIds(child))
556
- : [],
557
- };
558
- // The AXNode for an option doesn't contain its `value`.
559
- // Therefore, set text content of the option as value.
560
- if (node.role === 'option') {
561
- const optionText = node.name;
562
- if (optionText) {
563
- nodeWithId.value = optionText.toString();
564
- }
565
- }
566
- idToNode.set(nodeWithId.id, nodeWithId);
567
- return nodeWithId;
568
- };
569
- const rootNodeWithId = assignIds(rootNode);
570
- const snapshot = {
571
- root: rootNodeWithId,
572
- snapshotId: String(snapshotId),
573
- idToNode,
574
- hasSelectedElement: false,
575
- verbose,
576
- };
577
- page.textSnapshot = snapshot;
578
- const data = devtoolsData ?? (await this.getDevToolsData(page));
579
- if (data?.cdpBackendNodeId) {
580
- snapshot.hasSelectedElement = true;
581
- snapshot.selectedElementUid = this.resolveCdpElementId(page, data?.cdpBackendNodeId);
582
- }
583
- // Clean up unique IDs that we did not see anymore.
584
- for (const key of uniqueBackendNodeIdToMcpId.keys()) {
585
- if (!seenUniqueIds.has(key)) {
586
- uniqueBackendNodeIdToMcpId.delete(key);
587
- }
588
- }
589
- }
590
465
  async saveTemporaryFile(data, filename) {
591
466
  return await saveTemporaryFile(data, filename);
592
467
  }
@@ -3,6 +3,8 @@
3
3
  * Copyright 2025 Google LLC
4
4
  * SPDX-License-Identifier: Apache-2.0
5
5
  */
6
+ import { logger } from './logger.js';
7
+ import { TextSnapshot } from './TextSnapshot.js';
6
8
  import { takeSnapshot } from './tools/snapshot.js';
7
9
  import { getNetworkMultiplierFromString, WaitForHelper, } from './WaitForHelper.js';
8
10
  /**
@@ -19,6 +21,7 @@ export class McpPage {
19
21
  // Snapshot
20
22
  textSnapshot = null;
21
23
  uniqueBackendNodeIdToMcpId = new Map();
24
+ extraHandles = [];
22
25
  // Emulation
23
26
  emulationSettings = {};
24
27
  // Metadata
@@ -80,6 +83,151 @@ export class McpPage {
80
83
  dispose() {
81
84
  this.pptrPage.off('dialog', this.#dialogHandler);
82
85
  }
86
+ async executeInPageTool(toolName, params, response) {
87
+ // Creates array of ElementHandles from the UIDs in the params.
88
+ // We do not replace the uids with the ElementsHandles yet, because
89
+ // the `evaluate` function only turns them into DOM elements if they
90
+ // are passed as non-nested arguments.
91
+ const handles = [];
92
+ for (const value of Object.values(params)) {
93
+ if (value instanceof Object &&
94
+ 'uid' in value &&
95
+ typeof value.uid === 'string' &&
96
+ Object.keys(value).length === 1) {
97
+ handles.push(await this.getElementByUid(value.uid));
98
+ }
99
+ }
100
+ const result = await this.pptrPage.evaluate(async (name, args, ...elements) => {
101
+ // Replace the UIDs with DOM elements.
102
+ for (const [key, value] of Object.entries(args)) {
103
+ if (value instanceof Object &&
104
+ 'uid' in value &&
105
+ typeof value.uid === 'string' &&
106
+ Object.keys(value).length === 1) {
107
+ args[key] = elements.shift();
108
+ }
109
+ }
110
+ if (!window.__dtmcp?.executeTool) {
111
+ throw new Error('No tools found on the page');
112
+ }
113
+ const toolResult = await window.__dtmcp.executeTool(name, args);
114
+ const stashDOMElement = (el) => {
115
+ if (!window.__dtmcp) {
116
+ window.__dtmcp = {};
117
+ }
118
+ if (window.__dtmcp.stashedElements === undefined) {
119
+ window.__dtmcp.stashedElements = [];
120
+ }
121
+ window.__dtmcp.stashedElements.push(el);
122
+ return {
123
+ stashedId: `stashed-${window.__dtmcp.stashedElements.length - 1}`,
124
+ };
125
+ };
126
+ const ancestors = [];
127
+ // Recursively walks the tool result:
128
+ // - Replaces DOM elements with an ID and stashes the DOM element on the window object
129
+ // - Replaces non-plain objects with a string representation of the object
130
+ // - Replaces circular references with the string '<Circular reference>'
131
+ // - Replaces functions with the string '<Function object>'
132
+ const processToolResult = (data, parentEl) => {
133
+ // 1. Handle DOM Elements
134
+ if (data instanceof Element) {
135
+ return stashDOMElement(data);
136
+ }
137
+ // 2. Handle Arrays
138
+ if (Array.isArray(data)) {
139
+ return data.map((item) => processToolResult(item, parentEl));
140
+ }
141
+ // 3. Handle Objects
142
+ if (data !== null && typeof data === 'object') {
143
+ while (ancestors.length > 0 && ancestors.at(-1) !== parentEl) {
144
+ ancestors.pop();
145
+ }
146
+ if (ancestors.includes(data)) {
147
+ return '<Circular reference>';
148
+ }
149
+ ancestors.push(data);
150
+ // If not a plain object, return a string representation of the object
151
+ if (Object.getPrototypeOf(data) !== Object.prototype) {
152
+ return `<${data.constructor.name} instance>`;
153
+ }
154
+ const processedObj = {};
155
+ for (const [key, value] of Object.entries(data)) {
156
+ processedObj[key] = processToolResult(value, data);
157
+ }
158
+ return processedObj;
159
+ }
160
+ // 4. Handle Functions
161
+ if (typeof data === 'function') {
162
+ return '<Function object>';
163
+ }
164
+ // 5. Return primitives (strings, numbers, booleans) as-is
165
+ return data;
166
+ };
167
+ return {
168
+ result: processToolResult(toolResult),
169
+ stashed: window.__dtmcp?.stashedElements?.length ?? 0,
170
+ };
171
+ }, toolName, params, ...handles);
172
+ const elementHandles = [];
173
+ for (let i = 0; i < (result.stashed ?? 0); i++) {
174
+ const elementHandle = await this.pptrPage.evaluateHandle(index => {
175
+ const el = window.__dtmcp?.stashedElements?.[index];
176
+ if (!el) {
177
+ throw new Error(`Stashed element at index ${index} not found`);
178
+ }
179
+ return el;
180
+ }, i);
181
+ elementHandles.push(elementHandle);
182
+ }
183
+ if (elementHandles.length) {
184
+ const oldHandles = [...this.extraHandles];
185
+ this.textSnapshot = await TextSnapshot.create(this, {
186
+ extraHandles: elementHandles,
187
+ });
188
+ response.includeSnapshot();
189
+ for (const handle of oldHandles) {
190
+ await handle
191
+ .dispose()
192
+ .catch(e => logger('Failed to dispose old handle', e));
193
+ }
194
+ }
195
+ const cdpElementIds = await Promise.all(elementHandles.map(async (elementHandle, index) => {
196
+ const backendNodeId = await elementHandle.backendNodeId();
197
+ if (!backendNodeId) {
198
+ logger(`No backendNodeId for stashed DOM element with index ${index}`);
199
+ return `stashed-${index}`;
200
+ }
201
+ const cdpElementId = this.resolveCdpElementId(backendNodeId);
202
+ if (!cdpElementId) {
203
+ logger(`Could not get cdpElementId for backend node ${backendNodeId}`);
204
+ return `stashed-${index}`;
205
+ }
206
+ return cdpElementId;
207
+ }));
208
+ const recursivelyReplaceStashedElements = (node) => {
209
+ if (Array.isArray(node)) {
210
+ return node.map(x => recursivelyReplaceStashedElements(x));
211
+ }
212
+ if (node !== null && typeof node === 'object') {
213
+ if ('stashedId' in node &&
214
+ typeof node.stashedId === 'string' &&
215
+ node.stashedId.startsWith('stashed-') &&
216
+ Object.keys(node).length === 1) {
217
+ const index = parseInt(node.stashedId.split('-')[1]);
218
+ return { uid: cdpElementIds[index] };
219
+ }
220
+ const resultObj = {};
221
+ for (const [key, value] of Object.entries(node)) {
222
+ resultObj[key] = recursivelyReplaceStashedElements(value);
223
+ }
224
+ return resultObj;
225
+ }
226
+ return node;
227
+ };
228
+ const resultWithUids = recursivelyReplaceStashedElements(result.result);
229
+ response.appendResponseLine(JSON.stringify(resultWithUids, null, 2));
230
+ }
83
231
  async getElementByUid(uid) {
84
232
  if (!this.textSnapshot) {
85
233
  throw new Error(`No snapshot found for page ${this.id ?? '?'}. Use ${takeSnapshot.name} to capture one.`);
@@ -108,4 +256,54 @@ export class McpPage {
108
256
  getAXNodeByUid(uid) {
109
257
  return this.textSnapshot?.idToNode.get(uid);
110
258
  }
259
+ resolveCdpElementId(cdpBackendNodeId) {
260
+ if (!cdpBackendNodeId) {
261
+ logger('no cdpBackendNodeId');
262
+ return;
263
+ }
264
+ const snapshot = this.textSnapshot;
265
+ if (!snapshot) {
266
+ logger('no text snapshot');
267
+ return;
268
+ }
269
+ // TODO: index by backendNodeId instead.
270
+ const queue = [snapshot.root];
271
+ while (queue.length) {
272
+ const current = queue.pop();
273
+ if (current.backendNodeId === cdpBackendNodeId) {
274
+ return current.id;
275
+ }
276
+ for (const child of current.children) {
277
+ queue.push(child);
278
+ }
279
+ }
280
+ return;
281
+ }
282
+ async getDevToolsData() {
283
+ try {
284
+ logger('Getting DevTools UI data');
285
+ const devtoolsPage = this.devToolsPage;
286
+ if (!devtoolsPage) {
287
+ logger('No DevTools page detected');
288
+ return {};
289
+ }
290
+ const { cdpRequestId, cdpBackendNodeId } = await devtoolsPage.evaluate(async () => {
291
+ // @ts-expect-error no types
292
+ const UI = await import('/bundled/ui/legacy/legacy.js');
293
+ // @ts-expect-error no types
294
+ const SDK = await import('/bundled/core/sdk/sdk.js');
295
+ const request = UI.Context.Context.instance().flavor(SDK.NetworkRequest.NetworkRequest);
296
+ const node = UI.Context.Context.instance().flavor(SDK.DOMModel.DOMNode);
297
+ return {
298
+ cdpRequestId: request?.requestId(),
299
+ cdpBackendNodeId: node?.backendNodeId(),
300
+ };
301
+ });
302
+ return { cdpBackendNodeId, cdpRequestId };
303
+ }
304
+ catch (err) {
305
+ logger('error getting devtools data', err);
306
+ }
307
+ return {};
308
+ }
111
309
  }
@@ -9,6 +9,7 @@ import { IssueFormatter } from './formatters/IssueFormatter.js';
9
9
  import { NetworkFormatter } from './formatters/NetworkFormatter.js';
10
10
  import { SnapshotFormatter } from './formatters/SnapshotFormatter.js';
11
11
  import { UncaughtError } from './PageCollector.js';
12
+ import { TextSnapshot } from './TextSnapshot.js';
12
13
  import { DevTools } from './third_party/index.js';
13
14
  import { handleDialog } from './tools/pages.js';
14
15
  import { getInsightOutput, getTraceSummary } from './trace-processing/parse.js';
@@ -299,7 +300,10 @@ export class McpResponse {
299
300
  if (!this.#page) {
300
301
  throw new Error('Response must have a page');
301
302
  }
302
- await context.createTextSnapshot(this.#page, this.#snapshotParams.verbose, this.#devToolsData);
303
+ this.#page.textSnapshot = await TextSnapshot.create(this.#page, {
304
+ verbose: this.#snapshotParams.verbose,
305
+ devtoolsData: this.#devToolsData,
306
+ });
303
307
  const textSnapshot = this.#page.textSnapshot;
304
308
  if (textSnapshot) {
305
309
  const formatter = new SnapshotFormatter(textSnapshot);
@@ -349,7 +353,7 @@ export class McpResponse {
349
353
  const formatter = new IssueFormatter(message, {
350
354
  id: consoleMessageStableId,
351
355
  requestIdResolver: context.resolveCdpRequestId.bind(context, this.#page),
352
- elementIdResolver: context.resolveCdpElementId.bind(context, this.#page),
356
+ elementIdResolver: this.#page.resolveCdpElementId.bind(this.#page),
353
357
  });
354
358
  if (!formatter.isValid()) {
355
359
  throw new Error("Can't provide details for the msgid " + consoleMessageStableId);
@@ -0,0 +1,230 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+ import { logger } from './logger.js';
7
+ export class TextSnapshot {
8
+ static nextSnapshotId = 1;
9
+ static resetCounter() {
10
+ TextSnapshot.nextSnapshotId = 1;
11
+ }
12
+ root;
13
+ idToNode;
14
+ snapshotId;
15
+ selectedElementUid;
16
+ hasSelectedElement;
17
+ verbose;
18
+ constructor(data) {
19
+ this.root = data.root;
20
+ this.idToNode = data.idToNode;
21
+ this.snapshotId = data.snapshotId;
22
+ this.selectedElementUid = data.selectedElementUid;
23
+ this.hasSelectedElement = data.hasSelectedElement;
24
+ this.verbose = data.verbose;
25
+ }
26
+ static async create(page, options = {}) {
27
+ const verbose = options.verbose ?? false;
28
+ const rootNode = await page.pptrPage.accessibility.snapshot({
29
+ includeIframes: true,
30
+ interestingOnly: !verbose,
31
+ });
32
+ if (!rootNode) {
33
+ throw new Error('Failed to create accessibility snapshot');
34
+ }
35
+ const { uniqueBackendNodeIdToMcpId } = page;
36
+ const snapshotId = TextSnapshot.nextSnapshotId++;
37
+ // Iterate through the whole accessibility node tree and assign node ids that
38
+ // will be used for the tree serialization and mapping ids back to nodes.
39
+ let idCounter = 0;
40
+ const idToNode = new Map();
41
+ const seenUniqueIds = new Set();
42
+ const seenBackendNodeIds = new Set();
43
+ const assignIds = (node) => {
44
+ let id = '';
45
+ // @ts-expect-error untyped backendNodeId.
46
+ const backendNodeId = node.backendNodeId;
47
+ // @ts-expect-error untyped loaderId.
48
+ const uniqueBackendId = `${node.loaderId}_${backendNodeId}`;
49
+ const existingMcpId = uniqueBackendNodeIdToMcpId.get(uniqueBackendId);
50
+ if (existingMcpId !== undefined) {
51
+ // Re-use MCP exposed ID if the uniqueId is the same.
52
+ id = existingMcpId;
53
+ }
54
+ else {
55
+ // Only generate a new ID if we have not seen the node before.
56
+ id = `${snapshotId}_${idCounter++}`;
57
+ uniqueBackendNodeIdToMcpId.set(uniqueBackendId, id);
58
+ }
59
+ seenUniqueIds.add(uniqueBackendId);
60
+ seenBackendNodeIds.add(backendNodeId);
61
+ const nodeWithId = {
62
+ ...node,
63
+ id,
64
+ children: node.children
65
+ ? node.children.map(child => assignIds(child))
66
+ : [],
67
+ };
68
+ // The AXNode for an option doesn't contain its `value`.
69
+ // Therefore, set text content of the option as value.
70
+ if (node.role === 'option') {
71
+ const optionText = node.name;
72
+ if (optionText) {
73
+ nodeWithId.value = optionText.toString();
74
+ }
75
+ }
76
+ idToNode.set(nodeWithId.id, nodeWithId);
77
+ return nodeWithId;
78
+ };
79
+ const rootNodeWithId = assignIds(rootNode);
80
+ await TextSnapshot.insertExtraNodes(page, idToNode, seenUniqueIds, snapshotId, idCounter, rootNodeWithId, seenBackendNodeIds, options.extraHandles ?? []);
81
+ const snapshot = new TextSnapshot({
82
+ root: rootNodeWithId,
83
+ snapshotId: String(snapshotId),
84
+ idToNode,
85
+ hasSelectedElement: false,
86
+ verbose,
87
+ });
88
+ const data = options.devtoolsData ?? (await page.getDevToolsData());
89
+ if (data?.cdpBackendNodeId) {
90
+ snapshot.hasSelectedElement = true;
91
+ snapshot.selectedElementUid = page.resolveCdpElementId(data.cdpBackendNodeId);
92
+ }
93
+ // Clean up unique IDs that we did not see anymore.
94
+ for (const key of uniqueBackendNodeIdToMcpId.keys()) {
95
+ if (!seenUniqueIds.has(key)) {
96
+ uniqueBackendNodeIdToMcpId.delete(key);
97
+ }
98
+ }
99
+ return snapshot;
100
+ }
101
+ // ExtraHandles represent DOM nodes which might not be part of the accessibility tree, e.g. DOM nodes
102
+ // returned by in-page tools. We insert them into the tree by finding the closest ancestor in the
103
+ // tree and inserting the node as a child. The ancestor's child nodes are re-parented if necessary.
104
+ static async insertExtraNodes(page, idToNode, seenUniqueIds, snapshotId, idCounter, rootNodeWithId, seenBackendNodeIds, extraHandles) {
105
+ const { uniqueBackendNodeIdToMcpId } = page;
106
+ const createExtraNode = async (handle) => {
107
+ const backendNodeId = await handle.backendNodeId();
108
+ if (!backendNodeId || seenBackendNodeIds.has(backendNodeId)) {
109
+ return null;
110
+ }
111
+ const uniqueBackendId = `custom_${backendNodeId}`;
112
+ if (seenUniqueIds.has(uniqueBackendId)) {
113
+ return null;
114
+ }
115
+ seenBackendNodeIds.add(backendNodeId);
116
+ let id = '';
117
+ const mcpId = uniqueBackendNodeIdToMcpId.get(uniqueBackendId);
118
+ if (mcpId !== undefined) {
119
+ id = mcpId;
120
+ }
121
+ else {
122
+ id = `${snapshotId}_${idCounter++}`;
123
+ uniqueBackendNodeIdToMcpId.set(uniqueBackendId, id);
124
+ }
125
+ seenUniqueIds.add(uniqueBackendId);
126
+ const tagHandle = await handle.getProperty('localName');
127
+ const tagValue = await tagHandle.jsonValue();
128
+ const extraNode = {
129
+ role: tagValue,
130
+ id,
131
+ backendNodeId,
132
+ children: [],
133
+ elementHandle: async () => handle,
134
+ };
135
+ return extraNode;
136
+ };
137
+ const findAncestorNode = async (handle) => {
138
+ let ancestorHandle = await handle.evaluateHandle(el => el.parentElement);
139
+ while (ancestorHandle) {
140
+ const ancestorElement = ancestorHandle.asElement();
141
+ if (!ancestorElement) {
142
+ await ancestorHandle.dispose();
143
+ return null;
144
+ }
145
+ const ancestorBackendId = await ancestorElement.backendNodeId();
146
+ if (ancestorBackendId) {
147
+ const ancestorNode = idToNode
148
+ .values()
149
+ .find(node => node.backendNodeId === ancestorBackendId);
150
+ if (ancestorNode) {
151
+ await ancestorHandle.dispose();
152
+ return ancestorNode;
153
+ }
154
+ }
155
+ const nextHandle = await ancestorElement.evaluateHandle(el => el.parentElement);
156
+ await ancestorHandle.dispose();
157
+ ancestorHandle = nextHandle;
158
+ }
159
+ return null;
160
+ };
161
+ const findDescendantNodes = async (backendNodeId) => {
162
+ const descendantIds = new Set();
163
+ try {
164
+ // @ts-expect-error internal API
165
+ const client = page.pptrPage._client();
166
+ if (client) {
167
+ const { node } = await client.send('DOM.describeNode', {
168
+ backendNodeId,
169
+ depth: -1,
170
+ pierce: true,
171
+ });
172
+ const collect = (node) => {
173
+ if (node.backendNodeId && node.backendNodeId !== backendNodeId) {
174
+ descendantIds.add(node.backendNodeId);
175
+ }
176
+ if (node.children) {
177
+ for (const child of node.children) {
178
+ collect(child);
179
+ }
180
+ }
181
+ };
182
+ collect(node);
183
+ }
184
+ }
185
+ catch (e) {
186
+ logger(`Failed to collect descendants for backend node ${backendNodeId}`, e);
187
+ }
188
+ return descendantIds;
189
+ };
190
+ const moveChildNodes = (attachTarget, extraNode, descendantIds) => {
191
+ let firstMovedIndex = -1;
192
+ if (descendantIds.size > 0 && attachTarget.children) {
193
+ const remainingChildren = [];
194
+ for (const child of attachTarget.children) {
195
+ if (child.backendNodeId && descendantIds.has(child.backendNodeId)) {
196
+ if (firstMovedIndex === -1) {
197
+ firstMovedIndex = remainingChildren.length;
198
+ }
199
+ extraNode.children.push(child);
200
+ }
201
+ else {
202
+ remainingChildren.push(child);
203
+ }
204
+ }
205
+ attachTarget.children = remainingChildren;
206
+ }
207
+ return firstMovedIndex !== -1
208
+ ? firstMovedIndex
209
+ : attachTarget.children
210
+ ? attachTarget.children.length
211
+ : 0;
212
+ };
213
+ if (extraHandles.length) {
214
+ page.extraHandles = extraHandles;
215
+ }
216
+ for (const handle of page.extraHandles) {
217
+ const extraNode = await createExtraNode(handle);
218
+ if (!extraNode) {
219
+ continue;
220
+ }
221
+ idToNode.set(extraNode.id, extraNode);
222
+ const attachTarget = (await findAncestorNode(handle)) || rootNodeWithId;
223
+ if (extraNode.backendNodeId !== undefined) {
224
+ const descendantIds = await findDescendantNodes(extraNode.backendNodeId);
225
+ const index = moveChildNodes(attachTarget, extraNode, descendantIds);
226
+ attachTarget.children.splice(index, 0, extraNode);
227
+ }
228
+ }
229
+ }
230
+ }
@@ -164,6 +164,11 @@ export const cliOptions = {
164
164
  describe: 'Whether to include all kinds of pages such as webviews or background pages as pages.',
165
165
  hidden: true,
166
166
  },
167
+ experimentalNavigationAllowlist: {
168
+ type: 'boolean',
169
+ describe: 'Whether to enable navigation allowlist tool parameter.',
170
+ hidden: true,
171
+ },
167
172
  experimentalInteropTools: {
168
173
  type: 'boolean',
169
174
  describe: 'Whether to enable interoperability tools',
@@ -173,6 +178,11 @@ export const cliOptions = {
173
178
  type: 'boolean',
174
179
  describe: 'Exposes experimental screencast tools (requires ffmpeg). Install ffmpeg https://www.ffmpeg.org/download.html and ensure it is available in the MCP server PATH.',
175
180
  },
181
+ experimentalFfmpegPath: {
182
+ type: 'string',
183
+ describe: 'Path to ffmpeg executable for screencast recording.',
184
+ implies: 'experimentalScreencast',
185
+ },
176
186
  experimentalWebmcp: {
177
187
  type: 'boolean',
178
188
  describe: 'Set to true to enable debugging WebMCP tools. Requires Chrome 149+ with the following flags: `--enable-features=WebMCPTesting,DevToolsWebMCPSupport`',