chore: allow configuring raw Playwright options (#287)

Fixes: https://github.com/microsoft/playwright-mcp/issues/272
This commit is contained in:
Pavel Feldman 2025-04-28 20:17:16 -07:00 committed by GitHub
parent 12e72a96c4
commit 80c9b93b72
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 125 additions and 108 deletions

23
config.d.ts vendored
View File

@ -14,6 +14,8 @@
* limitations under the License.
*/
import type * as playwright from 'playwright';
export type ToolCapability = 'core' | 'tabs' | 'pdf' | 'history' | 'wait' | 'files' | 'install';
export type Config = {
@ -24,12 +26,7 @@ export type Config = {
/**
* The type of browser to use.
*/
type?: 'chrome' | 'chrome-beta' | 'chrome-canary' | 'chrome-dev' | 'chromium' | 'msedge' | 'msedge-beta' | 'msedge-canary' | 'msedge-dev' | 'firefox' | 'webkit';
/**
* Path to a custom browser executable.
*/
executablePath?: string;
browserName?: 'chromium' | 'firefox' | 'webkit';
/**
* Path to a user data directory for browser profile persistence.
@ -37,9 +34,19 @@ export type Config = {
userDataDir?: string;
/**
* Whether to run the browser in headless mode (default: true).
* Launch options passed to
* @see https://playwright.dev/docs/api/class-browsertype#browser-type-launch-persistent-context
*
* This is useful for settings options like `channel`, `headless`, `executablePath`, etc.
*/
headless?: boolean;
launchOptions?: playwright.BrowserLaunchOptions;
/**
* Context options for the browser context.
*
* This is useful for settings options like `viewport`.
*/
contextOptions?: playwright.BrowserContextOptions;
/**
* Chrome DevTools Protocol endpoint to connect to an existing browser instance in case of Chromium family browsers.

View File

@ -21,19 +21,46 @@ import path from 'path';
import { sanitizeForFilePath } from './tools/utils';
import type { Config } from '../config';
import type { LaunchOptions, BrowserContextOptions } from 'playwright';
import type { Config, ToolCapability } from '../config';
import type { LaunchOptions } from 'playwright';
export type BrowserOptions = {
browserName: 'chromium' | 'firefox' | 'webkit';
launchOptions: LaunchOptions;
contextOptions: BrowserContextOptions;
export type CLIOptions = {
browser?: string;
caps?: string;
cdpEndpoint?: string;
executablePath?: string;
headless?: boolean;
userDataDir?: string;
port?: number;
host?: string;
vision?: boolean;
config?: string;
};
export async function toBrowserOptions(config: Config): Promise<BrowserOptions> {
const defaultConfig: Config = {
browser: {
browserName: 'chromium',
userDataDir: os.tmpdir(),
launchOptions: {
channel: 'chrome',
headless: os.platform() === 'linux' && !process.env.DISPLAY,
},
contextOptions: {
viewport: null,
},
},
};
export async function resolveConfig(cliOptions: CLIOptions): Promise<Config> {
const config = await loadConfig(cliOptions.config);
const cliOverrides = await configFromCLIOptions(cliOptions);
return mergeConfig(defaultConfig, mergeConfig(config, cliOverrides));
}
export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Config> {
let browserName: 'chromium' | 'firefox' | 'webkit';
let channel: string | undefined;
switch (config.browser?.type) {
switch (cliOptions.browser) {
case 'chrome':
case 'chrome-beta':
case 'chrome-canary':
@ -44,7 +71,7 @@ export async function toBrowserOptions(config: Config): Promise<BrowserOptions>
case 'msedge-canary':
case 'msedge-dev':
browserName = 'chromium';
channel = config.browser.type;
channel = cliOptions.browser;
break;
case 'firefox':
browserName = 'firefox';
@ -58,23 +85,26 @@ export async function toBrowserOptions(config: Config): Promise<BrowserOptions>
}
const launchOptions: LaunchOptions = {
headless: !!(config.browser?.headless ?? (os.platform() === 'linux' && !process.env.DISPLAY)),
channel,
executablePath: config.browser?.executablePath,
...{ assistantMode: true },
};
const contextOptions: BrowserContextOptions = {
viewport: null,
executablePath: cliOptions.executablePath,
};
if (browserName === 'chromium')
(launchOptions as any).webSocketPort = await findFreePort();
return {
browserName,
launchOptions,
contextOptions,
browser: {
browserName,
userDataDir: cliOptions.userDataDir ?? await createUserDataDir(browserName),
launchOptions,
cdpEndpoint: cliOptions.cdpEndpoint,
},
server: {
port: cliOptions.port,
host: cliOptions.host,
},
capabilities: cliOptions.caps?.split(',').map((c: string) => c.trim() as ToolCapability),
vision: !!cliOptions.vision,
};
}
@ -89,9 +119,57 @@ async function findFreePort() {
});
}
async function loadConfig(configFile: string | undefined): Promise<Config> {
if (!configFile)
return {};
try {
return JSON.parse(await fs.promises.readFile(configFile, 'utf8'));
} catch (error) {
throw new Error(`Failed to load config file: ${configFile}`);
}
}
async function createUserDataDir(browserName: 'chromium' | 'firefox' | 'webkit') {
let cacheDirectory: string;
if (process.platform === 'linux')
cacheDirectory = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
else if (process.platform === 'darwin')
cacheDirectory = path.join(os.homedir(), 'Library', 'Caches');
else if (process.platform === 'win32')
cacheDirectory = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
else
throw new Error('Unsupported platform: ' + process.platform);
const result = path.join(cacheDirectory, 'ms-playwright', `mcp-${browserName}-profile`);
await fs.promises.mkdir(result, { recursive: true });
return result;
}
export async function outputFile(config: Config, name: string): Promise<string> {
const result = config.outputDir ?? os.tmpdir();
await fs.promises.mkdir(result, { recursive: true });
const fileName = sanitizeForFilePath(name);
return path.join(result, fileName);
}
function mergeConfig(base: Config, overrides: Config): Config {
const browser: Config['browser'] = {
...base.browser,
...overrides.browser,
launchOptions: {
...base.browser?.launchOptions,
...overrides.browser?.launchOptions,
...{ assistantMode: true },
},
contextOptions: {
...base.browser?.contextOptions,
...overrides.browser?.contextOptions,
},
};
return {
...base,
...overrides,
browser,
};
}

View File

@ -14,21 +14,15 @@
* limitations under the License.
*/
import fs from 'fs';
import path from 'path';
import os from 'os';
import * as playwright from 'playwright';
import { waitForCompletion } from './tools/utils';
import { ManualPromise } from './manualPromise';
import { toBrowserOptions } from './config';
import { Tab } from './tab';
import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types';
import type { ModalState, Tool, ToolActionResult } from './tools/tool';
import type { Config } from '../config';
import type { BrowserOptions } from './config';
type PendingAction = {
dialogShown: ManualPromise<void>;
@ -285,51 +279,32 @@ ${code.join('\n')}
}
private async _innerCreateBrowserContext(): Promise<{ browser?: playwright.Browser, browserContext: playwright.BrowserContext }> {
const browserOptions = await toBrowserOptions(this.config);
if (this.config.browser?.remoteEndpoint) {
const url = new URL(this.config.browser?.remoteEndpoint);
if (browserOptions.browserName)
url.searchParams.set('browser', browserOptions.browserName);
if (browserOptions.launchOptions)
url.searchParams.set('launch-options', JSON.stringify(browserOptions.launchOptions));
const browser = await playwright[browserOptions.browserName ?? 'chromium'].connect(String(url));
if (this.config.browser.browserName)
url.searchParams.set('browser', this.config.browser.browserName);
if (this.config.browser.launchOptions)
url.searchParams.set('launch-options', JSON.stringify(this.config.browser.launchOptions));
const browser = await playwright[this.config.browser?.browserName ?? 'chromium'].connect(String(url));
const browserContext = await browser.newContext();
return { browser, browserContext };
}
if (this.config.browser?.cdpEndpoint) {
const browser = await playwright.chromium.connectOverCDP(this.config.browser?.cdpEndpoint);
const browser = await playwright.chromium.connectOverCDP(this.config.browser.cdpEndpoint);
const browserContext = browser.contexts()[0];
return { browser, browserContext };
}
const browserContext = await launchPersistentContext(this.config.browser?.userDataDir, browserOptions);
const browserContext = await launchPersistentContext(this.config.browser);
return { browserContext };
}
}
async function createUserDataDir(browserName: 'chromium' | 'firefox' | 'webkit') {
let cacheDirectory: string;
if (process.platform === 'linux')
cacheDirectory = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
else if (process.platform === 'darwin')
cacheDirectory = path.join(os.homedir(), 'Library', 'Caches');
else if (process.platform === 'win32')
cacheDirectory = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
else
throw new Error('Unsupported platform: ' + process.platform);
const result = path.join(cacheDirectory, 'ms-playwright', `mcp-${browserName}-profile`);
await fs.promises.mkdir(result, { recursive: true });
return result;
}
async function launchPersistentContext(userDataDir: string | undefined, browserOptions: BrowserOptions): Promise<playwright.BrowserContext> {
userDataDir = userDataDir ?? await createUserDataDir(browserOptions.browserName);
async function launchPersistentContext(browserConfig: Config['browser']): Promise<playwright.BrowserContext> {
try {
const browserType = browserOptions.browserName ? playwright[browserOptions.browserName] : playwright.chromium;
return await browserType.launchPersistentContext(userDataDir, browserOptions.launchOptions);
const browserType = browserConfig?.browserName ? playwright[browserConfig.browserName] : playwright.chromium;
return await browserType.launchPersistentContext(browserConfig?.userDataDir || '', browserConfig?.launchOptions);
} catch (error: any) {
if (error.message.includes('Executable doesn\'t exist'))
throw new Error(`Browser specified in your config is not installed. Either install it (likely) or change the config.`);

View File

@ -14,8 +14,6 @@
* limitations under the License.
*/
import fs from 'fs';
import { program } from 'commander';
import { createServer } from './index';
@ -23,7 +21,7 @@ import { ServerList } from './server';
import { startHttpTransport, startStdioTransport } from './transport';
import type { Config, ToolCapability } from '../config';
import { resolveConfig } from './config';
const packageJSON = require('../package.json');
@ -41,22 +39,7 @@ program
.option('--vision', 'Run server that uses screenshots (Aria snapshots are used by default)')
.option('--config <path>', 'Path to the configuration file.')
.action(async options => {
const cliOverrides: Config = {
browser: {
type: options.browser,
userDataDir: options.userDataDir,
headless: options.headless,
executablePath: options.executablePath,
cdpEndpoint: options.cdpEndpoint,
},
server: {
port: options.port,
host: options.host,
},
capabilities: options.caps?.split(',').map((c: string) => c.trim() as ToolCapability),
vision: !!options.vision,
};
const config = await loadConfig(options.config, cliOverrides);
const config = await resolveConfig(options);
const serverList = new ServerList(() => createServer(config));
setupExitWatchdog(serverList);
@ -66,30 +49,6 @@ program
await startStdioTransport(serverList);
});
async function loadConfig(configFile: string | undefined, cliOverrides: Config): Promise<Config> {
if (!configFile)
return cliOverrides;
try {
const config = JSON.parse(await fs.promises.readFile(configFile, 'utf8'));
return {
...config,
...cliOverrides,
browser: {
...config.browser,
...cliOverrides.browser,
},
server: {
...config.server,
...cliOverrides.server,
},
};
} catch (e) {
console.error(`Error loading config file ${configFile}: ${e}`);
process.exit(1);
}
}
function setupExitWatchdog(serverList: ServerList) {
const handleExit = async () => {
setTimeout(() => process.exit(0), 15000);

View File

@ -19,7 +19,6 @@ import path from 'path';
import { z } from 'zod';
import { defineTool } from './tool';
import { toBrowserOptions } from '../config';
const install = defineTool({
capability: 'install',
@ -30,8 +29,7 @@ const install = defineTool({
},
handle: async context => {
const browserOptions = await toBrowserOptions(context.config);
const channel = browserOptions.launchOptions?.channel ?? browserOptions.browserName ?? 'chrome';
const channel = context.config.browser?.launchOptions?.channel ?? context.config.browser?.launchOptions.channel ?? context.config.browser?.launchOptions.browserName ?? 'chrome';
const cli = path.join(require.resolve('playwright/package.json'), '..', 'cli.js');
const child = fork(cli, ['install', channel], {
stdio: 'pipe',