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. * limitations under the License.
*/ */
import type * as playwright from 'playwright';
export type ToolCapability = 'core' | 'tabs' | 'pdf' | 'history' | 'wait' | 'files' | 'install'; export type ToolCapability = 'core' | 'tabs' | 'pdf' | 'history' | 'wait' | 'files' | 'install';
export type Config = { export type Config = {
@ -24,12 +26,7 @@ export type Config = {
/** /**
* The type of browser to use. * The type of browser to use.
*/ */
type?: 'chrome' | 'chrome-beta' | 'chrome-canary' | 'chrome-dev' | 'chromium' | 'msedge' | 'msedge-beta' | 'msedge-canary' | 'msedge-dev' | 'firefox' | 'webkit'; browserName?: 'chromium' | 'firefox' | 'webkit';
/**
* Path to a custom browser executable.
*/
executablePath?: string;
/** /**
* Path to a user data directory for browser profile persistence. * Path to a user data directory for browser profile persistence.
@ -37,9 +34,19 @@ export type Config = {
userDataDir?: string; 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. * 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 { sanitizeForFilePath } from './tools/utils';
import type { Config } from '../config'; import type { Config, ToolCapability } from '../config';
import type { LaunchOptions, BrowserContextOptions } from 'playwright'; import type { LaunchOptions } from 'playwright';
export type BrowserOptions = { export type CLIOptions = {
browserName: 'chromium' | 'firefox' | 'webkit'; browser?: string;
launchOptions: LaunchOptions; caps?: string;
contextOptions: BrowserContextOptions; 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 browserName: 'chromium' | 'firefox' | 'webkit';
let channel: string | undefined; let channel: string | undefined;
switch (config.browser?.type) { switch (cliOptions.browser) {
case 'chrome': case 'chrome':
case 'chrome-beta': case 'chrome-beta':
case 'chrome-canary': case 'chrome-canary':
@ -44,7 +71,7 @@ export async function toBrowserOptions(config: Config): Promise<BrowserOptions>
case 'msedge-canary': case 'msedge-canary':
case 'msedge-dev': case 'msedge-dev':
browserName = 'chromium'; browserName = 'chromium';
channel = config.browser.type; channel = cliOptions.browser;
break; break;
case 'firefox': case 'firefox':
browserName = 'firefox'; browserName = 'firefox';
@ -58,23 +85,26 @@ export async function toBrowserOptions(config: Config): Promise<BrowserOptions>
} }
const launchOptions: LaunchOptions = { const launchOptions: LaunchOptions = {
headless: !!(config.browser?.headless ?? (os.platform() === 'linux' && !process.env.DISPLAY)),
channel, channel,
executablePath: config.browser?.executablePath, executablePath: cliOptions.executablePath,
...{ assistantMode: true },
};
const contextOptions: BrowserContextOptions = {
viewport: null,
}; };
if (browserName === 'chromium') if (browserName === 'chromium')
(launchOptions as any).webSocketPort = await findFreePort(); (launchOptions as any).webSocketPort = await findFreePort();
return { return {
browser: {
browserName, browserName,
userDataDir: cliOptions.userDataDir ?? await createUserDataDir(browserName),
launchOptions, launchOptions,
contextOptions, 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> { export async function outputFile(config: Config, name: string): Promise<string> {
const result = config.outputDir ?? os.tmpdir(); const result = config.outputDir ?? os.tmpdir();
await fs.promises.mkdir(result, { recursive: true }); await fs.promises.mkdir(result, { recursive: true });
const fileName = sanitizeForFilePath(name); const fileName = sanitizeForFilePath(name);
return path.join(result, fileName); 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. * limitations under the License.
*/ */
import fs from 'fs';
import path from 'path';
import os from 'os';
import * as playwright from 'playwright'; import * as playwright from 'playwright';
import { waitForCompletion } from './tools/utils'; import { waitForCompletion } from './tools/utils';
import { ManualPromise } from './manualPromise'; import { ManualPromise } from './manualPromise';
import { toBrowserOptions } from './config';
import { Tab } from './tab'; import { Tab } from './tab';
import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types'; import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types';
import type { ModalState, Tool, ToolActionResult } from './tools/tool'; import type { ModalState, Tool, ToolActionResult } from './tools/tool';
import type { Config } from '../config'; import type { Config } from '../config';
import type { BrowserOptions } from './config';
type PendingAction = { type PendingAction = {
dialogShown: ManualPromise<void>; dialogShown: ManualPromise<void>;
@ -285,51 +279,32 @@ ${code.join('\n')}
} }
private async _innerCreateBrowserContext(): Promise<{ browser?: playwright.Browser, browserContext: playwright.BrowserContext }> { private async _innerCreateBrowserContext(): Promise<{ browser?: playwright.Browser, browserContext: playwright.BrowserContext }> {
const browserOptions = await toBrowserOptions(this.config);
if (this.config.browser?.remoteEndpoint) { if (this.config.browser?.remoteEndpoint) {
const url = new URL(this.config.browser?.remoteEndpoint); const url = new URL(this.config.browser?.remoteEndpoint);
if (browserOptions.browserName) if (this.config.browser.browserName)
url.searchParams.set('browser', browserOptions.browserName); url.searchParams.set('browser', this.config.browser.browserName);
if (browserOptions.launchOptions) if (this.config.browser.launchOptions)
url.searchParams.set('launch-options', JSON.stringify(browserOptions.launchOptions)); url.searchParams.set('launch-options', JSON.stringify(this.config.browser.launchOptions));
const browser = await playwright[browserOptions.browserName ?? 'chromium'].connect(String(url)); const browser = await playwright[this.config.browser?.browserName ?? 'chromium'].connect(String(url));
const browserContext = await browser.newContext(); const browserContext = await browser.newContext();
return { browser, browserContext }; return { browser, browserContext };
} }
if (this.config.browser?.cdpEndpoint) { 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]; const browserContext = browser.contexts()[0];
return { browser, browserContext }; return { browser, browserContext };
} }
const browserContext = await launchPersistentContext(this.config.browser?.userDataDir, browserOptions); const browserContext = await launchPersistentContext(this.config.browser);
return { browserContext }; return { browserContext };
} }
} }
async function createUserDataDir(browserName: 'chromium' | 'firefox' | 'webkit') { async function launchPersistentContext(browserConfig: Config['browser']): Promise<playwright.BrowserContext> {
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);
try { try {
const browserType = browserOptions.browserName ? playwright[browserOptions.browserName] : playwright.chromium; const browserType = browserConfig?.browserName ? playwright[browserConfig.browserName] : playwright.chromium;
return await browserType.launchPersistentContext(userDataDir, browserOptions.launchOptions); return await browserType.launchPersistentContext(browserConfig?.userDataDir || '', browserConfig?.launchOptions);
} catch (error: any) { } catch (error: any) {
if (error.message.includes('Executable doesn\'t exist')) 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.`); 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. * limitations under the License.
*/ */
import fs from 'fs';
import { program } from 'commander'; import { program } from 'commander';
import { createServer } from './index'; import { createServer } from './index';
@ -23,7 +21,7 @@ import { ServerList } from './server';
import { startHttpTransport, startStdioTransport } from './transport'; import { startHttpTransport, startStdioTransport } from './transport';
import type { Config, ToolCapability } from '../config'; import { resolveConfig } from './config';
const packageJSON = require('../package.json'); const packageJSON = require('../package.json');
@ -41,22 +39,7 @@ program
.option('--vision', 'Run server that uses screenshots (Aria snapshots are used by default)') .option('--vision', 'Run server that uses screenshots (Aria snapshots are used by default)')
.option('--config <path>', 'Path to the configuration file.') .option('--config <path>', 'Path to the configuration file.')
.action(async options => { .action(async options => {
const cliOverrides: Config = { const config = await resolveConfig(options);
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 serverList = new ServerList(() => createServer(config)); const serverList = new ServerList(() => createServer(config));
setupExitWatchdog(serverList); setupExitWatchdog(serverList);
@ -66,30 +49,6 @@ program
await startStdioTransport(serverList); 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) { function setupExitWatchdog(serverList: ServerList) {
const handleExit = async () => { const handleExit = async () => {
setTimeout(() => process.exit(0), 15000); setTimeout(() => process.exit(0), 15000);

View File

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