Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 26 additions & 12 deletions src/McpContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,11 @@ import {PredefinedNetworkConditions} from './third_party/index.js';
import {listPages} from './tools/pages.js';
import {takeSnapshot} from './tools/snapshot.js';
import {CLOSE_PAGE_ERROR} from './tools/ToolDefinition.js';
import type {Context, DevToolsData} from './tools/ToolDefinition.js';
import type {
Context,
DevToolsData,
ContextPage,
} from './tools/ToolDefinition.js';
import type {TraceResult} from './trace-processing/parse.js';
import type {
EmulationSettings,
Expand Down Expand Up @@ -117,7 +121,7 @@ export class McpContext implements Context {
null;
#focusedPagePerContext = new Map<BrowserContext, Page>();

#requestPage?: Page;
#requestPage?: ContextPage;

#nextPageId = 1;

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

#resolveTargetPage(): Page {
return this.#requestPage ?? this.getSelectedPage();
return this.#requestPage?.pptrPage ?? this.getSelectedPage();
}

resolveCdpRequestId(cdpRequestId: string): number | undefined {
Expand Down Expand Up @@ -283,7 +287,7 @@ export class McpContext implements Context {
async newPage(
background?: boolean,
isolatedContextName?: string,
): Promise<Page> {
): Promise<ContextPage> {
let page: Page;
if (isolatedContextName !== undefined) {
let ctx = this.#isolatedContexts.get(isolatedContextName);
Expand All @@ -299,7 +303,7 @@ export class McpContext implements Context {
this.selectPage(page);
this.#networkCollector.addPage(page);
this.#consoleCollector.addPage(page);
return page;
return this.#getMcpPage(page);
}
async closePage(pageId: number): Promise<void> {
if (this.#pages.length === 1) {
Expand Down Expand Up @@ -489,7 +493,8 @@ export class McpContext implements Context {
}

getDialog(page?: Page): Dialog | undefined {
const targetPage = page ?? this.#requestPage ?? this.#selectedPage;
const targetPage =
page ?? this.#requestPage?.pptrPage ?? this.#selectedPage;
if (!targetPage) {
return undefined;
}
Expand All @@ -516,11 +521,17 @@ export class McpContext implements Context {
return page;
}

resolvePageById(pageId?: number): Page {
getSelectedMcpPage(): McpPage {
const page = this.getSelectedPage();
return this.#getMcpPage(page);
}

resolvePageById(pageId?: number): McpPage {
if (pageId === undefined) {
return this.getSelectedPage();
return this.getSelectedMcpPage();
}
return this.getPageById(pageId);
const page = this.getPageById(pageId);
return this.#getMcpPage(page);
}

getPageById(pageId: number): Page {
Expand Down Expand Up @@ -551,7 +562,8 @@ export class McpContext implements Context {
return this.#selectedPage === page;
}

assertPageIsFocused(page: Page): void {
assertPageIsFocused(pageToCheck: Page | ContextPage): void {
const page = 'pptrPage' in pageToCheck ? pageToCheck.pptrPage : pageToCheck;
const ctx = page.browserContext();
const focused = this.#focusedPagePerContext.get(ctx);
if (focused && focused !== page) {
Expand All @@ -564,7 +576,9 @@ export class McpContext implements Context {
}
}

selectPage(newPage: Page): void {
selectPage(pageToSelect: Page | ContextPage): void {
const newPage =
'pptrPage' in pageToSelect ? pageToSelect.pptrPage : pageToSelect;
const ctx = newPage.browserContext();
const oldFocused = this.#focusedPagePerContext.get(ctx);
if (oldFocused && oldFocused !== newPage && !oldFocused.isClosed()) {
Expand Down
53 changes: 48 additions & 5 deletions src/McpPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,19 @@
* SPDX-License-Identifier: Apache-2.0
*/

import type {Dialog, Page, Viewport} from './third_party/index.js';
import type {
Dialog,
ElementHandle,
Page,
Viewport,
} from './third_party/index.js';
import {takeSnapshot} from './tools/snapshot.js';
import type {ContextPage} from './tools/ToolDefinition.js';
import type {
EmulationSettings,
GeolocationOptions,
TextSnapshot,
TextSnapshotNode,
} from './types.js';

/**
Expand All @@ -19,8 +27,8 @@ import type {
* read/write access. The dialog field is private because it requires an
* event listener lifecycle managed by the constructor/dispose pair.
*/
export class McpPage {
readonly page: Page;
export class McpPage implements ContextPage {
readonly pptrPage: Page;
readonly id: number;

// Snapshot
Expand All @@ -39,7 +47,7 @@ export class McpPage {
#dialogHandler: (dialog: Dialog) => void;

constructor(page: Page, id: number) {
this.page = page;
this.pptrPage = page;
this.id = id;
this.#dialogHandler = (dialog: Dialog): void => {
this.#dialog = dialog;
Expand Down Expand Up @@ -80,6 +88,41 @@ export class McpPage {
}

dispose(): void {
this.page.off('dialog', this.#dialogHandler);
this.pptrPage.off('dialog', this.#dialogHandler);
}

async getElementByUid(uid: string): Promise<ElementHandle<Element>> {
if (!this.textSnapshot) {
throw new Error(
`No snapshot found for page ${this.id ?? '?'}. Use ${takeSnapshot.name} to capture one.`,
);
}
const node = this.textSnapshot.idToNode.get(uid);
if (!node) {
throw new Error(`Element uid "${uid}" not found on page ${this.id}.`);
}
return this.#resolveElementHandle(node, uid);
}

async #resolveElementHandle(
node: TextSnapshotNode,
uid: string,
): Promise<ElementHandle<Element>> {
const message = `Element with uid ${uid} no longer exists on the page.`;
try {
const handle = await node.elementHandle();
if (!handle) {
throw new Error(message);
}
return handle;
} catch (error) {
throw new Error(message, {
cause: error,
});
}
}

getAXNodeByUid(uid: string) {
return this.textSnapshot?.idToNode.get(uid);
}
}
6 changes: 4 additions & 2 deletions src/McpResponse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,9 +263,11 @@ export class McpResponse implements Response {
await context.createTextSnapshot(
this.#snapshotParams.verbose,
this.#devToolsData,
this.#snapshotParams.page,
this.#snapshotParams.page?.pptrPage,
);
const textSnapshot = context.getTextSnapshot(
this.#snapshotParams.page?.pptrPage,
);
const textSnapshot = context.getTextSnapshot(this.#snapshotParams.page);
if (textSnapshot) {
const formatter = new SnapshotFormatter(textSnapshot);
if (this.#snapshotParams.filePath) {
Expand Down
2 changes: 1 addition & 1 deletion src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ export async function createMcpServer(
const page =
serverArgs.experimentalPageIdRouting && params.pageId
? context.resolvePageById(params.pageId)
: context.getSelectedPage();
: context.getSelectedMcpPage();
context.setRequestPage(page);
await tool.handler(
{
Expand Down
17 changes: 13 additions & 4 deletions src/tools/ToolDefinition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export interface ImageContentData {
export interface SnapshotParams {
verbose?: boolean;
filePath?: string;
page?: Page;
page?: ContextPage;
}

export interface LighthouseData {
Expand Down Expand Up @@ -141,7 +141,10 @@ export type Context = Readonly<{
getDialog(page?: Page): Dialog | undefined;
clearDialog(page?: Page): void;
getPageById(pageId: number): Page;
newPage(background?: boolean, isolatedContextName?: string): Promise<Page>;
newPage(
background?: boolean,
isolatedContextName?: string,
): Promise<ContextPage>;
closePage(pageId: number): Promise<void>;
selectPage(page: Page): void;
assertPageIsFocused(page: Page): void;
Expand Down Expand Up @@ -191,6 +194,12 @@ export type Context = Readonly<{
getExtension(id: string): InstalledExtension | undefined;
}>;

export type ContextPage = Readonly<{
readonly pptrPage: Page;
getAXNodeByUid(uid: string): TextSnapshotNode | undefined;
getElementByUid(uid: string): Promise<ElementHandle<Element>>;
}>;

export function defineTool<Schema extends zod.ZodRawShape>(
definition: ToolDefinition<Schema>,
): ToolDefinition<Schema>;
Expand Down Expand Up @@ -223,7 +232,7 @@ interface PageToolDefinition<
Schema extends zod.ZodRawShape = zod.ZodRawShape,
> extends BaseToolDefinition<Schema> {
handler: (
request: Request<Schema> & {page: Page},
request: Request<Schema> & {page: ContextPage},
response: Response,
context: Context,
) => Promise<void>;
Expand All @@ -233,7 +242,7 @@ export type DefinedPageTool<Schema extends zod.ZodRawShape = zod.ZodRawShape> =
PageToolDefinition<Schema> & {
pageScoped: true;
handler: (
request: Request<Schema> & {page: Page},
request: Request<Schema> & {page: ContextPage},
response: Response,
context: Context,
) => Promise<void>;
Expand Down
2 changes: 1 addition & 1 deletion src/tools/emulation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,6 @@ export const emulate = definePageTool({
},
handler: async (request, _response, context) => {
const page = request.page;
await context.emulate(request.params, page);
await context.emulate(request.params, page.pptrPage);
},
});
28 changes: 15 additions & 13 deletions src/tools/input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,9 +97,9 @@ export const clickAt = definePageTool({
},
handler: async (request, response, context) => {
const page = request.page;
context.assertPageIsFocused(page);
context.assertPageIsFocused(page.pptrPage);
await context.waitForEventsAfterAction(async () => {
await page.mouse.click(request.params.x, request.params.y, {
await page.pptrPage.mouse.click(request.params.x, request.params.y, {
clickCount: request.params.dblClick ? 2 : 1,
});
});
Expand Down Expand Up @@ -239,7 +239,7 @@ export const fill = definePageTool({
request.params.uid,
request.params.value,
context as McpContext,
page,
page.pptrPage,
);
});
response.appendResponseLine(`Successfully filled out the element`);
Expand All @@ -262,11 +262,13 @@ export const typeText = definePageTool({
},
handler: async (request, response, context) => {
const page = request.page;
context.assertPageIsFocused(page);
context.assertPageIsFocused(page.pptrPage);
await context.waitForEventsAfterAction(async () => {
await page.keyboard.type(request.params.text);
await page.pptrPage.keyboard.type(request.params.text);
if (request.params.submitKey) {
await page.keyboard.press(request.params.submitKey as KeyInput);
await page.pptrPage.keyboard.press(
request.params.submitKey as KeyInput,
);
}
});
response.appendResponseLine(
Expand Down Expand Up @@ -333,7 +335,7 @@ export const fillForm = definePageTool({
element.uid,
element.value,
context as McpContext,
page,
page.pptrPage,
);
});
}
Expand Down Expand Up @@ -364,7 +366,7 @@ export const uploadFile = definePageTool({
const {uid, filePath} = request.params;
const handle = (await context.getElementByUid(
uid,
request.page,
request.page.pptrPage,
)) as ElementHandle<HTMLInputElement>;
try {
try {
Expand All @@ -375,7 +377,7 @@ export const uploadFile = definePageTool({
// Page.waitForFileChooser() and upload the file this way.
try {
const [fileChooser] = await Promise.all([
request.page.waitForFileChooser({timeout: 3000}),
request.page.pptrPage.waitForFileChooser({timeout: 3000}),
handle.asLocator().click(),
]);
await fileChooser.accept([filePath]);
Expand Down Expand Up @@ -412,17 +414,17 @@ export const pressKey = definePageTool({
},
handler: async (request, response, context) => {
const page = request.page;
context.assertPageIsFocused(page);
context.assertPageIsFocused(page.pptrPage);
const tokens = parseKey(request.params.key);
const [key, ...modifiers] = tokens;

await context.waitForEventsAfterAction(async () => {
for (const modifier of modifiers) {
await page.keyboard.down(modifier);
await page.pptrPage.keyboard.down(modifier);
}
await page.keyboard.press(key);
await page.pptrPage.keyboard.press(key);
for (const modifier of modifiers.toReversed()) {
await page.keyboard.up(modifier);
await page.pptrPage.keyboard.up(modifier);
}
});

Expand Down
4 changes: 2 additions & 2 deletions src/tools/lighthouse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,11 +82,11 @@ export const lighthouseAudit = definePageTool({
let result: RunnerResult | undefined;
try {
if (mode === 'navigation') {
result = await navigation(page, page.url(), {
result = await navigation(page.pptrPage, page.pptrPage.url(), {
flags,
});
} else {
result = await snapshot(page, {
result = await snapshot(page.pptrPage, {
flags,
});
}
Expand Down
2 changes: 1 addition & 1 deletion src/tools/memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export const takeMemorySnapshot = definePageTool({
handler: async (request, response, _context) => {
const page = request.page;

await page.captureHeapSnapshot({
await page.pptrPage.captureHeapSnapshot({
path: request.params.filePath,
});

Expand Down
Loading