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 +4 -0
- package/build/src/McpContext.js +0 -125
- package/build/src/McpPage.js +198 -0
- package/build/src/McpResponse.js +6 -2
- package/build/src/TextSnapshot.js +230 -0
- package/build/src/bin/chrome-devtools-mcp-cli-options.js +10 -0
- package/build/src/bin/chrome-devtools.js +22 -14
- package/build/src/daemon/client.js +10 -10
- package/build/src/daemon/daemon.js +6 -5
- package/build/src/daemon/utils.js +19 -14
- package/build/src/third_party/THIRD_PARTY_NOTICES +27 -0
- package/build/src/third_party/bundled-packages.json +1 -0
- package/build/src/third_party/index.js +1407 -1401
- package/build/src/tools/inPage.js +2 -33
- package/build/src/tools/network.js +2 -2
- package/build/src/tools/pages.js +209 -146
- package/build/src/tools/screencast.js +19 -8
- package/build/src/version.js +1 -1
- package/package.json +2 -1
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
|
package/build/src/McpContext.js
CHANGED
|
@@ -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
|
}
|
package/build/src/McpPage.js
CHANGED
|
@@ -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
|
}
|
package/build/src/McpResponse.js
CHANGED
|
@@ -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
|
|
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:
|
|
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`',
|