From 376527f7f370593d38f8661985eeccad7f3bcdd1 Mon Sep 17 00:00:00 2001 From: Aalivexy Date: Sat, 7 Mar 2026 23:53:03 +0800 Subject: [PATCH 1/2] feat: support device scale factor in --viewport --- README.md | 2 +- src/bin/chrome-devtools-mcp-cli-options.ts | 53 ++++++++++++++++------ src/browser.ts | 9 ++++ tests/browser.test.ts | 35 ++++++++++++++ tests/cli.test.ts | 21 +++++++++ 5 files changed, 105 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 0178f5e96..afb41e08e 100644 --- a/README.md +++ b/README.md @@ -502,7 +502,7 @@ The Chrome DevTools MCP server supports the following configuration option: - **Type:** string - **`--viewport`** - Initial viewport size for the Chrome instances started by the server. For example, `1280x720`. In headless mode, max size is 3840x2160px. + Initial viewport size for the Chrome instances started by the server. For example, `1280x720` or `1280x720x2` to set the device scale factor. In headless mode, max size is 3840x2160px. - **Type:** string - **`--proxyServer`/ `--proxy-server`** diff --git a/src/bin/chrome-devtools-mcp-cli-options.ts b/src/bin/chrome-devtools-mcp-cli-options.ts index f81c9e208..bf6f36e66 100644 --- a/src/bin/chrome-devtools-mcp-cli-options.ts +++ b/src/bin/chrome-devtools-mcp-cli-options.ts @@ -7,6 +7,39 @@ import type {YargsOptions} from '../third_party/index.js'; import {yargs, hideBin} from '../third_party/index.js'; +function parseViewportOption(arg: string | undefined) { + if (arg === undefined) { + return; + } + + const dimensions = arg.split('x'); + if (dimensions.length < 2 || dimensions.length > 3) { + throw new Error( + 'Invalid viewport. Expected format is `1280x720` or `1280x720x2`.', + ); + } + + const [width, height, deviceScaleFactor] = dimensions.map(Number); + if ( + !width || + !height || + Number.isNaN(width) || + Number.isNaN(height) || + (deviceScaleFactor !== undefined && + (deviceScaleFactor <= 0 || Number.isNaN(deviceScaleFactor))) + ) { + throw new Error( + 'Invalid viewport. Expected format is `1280x720` or `1280x720x2`.', + ); + } + + return { + width, + height, + ...(deviceScaleFactor === undefined ? {} : {deviceScaleFactor}), + }; +} + export const cliOptions = { autoConnect: { type: 'boolean', @@ -124,20 +157,8 @@ export const cliOptions = { viewport: { type: 'string', describe: - 'Initial viewport size for the Chrome instances started by the server. For example, `1280x720`. In headless mode, max size is 3840x2160px.', - coerce: (arg: string | undefined) => { - if (arg === undefined) { - return; - } - const [width, height] = arg.split('x').map(Number); - if (!width || !height || Number.isNaN(width) || Number.isNaN(height)) { - throw new Error('Invalid viewport. Expected format is `1280x720`.'); - } - return { - width, - height, - }; - }, + 'Initial viewport size for the Chrome instances started by the server. For example, `1280x720` or `1280x720x2` to set the device scale factor. In headless mode, max size is 3840x2160px.', + coerce: parseViewportOption, }, proxyServer: { type: 'string', @@ -297,6 +318,10 @@ export function parseArguments(version: string, argv = process.argv) { '$0 --viewport 1280x720', 'Launch Chrome with the initial viewport size of 1280x720px', ], + [ + '$0 --viewport 1280x720x2', + 'Launch Chrome with the initial viewport size of 1280x720px and device scale factor 2', + ], [ `$0 --chrome-arg='--no-sandbox' --chrome-arg='--disable-setuid-sandbox'`, 'Launch Chrome without sandboxes. Use with caution.', diff --git a/src/browser.ts b/src/browser.ts index 7deea75b4..a8048e651 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -144,6 +144,7 @@ interface McpLaunchOptions { viewport?: { width: number; height: number; + deviceScaleFactor?: number; }; chromeArgs?: string[]; ignoreDefaultChromeArgs?: string[]; @@ -242,6 +243,14 @@ export async function launch(options: McpLaunchOptions): Promise { contentWidth: options.viewport.width, contentHeight: options.viewport.height, }); + if (options.viewport.deviceScaleFactor !== undefined) { + // page.resize() only affects the content size. Apply DPR separately. + await page?.setViewport({ + width: options.viewport.width, + height: options.viewport.height, + deviceScaleFactor: options.viewport.deviceScaleFactor, + }); + } } return browser; } catch (error) { diff --git a/tests/browser.test.ts b/tests/browser.test.ts index b0835bf96..c9fa3bf49 100644 --- a/tests/browser.test.ts +++ b/tests/browser.test.ts @@ -77,6 +77,41 @@ describe('browser', () => { await browser.close(); } }); + + it('launches with the initial viewport device scale factor', async () => { + const tmpDir = os.tmpdir(); + const folderPath = path.join(tmpDir, `temp-folder-${crypto.randomUUID()}`); + const browser = await launch({ + headless: true, + isolated: false, + userDataDir: folderPath, + executablePath: executablePath(), + viewport: { + width: 1501, + height: 801, + deviceScaleFactor: 2, + }, + devtools: false, + }); + try { + const [page] = await browser.pages(); + const result = await page.evaluate(() => { + return { + width: window.innerWidth, + height: window.innerHeight, + devicePixelRatio: window.devicePixelRatio, + }; + }); + assert.deepStrictEqual(result, { + width: 1501, + height: 801, + devicePixelRatio: 2, + }); + } finally { + await browser.close(); + } + }); + it('connects to an existing browser with userDataDir', async () => { const tmpDir = os.tmpdir(); const folderPath = path.join(tmpDir, `temp-folder-${crypto.randomUUID()}`); diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 4bea6b954..6a11b4444 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -131,6 +131,27 @@ describe('cli args parsing', () => { }); }); + it('parses viewport with device scale factor', async () => { + const args = parseArguments('1.0.0', [ + 'node', + 'main.js', + '--viewport', + '888x777x2', + ]); + assert.deepStrictEqual(args, { + ...defaultArgs, + _: [], + headless: false, + $0: 'npx chrome-devtools-mcp@latest', + channel: 'stable', + viewport: { + width: 888, + height: 777, + deviceScaleFactor: 2, + }, + }); + }); + it('parses chrome args', async () => { const args = parseArguments('1.0.0', [ 'node', From 7a6c148a0c6a78edc307cba2b5c46755bd216e56 Mon Sep 17 00:00:00 2001 From: Aalivexy Date: Sun, 8 Mar 2026 00:10:18 +0800 Subject: [PATCH 2/2] feat: support device scale factor in --viewport --- src/McpContext.ts | 25 +++++++++---- src/index.ts | 4 +++ tests/tools/lighthouse.test.ts | 64 ++++++++++++++++++++++++++++++++++ tests/utils.ts | 14 +++++++- 4 files changed, 99 insertions(+), 8 deletions(-) diff --git a/src/McpContext.ts b/src/McpContext.ts index 889103fab..681865681 100644 --- a/src/McpContext.ts +++ b/src/McpContext.ts @@ -63,6 +63,8 @@ interface McpContextOptions { experimentalIncludeAllPages?: boolean; // Whether CrUX data should be fetched. performanceCrux: boolean; + // Viewport emulation applied before the context was created. + initialViewport?: Viewport; } const DEFAULT_TIMEOUT = 5_000; @@ -85,6 +87,16 @@ function getNetworkMultiplierFromString(condition: string | null): number { return 1; } +function normalizeViewportSettings(viewport: Viewport): Viewport { + return { + deviceScaleFactor: 1, + isMobile: false, + hasTouch: false, + isLandscape: false, + ...viewport, + }; +} + export class McpContext implements Context { browser: Browser; logger: Debugger; @@ -151,6 +163,11 @@ export class McpContext implements Context { async #init() { const pages = await this.createPagesSnapshot(); + if (this.#selectedPage && this.#options.initialViewport) { + this.#selectedPage.emulationSettings.viewport = normalizeViewportSettings( + this.#options.initialViewport, + ); + } await this.createExtensionServiceWorkersSnapshot(); await this.#networkCollector.init(pages); await this.#consoleCollector.init(pages); @@ -380,13 +397,7 @@ export class McpContext implements Context { await page.setViewport(null); delete newSettings.viewport; } else { - const defaults = { - deviceScaleFactor: 1, - isMobile: false, - hasTouch: false, - isLandscape: false, - }; - const viewport = {...defaults, ...options.viewport}; + const viewport = normalizeViewportSettings(options.viewport); await page.setViewport(viewport); newSettings.viewport = viewport; } diff --git a/src/index.ts b/src/index.ts index 1e731521c..c506442a9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -101,6 +101,10 @@ export async function createMcpServer( experimentalDevToolsDebugging: devtools, experimentalIncludeAllPages: serverArgs.experimentalIncludeAllPages, performanceCrux: serverArgs.performanceCrux, + initialViewport: + serverArgs.viewport?.deviceScaleFactor === undefined + ? undefined + : serverArgs.viewport, }); } return context; diff --git a/tests/tools/lighthouse.test.ts b/tests/tools/lighthouse.test.ts index 4b7dce77e..0f7fbdf25 100644 --- a/tests/tools/lighthouse.test.ts +++ b/tests/tools/lighthouse.test.ts @@ -10,6 +10,7 @@ import os from 'node:os'; import path from 'node:path'; import {describe, it} from 'node:test'; +import type {ParsedArguments} from '../../src/bin/chrome-devtools-mcp-cli-options.js'; import {lighthouseAudit} from '../../src/tools/lighthouse.js'; import {serverHooks} from '../server.js'; import {html, withMcpContext} from '../utils.js'; @@ -117,6 +118,69 @@ describe('lighthouse', () => { }); }); + it('restores launch-time viewport device scale factor', async () => { + server.addHtmlRoute('/test-launch-viewport', html`
Test DPR
`); + + await withMcpContext( + async (response, context) => { + const page = context.getSelectedPptrPage(); + await page.goto(server.getRoute('/test-launch-viewport')); + + { + const viewportData = await page.evaluate(() => { + return { + width: window.innerWidth, + height: window.innerHeight, + deviceScaleFactor: window.devicePixelRatio, + }; + }); + + assert.deepStrictEqual(viewportData, { + width: 400, + height: 400, + deviceScaleFactor: 2, + }); + } + + await lighthouseAudit.handler( + { + params: { + mode: 'snapshot', + device: 'desktop', + }, + page: context.getSelectedMcpPage(), + }, + response, + context, + ); + + { + const viewportData = await page.evaluate(() => { + return { + width: window.innerWidth, + height: window.innerHeight, + deviceScaleFactor: window.devicePixelRatio, + }; + }); + + assert.deepStrictEqual(viewportData, { + width: 400, + height: 400, + deviceScaleFactor: 2, + }); + } + }, + {}, + { + viewport: { + width: 400, + height: 400, + deviceScaleFactor: 2, + }, + } as ParsedArguments, + ); + }); + it('runs Lighthouse in snapshot mode with mobile device', async () => { server.addHtmlRoute('/test-mobile', html`
Test Mobile
`); diff --git a/tests/utils.ts b/tests/utils.ts index 4d48d970c..eca94bbbe 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -105,7 +105,15 @@ export async function withMcpContext( } = {}, args: ParsedArguments = {} as ParsedArguments, ) { - await withBrowser(async browser => { + await withBrowser(async (browser, page) => { + if (args.viewport?.deviceScaleFactor !== undefined) { + await page.setViewport({ + width: args.viewport.width, + height: args.viewport.height, + deviceScaleFactor: args.viewport.deviceScaleFactor, + }); + } + const response = new McpResponse(args); if (context) { context.dispose(); @@ -116,6 +124,10 @@ export async function withMcpContext( { experimentalDevToolsDebugging: false, performanceCrux: options.performanceCrux ?? true, + initialViewport: + args.viewport?.deviceScaleFactor === undefined + ? undefined + : args.viewport, }, Locator, );