Skip to content

Commit d6a1635

Browse files
committed
refactor: consistently use McpPage in tools
1 parent 21634e6 commit d6a1635

29 files changed

Lines changed: 382 additions & 321 deletions

src/McpContext.ts

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ import {PredefinedNetworkConditions} from './third_party/index.js';
3737
import {listPages} from './tools/pages.js';
3838
import {takeSnapshot} from './tools/snapshot.js';
3939
import {CLOSE_PAGE_ERROR} from './tools/ToolDefinition.js';
40-
import type {Context, DevToolsData} from './tools/ToolDefinition.js';
40+
import type {Context, DevToolsData, ToolPage} from './tools/ToolDefinition.js';
4141
import type {TraceResult} from './trace-processing/parse.js';
4242
import type {
4343
EmulationSettings,
@@ -117,7 +117,7 @@ export class McpContext implements Context {
117117
null;
118118
#focusedPagePerContext = new Map<BrowserContext, Page>();
119119

120-
#requestPage?: Page;
120+
#requestPage?: ToolPage;
121121

122122
#nextPageId = 1;
123123

@@ -196,12 +196,12 @@ export class McpContext implements Context {
196196
// TODO: Refactor away mutable request state (e.g. per-request facade,
197197
// per-request context object, or another approach). Once resolved, the
198198
// global toolMutex could become per-BrowserContext for parallel execution.
199-
setRequestPage(page?: Page): void {
199+
setRequestPage(page?: ToolPage): void {
200200
this.#requestPage = page;
201201
}
202202

203203
#resolveTargetPage(): Page {
204-
return this.#requestPage ?? this.getSelectedPage();
204+
return this.#requestPage?.page ?? this.getSelectedPage();
205205
}
206206

207207
resolveCdpRequestId(cdpRequestId: string): number | undefined {
@@ -283,7 +283,7 @@ export class McpContext implements Context {
283283
async newPage(
284284
background?: boolean,
285285
isolatedContextName?: string,
286-
): Promise<Page> {
286+
): Promise<ToolPage> {
287287
let page: Page;
288288
if (isolatedContextName !== undefined) {
289289
let ctx = this.#isolatedContexts.get(isolatedContextName);
@@ -299,7 +299,7 @@ export class McpContext implements Context {
299299
this.selectPage(page);
300300
this.#networkCollector.addPage(page);
301301
this.#consoleCollector.addPage(page);
302-
return page;
302+
return this.#getMcpPage(page);
303303
}
304304
async closePage(pageId: number): Promise<void> {
305305
if (this.#pages.length === 1) {
@@ -489,7 +489,7 @@ export class McpContext implements Context {
489489
}
490490

491491
getDialog(page?: Page): Dialog | undefined {
492-
const targetPage = page ?? this.#requestPage ?? this.#selectedPage;
492+
const targetPage = page ?? this.#requestPage?.page ?? this.#selectedPage;
493493
if (!targetPage) {
494494
return undefined;
495495
}
@@ -516,11 +516,17 @@ export class McpContext implements Context {
516516
return page;
517517
}
518518

519-
resolvePageById(pageId?: number): Page {
519+
getSelectedMcpPage(): McpPage {
520+
const page = this.getSelectedPage();
521+
return this.#getMcpPage(page);
522+
}
523+
524+
resolvePageById(pageId?: number): McpPage {
520525
if (pageId === undefined) {
521-
return this.getSelectedPage();
526+
return this.getSelectedMcpPage();
522527
}
523-
return this.getPageById(pageId);
528+
const page = this.getPageById(pageId);
529+
return this.#getMcpPage(page);
524530
}
525531

526532
getPageById(pageId: number): Page {
@@ -551,7 +557,8 @@ export class McpContext implements Context {
551557
return this.#selectedPage === page;
552558
}
553559

554-
assertPageIsFocused(page: Page): void {
560+
assertPageIsFocused(pageToCheck: Page|ToolPage): void {
561+
const page = 'page' in pageToCheck ? pageToCheck.page : pageToCheck;
555562
const ctx = page.browserContext();
556563
const focused = this.#focusedPagePerContext.get(ctx);
557564
if (focused && focused !== page) {
@@ -564,7 +571,8 @@ export class McpContext implements Context {
564571
}
565572
}
566573

567-
selectPage(newPage: Page): void {
574+
selectPage(pageToSelect: Page|ToolPage): void {
575+
const newPage = 'page' in pageToSelect ? pageToSelect.page : pageToSelect;
568576
const ctx = newPage.browserContext();
569577
const oldFocused = this.#focusedPagePerContext.get(ctx);
570578
if (oldFocused && oldFocused !== newPage && !oldFocused.isClosed()) {

src/McpPage.ts

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,14 @@
44
* SPDX-License-Identifier: Apache-2.0
55
*/
66

7-
import type {Dialog, Page, Viewport} from './third_party/index.js';
7+
import type {Dialog, ElementHandle, Page, Viewport} from './third_party/index.js';
8+
import { takeSnapshot } from './tools/snapshot.js';
9+
import { ToolPage } from './tools/ToolDefinition.js';
810
import type {
911
EmulationSettings,
1012
GeolocationOptions,
1113
TextSnapshot,
14+
TextSnapshotNode,
1215
} from './types.js';
1316

1417
/**
@@ -19,7 +22,7 @@ import type {
1922
* read/write access. The dialog field is private because it requires an
2023
* event listener lifecycle managed by the constructor/dispose pair.
2124
*/
22-
export class McpPage {
25+
export class McpPage implements ToolPage {
2326
readonly page: Page;
2427
readonly id: number;
2528

@@ -82,4 +85,45 @@ export class McpPage {
8285
dispose(): void {
8386
this.page.off('dialog', this.#dialogHandler);
8487
}
88+
89+
async getElementByUid(
90+
uid: string,
91+
): Promise<ElementHandle<Element>> {
92+
if (!this.textSnapshot) {
93+
throw new Error(
94+
`No snapshot found for page ${this.id ?? '?'}. Use ${takeSnapshot.name} to capture one.`,
95+
);
96+
}
97+
const node = this.textSnapshot.idToNode.get(uid);
98+
if (!node) {
99+
throw new Error(
100+
`Element uid "${uid}" not found on page ${this.id}.`,
101+
);
102+
}
103+
return this.#resolveElementHandle(node, uid);
104+
}
105+
106+
async #resolveElementHandle(
107+
node: TextSnapshotNode,
108+
uid: string,
109+
): Promise<ElementHandle<Element>> {
110+
const message = `Element with uid ${uid} no longer exists on the page.`;
111+
try {
112+
const handle = await node.elementHandle();
113+
if (!handle) {
114+
throw new Error(message);
115+
}
116+
return handle;
117+
} catch (error) {
118+
throw new Error(message, {
119+
cause: error,
120+
});
121+
}
122+
}
123+
124+
125+
getAXNodeByUid(uid: string) {
126+
return this.textSnapshot?.idToNode.get(uid);
127+
}
128+
85129
}

src/McpResponse.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -263,9 +263,9 @@ export class McpResponse implements Response {
263263
await context.createTextSnapshot(
264264
this.#snapshotParams.verbose,
265265
this.#devToolsData,
266-
this.#snapshotParams.page,
266+
this.#snapshotParams.page?.page,
267267
);
268-
const textSnapshot = context.getTextSnapshot(this.#snapshotParams.page);
268+
const textSnapshot = context.getTextSnapshot(this.#snapshotParams.page?.page);
269269
if (textSnapshot) {
270270
const formatter = new SnapshotFormatter(textSnapshot);
271271
if (this.#snapshotParams.filePath) {

src/server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,7 @@ export async function createMcpServer(
181181
const page =
182182
serverArgs.experimentalPageIdRouting && params.pageId
183183
? context.resolvePageById(params.pageId)
184-
: context.getSelectedPage();
184+
: context.getSelectedMcpPage();
185185
context.setRequestPage(page);
186186
await tool.handler(
187187
{

src/tools/ToolDefinition.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ export interface ImageContentData {
6060
export interface SnapshotParams {
6161
verbose?: boolean;
6262
filePath?: string;
63-
page?: Page;
63+
page?: ToolPage;
6464
}
6565

6666
export interface LighthouseData {
@@ -141,7 +141,7 @@ export type Context = Readonly<{
141141
getDialog(page?: Page): Dialog | undefined;
142142
clearDialog(page?: Page): void;
143143
getPageById(pageId: number): Page;
144-
newPage(background?: boolean, isolatedContextName?: string): Promise<Page>;
144+
newPage(background?: boolean, isolatedContextName?: string): Promise<ToolPage>;
145145
closePage(pageId: number): Promise<void>;
146146
selectPage(page: Page): void;
147147
assertPageIsFocused(page: Page): void;
@@ -191,6 +191,14 @@ export type Context = Readonly<{
191191
getExtension(id: string): InstalledExtension | undefined;
192192
}>;
193193

194+
export type ToolPage = Readonly<{
195+
readonly page: Page;
196+
getAXNodeByUid(uid: string): TextSnapshotNode | undefined;
197+
getElementByUid(
198+
uid: string,
199+
): Promise<ElementHandle<Element>>;
200+
}>;
201+
194202
export function defineTool<Schema extends zod.ZodRawShape>(
195203
definition: ToolDefinition<Schema>,
196204
): ToolDefinition<Schema>;
@@ -223,7 +231,7 @@ interface PageToolDefinition<
223231
Schema extends zod.ZodRawShape = zod.ZodRawShape,
224232
> extends BaseToolDefinition<Schema> {
225233
handler: (
226-
request: Request<Schema> & {page: Page},
234+
request: Request<Schema> & {page: ToolPage},
227235
response: Response,
228236
context: Context,
229237
) => Promise<void>;
@@ -233,7 +241,7 @@ export type DefinedPageTool<Schema extends zod.ZodRawShape = zod.ZodRawShape> =
233241
PageToolDefinition<Schema> & {
234242
pageScoped: true;
235243
handler: (
236-
request: Request<Schema> & {page: Page},
244+
request: Request<Schema> & {page: ToolPage},
237245
response: Response,
238246
context: Context,
239247
) => Promise<void>;

src/tools/emulation.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,6 @@ export const emulate = definePageTool({
105105
},
106106
handler: async (request, _response, context) => {
107107
const page = request.page;
108-
await context.emulate(request.params, page);
108+
await context.emulate(request.params, page.page);
109109
},
110110
});

src/tools/input.ts

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -97,9 +97,9 @@ export const clickAt = definePageTool({
9797
},
9898
handler: async (request, response, context) => {
9999
const page = request.page;
100-
context.assertPageIsFocused(page);
100+
context.assertPageIsFocused(page.page);
101101
await context.waitForEventsAfterAction(async () => {
102-
await page.mouse.click(request.params.x, request.params.y, {
102+
await page.page.mouse.click(request.params.x, request.params.y, {
103103
clickCount: request.params.dblClick ? 2 : 1,
104104
});
105105
});
@@ -239,7 +239,7 @@ export const fill = definePageTool({
239239
request.params.uid,
240240
request.params.value,
241241
context as McpContext,
242-
page,
242+
page.page,
243243
);
244244
});
245245
response.appendResponseLine(`Successfully filled out the element`);
@@ -262,11 +262,11 @@ export const typeText = definePageTool({
262262
},
263263
handler: async (request, response, context) => {
264264
const page = request.page;
265-
context.assertPageIsFocused(page);
265+
context.assertPageIsFocused(page.page);
266266
await context.waitForEventsAfterAction(async () => {
267-
await page.keyboard.type(request.params.text);
267+
await page.page.keyboard.type(request.params.text);
268268
if (request.params.submitKey) {
269-
await page.keyboard.press(request.params.submitKey as KeyInput);
269+
await page.page.keyboard.press(request.params.submitKey as KeyInput);
270270
}
271271
});
272272
response.appendResponseLine(
@@ -333,7 +333,7 @@ export const fillForm = definePageTool({
333333
element.uid,
334334
element.value,
335335
context as McpContext,
336-
page,
336+
page.page,
337337
);
338338
});
339339
}
@@ -364,7 +364,7 @@ export const uploadFile = definePageTool({
364364
const {uid, filePath} = request.params;
365365
const handle = (await context.getElementByUid(
366366
uid,
367-
request.page,
367+
request.page.page,
368368
)) as ElementHandle<HTMLInputElement>;
369369
try {
370370
try {
@@ -375,7 +375,7 @@ export const uploadFile = definePageTool({
375375
// Page.waitForFileChooser() and upload the file this way.
376376
try {
377377
const [fileChooser] = await Promise.all([
378-
request.page.waitForFileChooser({timeout: 3000}),
378+
request.page.page.waitForFileChooser({timeout: 3000}),
379379
handle.asLocator().click(),
380380
]);
381381
await fileChooser.accept([filePath]);
@@ -412,17 +412,17 @@ export const pressKey = definePageTool({
412412
},
413413
handler: async (request, response, context) => {
414414
const page = request.page;
415-
context.assertPageIsFocused(page);
415+
context.assertPageIsFocused(page.page);
416416
const tokens = parseKey(request.params.key);
417417
const [key, ...modifiers] = tokens;
418418

419419
await context.waitForEventsAfterAction(async () => {
420420
for (const modifier of modifiers) {
421-
await page.keyboard.down(modifier);
421+
await page.page.keyboard.down(modifier);
422422
}
423-
await page.keyboard.press(key);
423+
await page.page.keyboard.press(key);
424424
for (const modifier of modifiers.toReversed()) {
425-
await page.keyboard.up(modifier);
425+
await page.page.keyboard.up(modifier);
426426
}
427427
});
428428

src/tools/lighthouse.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,11 +82,11 @@ export const lighthouseAudit = definePageTool({
8282
let result: RunnerResult | undefined;
8383
try {
8484
if (mode === 'navigation') {
85-
result = await navigation(page, page.url(), {
85+
result = await navigation(page.page, page.page.url(), {
8686
flags,
8787
});
8888
} else {
89-
result = await snapshot(page, {
89+
result = await snapshot(page.page, {
9090
flags,
9191
});
9292
}

src/tools/memory.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ export const takeMemorySnapshot = definePageTool({
2424
handler: async (request, response, _context) => {
2525
const page = request.page;
2626

27-
await page.captureHeapSnapshot({
27+
await page.page.captureHeapSnapshot({
2828
path: request.params.filePath,
2929
});
3030

0 commit comments

Comments
 (0)