Skip to content
Open
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
15 changes: 15 additions & 0 deletions src/McpContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ export class McpContext implements Context {
#isRunningTrace = false;
#networkConditionsMap = new WeakMap<Page, string>();
#cpuThrottlingRateMap = new WeakMap<Page, number>();
#deviceEmulationMap = new WeakMap<Page, string>();
#dialog?: Dialog;

#nextSnapshotId = 1;
Expand Down Expand Up @@ -207,6 +208,20 @@ export class McpContext implements Context {
return this.#cpuThrottlingRateMap.get(page) ?? 1;
}

setDeviceEmulation(device: string | null): void {
const page = this.getSelectedPage();
if (device === null) {
this.#deviceEmulationMap.delete(page);
} else {
this.#deviceEmulationMap.set(page, device);
}
}

getDeviceEmulation(): string | null {
const page = this.getSelectedPage();
return this.#deviceEmulationMap.get(page) ?? null;
}

setIsRunningPerformanceTrace(x: boolean): void {
this.#isRunningTrace = x;
}
Expand Down
6 changes: 5 additions & 1 deletion src/tools/ToolDefinition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import type {Dialog, ElementHandle, Page} from '../third_party/index.js';
import type {TraceResult} from '../trace-processing/parse.js';
import type {PaginationOptions} from '../utils/types.js';

import type {ToolCategories} from './categories.js';
import type { ToolCategories } from './categories.js';

export interface ToolDefinition<
Schema extends zod.ZodRawShape = zod.ZodRawShape,
Expand Down Expand Up @@ -85,6 +85,10 @@ export type Context = Readonly<{
getAXNodeByUid(uid: string): TextSnapshotNode | undefined;
setNetworkConditions(conditions: string | null): void;
setCpuThrottlingRate(rate: number): void;
setDeviceEmulation(device: string | null): void;
getDeviceEmulation(): string | null;
getPages:() => Page[];
createPagesSnapshot(): Promise<Page[]>
saveTemporaryFile(
data: Uint8Array<ArrayBufferLike>,
mimeType: 'image/png' | 'image/jpeg' | 'image/webp',
Expand Down
195 changes: 190 additions & 5 deletions src/tools/emulation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,56 @@
*/

import {zod, PredefinedNetworkConditions} from '../third_party/index.js';

import {ToolCategories} from './categories.js';
import {defineTool} from './ToolDefinition.js';
import { KnownDevices } from 'puppeteer-core';
import { ToolCategories } from './categories.js';
import { defineTool } from './ToolDefinition.js';

const throttlingOptions: [string, ...string[]] = [
'No emulation',
'Offline',
...Object.keys(PredefinedNetworkConditions),
];

/**
* Get all mobile device list (dynamically from KnownDevices)
* Filter out landscape devices and uncommon devices, keep only common portrait mobile devices
*/
function getMobileDeviceList(): string[] {
const allDevices = Object.keys(KnownDevices);
// Filter out landscape devices (containing 'landscape') and some uncommon devices
const mobileDevices = allDevices.filter(device => {
const lowerDevice = device.toLowerCase();
// Exclude landscape devices
if (lowerDevice.includes('landscape')) return false;
// Exclude tablets (optional, but keep iPad as common device)
// if (lowerDevice.includes('ipad') || lowerDevice.includes('tab')) return false;
// Exclude some old or uncommon devices
if (lowerDevice.includes('blackberry')) return false;
if (lowerDevice.includes('lumia')) return false;
if (lowerDevice.includes('nokia')) return false;
if (lowerDevice.includes('kindle')) return false;
if (lowerDevice.includes('jio')) return false;
if (lowerDevice.includes('optimus')) return false;
return true;
});

return mobileDevices;
}

/**
* Get default mobile device
*/
function getDefaultMobileDevice(): string {
return 'iPhone 8';
}

/**
* Validate if device exists in KnownDevices
*/
function validateDeviceExists(device: string): boolean {
return device in KnownDevices;
}

export const emulateNetwork = defineTool({
name: 'emulate_network',
description: `Emulates network conditions such as throttling or offline mode on the selected page.`,
Expand Down Expand Up @@ -53,7 +93,7 @@ export const emulateNetwork = defineTool({
if (conditions in PredefinedNetworkConditions) {
const networkCondition =
PredefinedNetworkConditions[
conditions as keyof typeof PredefinedNetworkConditions
conditions as keyof typeof PredefinedNetworkConditions
];
await page.emulateNetworkConditions(networkCondition);
context.setNetworkConditions(conditions);
Expand All @@ -79,9 +119,154 @@ export const emulateCpu = defineTool({
},
handler: async (request, _response, context) => {
const page = context.getSelectedPage();
const {throttlingRate} = request.params;
const { throttlingRate } = request.params;

await page.emulateCPUThrottling(throttlingRate);
context.setCpuThrottlingRate(throttlingRate);
},
});

export const emulateDevice = defineTool({
name: 'emulate_device',
description: `IMPORTANT: Emulates a mobile device including viewport, user-agent, touch support, and device scale factor. This tool MUST be called BEFORE navigating to any website to ensure the correct mobile user-agent is used. Essential for testing mobile website performance and user experience. If no device is specified, defaults to iPhone 8.`,
annotations: {
category: ToolCategories.EMULATION,
readOnlyHint: false,
},
schema: {
device: zod
.string()
.optional()
.describe(
`The mobile device to emulate. If not specified, defaults to "${getDefaultMobileDevice()}". Available devices include all mobile devices from Puppeteer's KnownDevices (e.g., iPhone 8, iPhone 13, iPhone 14, iPhone 15, Galaxy S8, Galaxy S9+, Pixel 2-5, iPad, iPad Pro, etc.). Use the exact device name as defined in Puppeteer.`,
),
customUserAgent: zod
.string()
.optional()
.describe(
'Optional custom user agent string. If provided, it will override the device\'s default user agent.',
),
},
handler: async (request, response, context) => {
let { device, customUserAgent } = request.params;

// ========== Phase 0: Handle default device ==========
// If user didn't specify device, use default mobile device
if (!device) {
device = getDefaultMobileDevice();
}

// ========== Phase 1: Device validation ==========
// Validate if device exists in KnownDevices
if (!validateDeviceExists(device)) {
const availableDevices = getMobileDeviceList();
device = availableDevices[0];
}

// ========== Phase 2: Page collection and state check ==========
await context.createPagesSnapshot();
const allPages = context.getPages();
const currentPage = context.getSelectedPage();

// Filter out closed pages
const activePages = allPages.filter(page => !page.isClosed());
if (activePages.length === 0) {
response.appendResponseLine('❌ Error: No active pages available for device emulation.');
return;
}

// ========== Phase 3: Determine pages to emulate ==========
let pagesToEmulate = [currentPage];

if (activePages.length > 1) {
// Check if other pages have navigated content
const navigatedPages = [];
for (const page of activePages) {
if (page.isClosed()) continue; // Double check

try {
const url = page.url();
if (url !== 'about:blank' && url !== currentPage.url()) {
navigatedPages.push({ page, url });
}
} catch (error) {
// Page may have been closed during check
continue;
}
}

// Set emulation for all pages
if (navigatedPages.length > 0) {
pagesToEmulate = [currentPage, ...navigatedPages.map(p => p.page)];
}
}

// Filter again to ensure all pages to emulate are active
pagesToEmulate = pagesToEmulate.filter(page => !page.isClosed());

if (pagesToEmulate.length === 0) {
response.appendResponseLine('❌ Error: All target pages have been closed.');
return;
}


// ========== Phase 4: Mobile device emulation ==========
const deviceConfig = KnownDevices[device as keyof typeof KnownDevices];

let successCount = 0;
const failedPages: Array<{ url: string; reason: string }> = [];

for (const pageToEmulate of pagesToEmulate) {
if (pageToEmulate.isClosed()) {
failedPages.push({
url: 'unknown',
reason: 'Page closed'
});
continue;
}

const pageUrl = pageToEmulate.url();

try {
// Directly apply device emulation
await pageToEmulate.emulate({
userAgent: customUserAgent || deviceConfig.userAgent,
viewport: deviceConfig.viewport,
});
successCount++;
} catch (error) {
failedPages.push({
url: pageUrl,
reason: (error as Error).message
});
}
}

// ========== Phase 5: Save state and report results ==========
if (successCount > 0) {
context.setDeviceEmulation(device);
}

// Build detailed report
if (successCount > 0) {
response.appendResponseLine(
`✅ Successfully emulated device: ${device}, applied to ${successCount} page(s).\n` +
`Viewport: ${deviceConfig.viewport.width}x${deviceConfig.viewport.height}, ` +
`Scale: ${deviceConfig.viewport.deviceScaleFactor}x, ` +
`Mobile: ${deviceConfig.viewport.isMobile ? 'Yes' : 'No'}, ` +
`Touch: ${deviceConfig.viewport.hasTouch ? 'Yes' : 'No'}${customUserAgent ? ', Custom UA applied' : ''}.`
);
} else {
// Complete failure
response.appendResponseLine(
`❌ Error: Unable to apply device emulation to any page.\n\n` +
`Failure details:\n${failedPages.map(p => ` - ${p.url}: ${p.reason}`).join('\n')}\n\n` +
`Diagnostic suggestions:\n` +
` 1. Confirm all target pages are in active state\n` +
` 2. Check if pages allow device emulation (some internal pages may restrict it)\n` +
` 3. Try closing other pages and keep only one page\n` +
` 4. Restart browser and retry`
);
}
},
});