Skip to content

Commit f8c676d

Browse files
committed
feat: read elements from DevTools UI
1 parent cca5ff4 commit f8c676d

File tree

8 files changed

+137
-52
lines changed

8 files changed

+137
-52
lines changed

docs/tool-reference.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -329,7 +329,8 @@ so returned values have to JSON-serializable.
329329
### `take_snapshot`
330330

331331
**Description:** Take a text snapshot of the currently selected page based on the a11y tree. The snapshot lists page elements along with a unique
332-
identifier (uid). Always use the latest snapshot. Prefer taking a snapshot over taking a screenshot.
332+
identifier (uid). Always use the latest snapshot. Prefer taking a snapshot over taking a screenshot. The snapshot indicates the element selected
333+
in the DevTools Elements panel (if any).
333334

334335
**Parameters:**
335336

src/McpContext.ts

Lines changed: 68 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -25,19 +25,21 @@ import type {
2525
import {listPages} from './tools/pages.js';
2626
import {takeSnapshot} from './tools/snapshot.js';
2727
import {CLOSE_PAGE_ERROR} from './tools/ToolDefinition.js';
28-
import type {Context} from './tools/ToolDefinition.js';
28+
import type {Context, DevToolsData} from './tools/ToolDefinition.js';
2929
import type {TraceResult} from './trace-processing/parse.js';
3030
import {WaitForHelper} from './WaitForHelper.js';
3131

3232
export interface TextSnapshotNode extends SerializedAXNode {
3333
id: string;
34+
backendNodeId?: number;
3435
children: TextSnapshotNode[];
3536
}
3637

3738
export interface TextSnapshot {
3839
root: TextSnapshotNode;
3940
idToNode: Map<string, TextSnapshotNode>;
4041
snapshotId: string;
42+
selectedElementUid?: string;
4143
}
4244

4345
interface McpContextOptions {
@@ -151,6 +153,42 @@ export class McpContext implements Context {
151153
return context;
152154
}
153155

156+
resolveCdpRequestId(cdpRequestId: string): number | undefined {
157+
const selectedPage = this.getSelectedPage();
158+
if (!cdpRequestId) {
159+
this.logger('no network request');
160+
return;
161+
}
162+
const request = this.#networkCollector.find(selectedPage, request => {
163+
// @ts-expect-error id is internal.
164+
return request.id === cdpRequestId;
165+
});
166+
if (!request) {
167+
this.logger('no network request for ' + cdpRequestId);
168+
return;
169+
}
170+
return this.#networkCollector.getIdForResource(request);
171+
}
172+
173+
resolveCdpElementId(cdpBackendNodeId: number): string | undefined {
174+
if (!cdpBackendNodeId) {
175+
this.logger('no cdpBackendNodeId');
176+
return;
177+
}
178+
// TODO: index by backendNodeId instead.
179+
const queue = [this.#textSnapshot?.root];
180+
while (queue.length) {
181+
const current = queue.pop()!;
182+
if (current.backendNodeId === cdpBackendNodeId) {
183+
return current.id;
184+
}
185+
for (const child of current.children) {
186+
queue.push(child);
187+
}
188+
}
189+
return;
190+
}
191+
154192
getNetworkRequests(includePreservedRequests?: boolean): HTTPRequest[] {
155193
const page = this.getSelectedPage();
156194
return this.#networkCollector.getData(page, includePreservedRequests);
@@ -380,49 +418,47 @@ export class McpContext implements Context {
380418
return this.#pageToDevToolsPage.get(page);
381419
}
382420

383-
async getDevToolsData(): Promise<undefined | {requestId?: number}> {
421+
async getDevToolsData(): Promise<DevToolsData> {
384422
try {
423+
this.logger('Getting DevTools UI data');
385424
const selectedPage = this.getSelectedPage();
386425
const devtoolsPage = this.getDevToolsPage(selectedPage);
387-
if (devtoolsPage) {
388-
const cdpRequestId = await devtoolsPage.evaluate(async () => {
426+
if (!devtoolsPage) {
427+
this.logger('No DevTools page detected');
428+
return {};
429+
}
430+
const {cdpRequestId, cdpBackendNodeId} = await devtoolsPage.evaluate(
431+
async () => {
389432
// @ts-expect-error no types
390433
const UI = await import('/bundled/ui/legacy/legacy.js');
391434
// @ts-expect-error no types
392435
const SDK = await import('/bundled/core/sdk/sdk.js');
393436
const request = UI.Context.Context.instance().flavor(
394437
SDK.NetworkRequest.NetworkRequest,
395438
);
396-
return request?.requestId();
397-
});
398-
if (!cdpRequestId) {
399-
this.logger('no context request');
400-
return;
401-
}
402-
const request = this.#networkCollector.find(selectedPage, request => {
403-
// @ts-expect-error id is internal.
404-
return request.id === cdpRequestId;
405-
});
406-
if (!request) {
407-
this.logger('no collected request for ' + cdpRequestId);
408-
return;
409-
}
410-
return {
411-
requestId: this.#networkCollector.getIdForResource(request),
412-
};
413-
} else {
414-
this.logger('no devtools page deteched');
415-
}
439+
const node = UI.Context.Context.instance().flavor(
440+
SDK.DOMModel.DOMNode,
441+
);
442+
return {
443+
cdpRequestId: request?.requestId(),
444+
cdpBackendNodeId: node?.backendNodeId(),
445+
};
446+
},
447+
);
448+
return {cdpBackendNodeId, cdpRequestId};
416449
} catch (err) {
417450
this.logger('error getting devtools data', err);
418451
}
419-
return;
452+
return {};
420453
}
421454

422455
/**
423456
* Creates a text snapshot of a page.
424457
*/
425-
async createTextSnapshot(verbose = false): Promise<void> {
458+
async createTextSnapshot(
459+
verbose = false,
460+
devtoolsData: DevToolsData | undefined = undefined,
461+
): Promise<void> {
426462
const page = this.getSelectedPage();
427463
const rootNode = await page.accessibility.snapshot({
428464
includeIframes: true,
@@ -465,6 +501,12 @@ export class McpContext implements Context {
465501
snapshotId: String(snapshotId),
466502
idToNode,
467503
};
504+
const data = devtoolsData ?? (await this.getDevToolsData());
505+
if (data?.cdpBackendNodeId) {
506+
this.#textSnapshot.selectedElementUid = this.resolveCdpElementId(
507+
data?.cdpBackendNodeId,
508+
);
509+
}
468510
}
469511

470512
getTextSnapshot(): TextSnapshot | null {

src/McpResponse.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {
1515
getShortDescriptionForRequest,
1616
getStatusFromRequest,
1717
} from './formatters/networkFormatter.js';
18-
import {formatA11ySnapshot} from './formatters/snapshotFormatter.js';
18+
import {formatSnapshotNode} from './formatters/snapshotFormatter.js';
1919
import type {McpContext} from './McpContext.js';
2020
import type {
2121
ConsoleMessage,
@@ -25,6 +25,7 @@ import type {
2525
} from './third_party/index.js';
2626
import {handleDialog} from './tools/pages.js';
2727
import type {
28+
DevToolsData,
2829
ImageContentData,
2930
Response,
3031
SnapshotParams,
@@ -52,6 +53,11 @@ export class McpResponse implements Response {
5253
types?: string[];
5354
includePreservedMessages?: boolean;
5455
};
56+
#devToolsData?: DevToolsData;
57+
58+
attachDevToolsData(data: DevToolsData): void {
59+
this.#devToolsData = data;
60+
}
5561

5662
setIncludePages(value: boolean): void {
5763
this.#includePages = value;
@@ -179,17 +185,22 @@ export class McpResponse implements Response {
179185

180186
let formattedSnapshot: string | undefined;
181187
if (this.#snapshotParams) {
182-
await context.createTextSnapshot(this.#snapshotParams.verbose);
188+
await context.createTextSnapshot(
189+
this.#snapshotParams.verbose,
190+
this.#devToolsData,
191+
);
183192
const snapshot = context.getTextSnapshot();
184193
if (snapshot) {
185194
if (this.#snapshotParams.filePath) {
186195
await context.saveFile(
187-
new TextEncoder().encode(formatA11ySnapshot(snapshot.root)),
196+
new TextEncoder().encode(
197+
formatSnapshotNode(snapshot.root, snapshot),
198+
),
188199
this.#snapshotParams.filePath,
189200
);
190201
formattedSnapshot = `Saved snapshot to ${this.#snapshotParams.filePath}.`;
191202
} else {
192-
formattedSnapshot = formatA11ySnapshot(snapshot.root);
203+
formattedSnapshot = formatSnapshotNode(snapshot.root, snapshot);
193204
}
194205
}
195206
}

src/formatters/snapshotFormatter.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,26 @@
33
* Copyright 2025 Google LLC
44
* SPDX-License-Identifier: Apache-2.0
55
*/
6-
import type {TextSnapshotNode} from '../McpContext.js';
6+
import type {TextSnapshot, TextSnapshotNode} from '../McpContext.js';
77

8-
export function formatA11ySnapshot(
9-
serializedAXNodeRoot: TextSnapshotNode,
8+
export function formatSnapshotNode(
9+
root: TextSnapshotNode,
10+
snapshot?: TextSnapshot,
1011
depth = 0,
1112
): string {
1213
let result = '';
13-
const attributes = getAttributes(serializedAXNodeRoot);
14-
const line = ' '.repeat(depth * 2) + attributes.join(' ') + '\n';
14+
const attributes = getAttributes(root);
15+
const line =
16+
' '.repeat(depth * 2) +
17+
attributes.join(' ') +
18+
(root.id === snapshot?.selectedElementUid
19+
? ' [selected in the DevTools Elements panel]'
20+
: '') +
21+
'\n';
1522
result += line;
1623

17-
for (const child of serializedAXNodeRoot.children) {
18-
result += formatA11ySnapshot(child, depth + 1);
24+
for (const child of root.children) {
25+
result += formatSnapshotNode(child, snapshot, depth + 1);
1926
}
2027

2128
return result;

src/tools/ToolDefinition.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,11 @@ export interface SnapshotParams {
4747
filePath?: string;
4848
}
4949

50+
export interface DevToolsData {
51+
cdpRequestId?: string;
52+
cdpBackendNodeId?: number;
53+
}
54+
5055
export interface Response {
5156
appendResponseLine(value: string): void;
5257
setIncludePages(value: boolean): void;
@@ -69,6 +74,8 @@ export interface Response {
6974
attachImage(value: ImageContentData): void;
7075
attachNetworkRequest(reqid: number): void;
7176
attachConsoleMessage(msgid: number): void;
77+
// Allows re-using DevTools data queried by some tools.
78+
attachDevToolsData(data: DevToolsData): void;
7279
}
7380

7481
/**
@@ -103,7 +110,15 @@ export type Context = Readonly<{
103110
text: string;
104111
timeout?: number | undefined;
105112
}): Promise<Element>;
106-
getDevToolsData(): Promise<undefined | {requestId?: number}>;
113+
getDevToolsData(): Promise<DevToolsData>;
114+
/**
115+
* Returns a reqid for a cdpRequestId.
116+
*/
117+
resolveCdpRequestId(cdpRequestId: string): number | undefined;
118+
/**
119+
* Returns a reqid for a cdpRequestId.
120+
*/
121+
resolveCdpElementId(cdpBackendNodeId: number): string | undefined;
107122
}>;
108123

109124
export function defineTool<Schema extends zod.ZodRawShape>(

src/tools/network.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,12 +72,16 @@ export const listNetworkRequests = defineTool({
7272
},
7373
handler: async (request, response, context) => {
7474
const data = await context.getDevToolsData();
75+
response.attachDevToolsData(data);
76+
const reqid = data?.cdpRequestId
77+
? context.resolveCdpRequestId(data.cdpRequestId)
78+
: undefined;
7579
response.setIncludeNetworkRequests(true, {
7680
pageSize: request.params.pageSize,
7781
pageIdx: request.params.pageIdx,
7882
resourceTypes: request.params.resourceTypes,
7983
includePreservedRequests: request.params.includePreservedRequests,
80-
networkRequestIdInDevToolsUI: data?.requestId,
84+
networkRequestIdInDevToolsUI: reqid,
8185
});
8286
},
8387
});
@@ -102,8 +106,12 @@ export const getNetworkRequest = defineTool({
102106
response.attachNetworkRequest(request.params.reqid);
103107
} else {
104108
const data = await context.getDevToolsData();
105-
if (data?.requestId) {
106-
response.attachNetworkRequest(data?.requestId);
109+
response.attachDevToolsData(data);
110+
const reqid = data?.cdpRequestId
111+
? context.resolveCdpRequestId(data.cdpRequestId)
112+
: undefined;
113+
if (reqid) {
114+
response.attachNetworkRequest(reqid);
107115
} else {
108116
response.appendResponseLine(
109117
`Nothing is currently selected in the DevTools Network panel.`,

src/tools/snapshot.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ import {defineTool, timeoutSchema} from './ToolDefinition.js';
1212
export const takeSnapshot = defineTool({
1313
name: 'take_snapshot',
1414
description: `Take a text snapshot of the currently selected page based on the a11y tree. The snapshot lists page elements along with a unique
15-
identifier (uid). Always use the latest snapshot. Prefer taking a snapshot over taking a screenshot.`,
15+
identifier (uid). Always use the latest snapshot. Prefer taking a snapshot over taking a screenshot. The snapshot indicates the element selected
16+
in the DevTools Elements panel (if any).`,
1617
annotations: {
1718
category: ToolCategory.DEBUGGING,
1819
// Not read-only due to filePath param.

0 commit comments

Comments
 (0)