Skip to content

Commit 83b53db

Browse files
committed
move to McpPage
1 parent 7f51c3a commit 83b53db

5 files changed

Lines changed: 229 additions & 256 deletions

File tree

src/McpContext.ts

Lines changed: 24 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,6 @@ import type {
3838
Context,
3939
DevToolsData,
4040
SupportedExtensions,
41-
ContextPage,
4241
} from './tools/ToolDefinition.js';
4342
import type {TraceResult} from './trace-processing/parse.js';
4443
import type {
@@ -80,7 +79,7 @@ export class McpContext implements Context {
8079
#extensionServiceWorkers: ExtensionServiceWorker[] = [];
8180

8281
#mcpPages = new Map<Page, McpPage>();
83-
#selectedPage?: ContextPage;
82+
#selectedPage?: McpPage;
8483
#networkCollector: NetworkCollector;
8584
#consoleCollector: ConsoleCollector;
8685
#devtoolsUniverseManager: UniverseManager;
@@ -166,10 +165,7 @@ export class McpContext implements Context {
166165
return context;
167166
}
168167

169-
resolveCdpRequestId(
170-
page: ContextPage,
171-
cdpRequestId: string,
172-
): number | undefined {
168+
resolveCdpRequestId(page: McpPage, cdpRequestId: string): number | undefined {
173169
if (!cdpRequestId) {
174170
this.logger('no network request');
175171
return;
@@ -186,14 +182,14 @@ export class McpContext implements Context {
186182
}
187183

188184
resolveCdpElementId(
189-
page: ContextPage,
185+
page: McpPage,
190186
cdpBackendNodeId: number,
191187
): string | undefined {
192188
if (!cdpBackendNodeId) {
193189
this.logger('no cdpBackendNodeId');
194190
return;
195191
}
196-
const snapshot = page.getSnapshot();
192+
const snapshot = page.textSnapshot;
197193
if (!snapshot) {
198194
this.logger('no text snapshot');
199195
return;
@@ -286,7 +282,7 @@ export class McpContext implements Context {
286282
return this.#networkCollector.getById(page.pptrPage, reqid);
287283
}
288284

289-
async restoreEmulation(page: ContextPage) {
285+
async restoreEmulation(page: McpPage) {
290286
const currentSetting = page.emulationSettings;
291287
await this.emulate(currentSetting, page.pptrPage);
292288
}
@@ -452,7 +448,7 @@ export class McpContext implements Context {
452448
return this.#selectedPage?.pptrPage === page;
453449
}
454450

455-
selectPage(newPage: ContextPage): void {
451+
selectPage(newPage: McpPage): void {
456452
this.#selectedPage = newPage;
457453
this.#updateSelectedPageTimeouts();
458454
}
@@ -685,7 +681,7 @@ export class McpContext implements Context {
685681
return this.#mcpPages.get(page)?.devToolsPage;
686682
}
687683

688-
async getDevToolsData(page: ContextPage): Promise<DevToolsData> {
684+
async getDevToolsData(page: McpPage): Promise<DevToolsData> {
689685
try {
690686
this.logger('Getting DevTools UI data');
691687
const devtoolsPage = this.getDevToolsPage(page.pptrPage);
@@ -722,10 +718,10 @@ export class McpContext implements Context {
722718
* Creates a text snapshot of a page.
723719
*/
724720
async createTextSnapshot(
725-
page: ContextPage,
721+
page: McpPage,
726722
verbose = false,
727723
devtoolsData: DevToolsData | undefined = undefined,
728-
extraHandles?: ElementHandle[],
724+
extraHandles: ElementHandle[] = [],
729725
): Promise<void> {
730726
const rootNode = await page.pptrPage.accessibility.snapshot({
731727
includeIframes: true,
@@ -784,18 +780,16 @@ export class McpContext implements Context {
784780

785781
const rootNodeWithId = assignIds(rootNode);
786782

787-
if (extraHandles) {
788-
await this.#insertExtraNodes(
789-
page,
790-
idToNode,
791-
seenUniqueIds,
792-
snapshotId,
793-
idCounter,
794-
rootNodeWithId,
795-
seenBackendNodeIds,
796-
extraHandles,
797-
);
798-
}
783+
await this.#insertExtraNodes(
784+
page,
785+
idToNode,
786+
seenUniqueIds,
787+
snapshotId,
788+
idCounter,
789+
rootNodeWithId,
790+
seenBackendNodeIds,
791+
extraHandles,
792+
);
799793

800794
const snapshot: TextSnapshot = {
801795
root: rootNodeWithId,
@@ -804,7 +798,7 @@ export class McpContext implements Context {
804798
hasSelectedElement: false,
805799
verbose,
806800
};
807-
page.setSnapshot(snapshot);
801+
page.textSnapshot = snapshot;
808802
const data = devtoolsData ?? (await this.getDevToolsData(page));
809803
if (data?.cdpBackendNodeId) {
810804
snapshot.hasSelectedElement = true;
@@ -826,7 +820,7 @@ export class McpContext implements Context {
826820
// returned by in-page tools. We insert them into the tree by finding the closest ancestor in the
827821
// tree and inserting the node as a child. The ancestor's child nodes are re-parented if necessary.
828822
async #insertExtraNodes(
829-
page: ContextPage,
823+
page: McpPage,
830824
idToNode: Map<string, TextSnapshotNode>,
831825
seenUniqueIds: Set<string>,
832826
snapshotId: number,
@@ -968,10 +962,10 @@ export class McpContext implements Context {
968962
: 0;
969963
};
970964

971-
if (extraHandles) {
972-
page.setExtraHandles(extraHandles);
965+
if (extraHandles.length) {
966+
page.extraHandles = extraHandles;
973967
}
974-
for (const handle of page.getExtraHandles() ?? []) {
968+
for (const handle of page.extraHandles) {
975969
const extraNode = await createExtraNode(handle);
976970
if (!extraNode) {
977971
continue;

src/McpPage.ts

Lines changed: 195 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
* SPDX-License-Identifier: Apache-2.0
55
*/
66

7+
import {logger} from './logger.js';
78
import type {
89
Dialog,
910
ElementHandle,
@@ -13,7 +14,7 @@ import type {
1314
} from './third_party/index.js';
1415
import type {ToolGroup, ToolDefinition} from './tools/inPage.js';
1516
import {takeSnapshot} from './tools/snapshot.js';
16-
import type {ContextPage} from './tools/ToolDefinition.js';
17+
import type {ContextPage, Context, Response} from './tools/ToolDefinition.js';
1718
import type {
1819
EmulationSettings,
1920
GeolocationOptions,
@@ -40,7 +41,7 @@ export class McpPage implements ContextPage {
4041
// Snapshot
4142
textSnapshot: TextSnapshot | null = null;
4243
uniqueBackendNodeIdToMcpId = new Map<string, string>();
43-
extraHandles?: ElementHandle[];
44+
extraHandles: ElementHandle[] = [];
4445

4546
// Emulation
4647
emulationSettings: EmulationSettings = {};
@@ -131,6 +132,198 @@ export class McpPage implements ContextPage {
131132
this.pptrPage.off('dialog', this.#dialogHandler);
132133
}
133134

135+
async executeInPageTool(
136+
toolName: string,
137+
params: Record<string, unknown>,
138+
response: Response,
139+
context: Context,
140+
): Promise<void> {
141+
// Creates array of ElementHandles from the UIDs in the params.
142+
// We do not replace the uids with the ElementsHandles yet, because
143+
// the `evaluate` function only turns them into DOM elements if they
144+
// are passed as non-nested arguments.
145+
const handles: ElementHandle[] = [];
146+
for (const value of Object.values(params)) {
147+
if (
148+
value instanceof Object &&
149+
'uid' in value &&
150+
typeof value.uid === 'string' &&
151+
Object.keys(value).length === 1
152+
) {
153+
handles.push(await this.getElementByUid(value.uid));
154+
}
155+
}
156+
157+
const result = await this.pptrPage.evaluate(
158+
async (name, args, ...elements) => {
159+
// Replace the UIDs with DOM elements.
160+
for (const [key, value] of Object.entries(args)) {
161+
if (
162+
value instanceof Object &&
163+
'uid' in value &&
164+
typeof value.uid === 'string' &&
165+
Object.keys(value).length === 1
166+
) {
167+
args[key] = elements.shift();
168+
}
169+
}
170+
171+
if (!window.__dtmcp?.executeTool) {
172+
throw new Error('No tools found on the page');
173+
}
174+
const toolResult = await window.__dtmcp.executeTool(name, args);
175+
176+
const stashDOMElement = (el: Element) => {
177+
if (!window.__dtmcp) {
178+
window.__dtmcp = {};
179+
}
180+
if (window.__dtmcp.stashedElements === undefined) {
181+
window.__dtmcp.stashedElements = [];
182+
}
183+
window.__dtmcp.stashedElements.push(el);
184+
return {
185+
stashedId: `stashed-${window.__dtmcp.stashedElements.length - 1}`,
186+
};
187+
};
188+
189+
const ancestors: unknown[] = [];
190+
// Recursively walks the tool result:
191+
// - Replaces DOM elements with an ID and stashes the DOM element on the window object
192+
// - Replaces non-plain objects with a string representation of the object
193+
// - Replaces circular references with the string '<Circular reference>'
194+
// - Replaces functions with the string '<Function object>'
195+
const processToolResult = (
196+
data: unknown,
197+
parentEl?: unknown,
198+
): unknown => {
199+
// 1. Handle DOM Elements
200+
if (data instanceof Element) {
201+
return stashDOMElement(data);
202+
}
203+
204+
// 2. Handle Arrays
205+
if (Array.isArray(data)) {
206+
return data.map((item: unknown) =>
207+
processToolResult(item, parentEl),
208+
);
209+
}
210+
211+
// 3. Handle Objects
212+
if (data !== null && typeof data === 'object') {
213+
while (ancestors.length > 0 && ancestors.at(-1) !== parentEl) {
214+
ancestors.pop();
215+
}
216+
if (ancestors.includes(data)) {
217+
return '<Circular reference>';
218+
}
219+
ancestors.push(data);
220+
221+
// If not a plain object, return a string representation of the object
222+
if (Object.getPrototypeOf(data) !== Object.prototype) {
223+
return `<${data.constructor.name} instance>`;
224+
}
225+
226+
const processedObj: Record<string, unknown> = {};
227+
for (const [key, value] of Object.entries(data)) {
228+
processedObj[key] = processToolResult(value, data);
229+
}
230+
return processedObj;
231+
}
232+
233+
// 4. Handle Functions
234+
if (typeof data === 'function') {
235+
return '<Function object>';
236+
}
237+
238+
// 5. Return primitives (strings, numbers, booleans) as-is
239+
return data;
240+
};
241+
242+
return {
243+
result: processToolResult(toolResult),
244+
stashed: window.__dtmcp?.stashedElements?.length ?? 0,
245+
};
246+
},
247+
toolName,
248+
params,
249+
...handles,
250+
);
251+
252+
const elementHandles: ElementHandle[] = [];
253+
for (let i = 0; i < (result.stashed ?? 0); i++) {
254+
const elementHandle = await this.pptrPage.evaluateHandle(index => {
255+
const el = window.__dtmcp?.stashedElements?.[index];
256+
if (!el) {
257+
throw new Error(`Stashed element at index ${index} not found`);
258+
}
259+
return el;
260+
}, i);
261+
elementHandles.push(elementHandle);
262+
}
263+
const resultWithStashedElements = result.result;
264+
265+
let isPageSnapshotUpdated = false;
266+
267+
const stashedToUid = async (index: number) => {
268+
const backendNodeId = await elementHandles[index].backendNodeId();
269+
if (!backendNodeId) {
270+
logger(`No backendNodeId for stashed DOM element with index ${index}`);
271+
return {uid: `stashed-${index}`};
272+
}
273+
let cdpElementId = context.resolveCdpElementId(this, backendNodeId);
274+
if (!cdpElementId) {
275+
await context.createTextSnapshot(
276+
this,
277+
false,
278+
undefined,
279+
elementHandles,
280+
);
281+
isPageSnapshotUpdated = true;
282+
cdpElementId = context.resolveCdpElementId(this, backendNodeId);
283+
}
284+
if (!cdpElementId) {
285+
logger(`Could not get cdpElementId for backend node ${backendNodeId}`);
286+
return {uid: `stashed-${index}`};
287+
}
288+
return {uid: cdpElementId};
289+
};
290+
291+
const recursivelyReplaceStashedElements = async (
292+
node: unknown,
293+
): Promise<unknown> => {
294+
if (Array.isArray(node)) {
295+
return await Promise.all(
296+
node.map(async x => await recursivelyReplaceStashedElements(x)),
297+
);
298+
}
299+
if (node !== null && typeof node === 'object') {
300+
if (
301+
'stashedId' in node &&
302+
typeof node.stashedId === 'string' &&
303+
node.stashedId.startsWith('stashed-') &&
304+
Object.keys(node).length === 1
305+
) {
306+
const index = parseInt(node.stashedId.split('-')[1]);
307+
return stashedToUid(index);
308+
}
309+
const resultObj: Record<string, unknown> = {};
310+
for (const [key, value] of Object.entries(node)) {
311+
resultObj[key] = await recursivelyReplaceStashedElements(value);
312+
}
313+
return resultObj;
314+
}
315+
return node;
316+
};
317+
318+
const resultWithUids = await recursivelyReplaceStashedElements(
319+
resultWithStashedElements,
320+
);
321+
response.appendResponseLine(JSON.stringify(resultWithUids, null, 2));
322+
if (isPageSnapshotUpdated) {
323+
response.includeSnapshot();
324+
}
325+
}
326+
134327
async getElementByUid(uid: string): Promise<ElementHandle<Element>> {
135328
if (!this.textSnapshot) {
136329
throw new Error(
@@ -165,20 +358,4 @@ export class McpPage implements ContextPage {
165358
getAXNodeByUid(uid: string) {
166359
return this.textSnapshot?.idToNode.get(uid);
167360
}
168-
169-
getSnapshot(): TextSnapshot | null {
170-
return this.textSnapshot;
171-
}
172-
173-
setSnapshot(snapshot: TextSnapshot): void {
174-
this.textSnapshot = snapshot;
175-
}
176-
177-
getExtraHandles(): ElementHandle[] | undefined {
178-
return this.extraHandles;
179-
}
180-
181-
setExtraHandles(extraHandles: ElementHandle[]): void {
182-
this.extraHandles = extraHandles;
183-
}
184361
}

0 commit comments

Comments
 (0)