chore: introduce resolved config (#425)

This commit is contained in:
Pavel Feldman 2025-05-14 16:01:08 -07:00 committed by GitHub
parent 746c9fc124
commit fea50e6840
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 69 additions and 41 deletions

View File

@ -51,7 +51,7 @@ export type CLIOptions = {
vision?: boolean; vision?: boolean;
}; };
const defaultConfig: Config = { const defaultConfig: FullConfig = {
browser: { browser: {
browserName: 'chromium', browserName: 'chromium',
launchOptions: { launchOptions: {
@ -67,12 +67,32 @@ const defaultConfig: Config = {
allowedOrigins: undefined, allowedOrigins: undefined,
blockedOrigins: undefined, blockedOrigins: undefined,
}, },
outputDir: path.join(os.tmpdir(), 'playwright-mcp-output'),
}; };
export async function resolveConfig(cliOptions: CLIOptions): Promise<Config> { type BrowserUserConfig = NonNullable<Config['browser']>;
const config = await loadConfig(cliOptions.config);
export type FullConfig = Config & {
browser: BrowserUserConfig & {
browserName: NonNullable<BrowserUserConfig['browserName']>;
launchOptions: NonNullable<BrowserUserConfig['launchOptions']>;
contextOptions: NonNullable<BrowserUserConfig['contextOptions']>;
},
network: NonNullable<Config['network']>,
outputDir: string;
};
export async function resolveConfig(config: Config): Promise<FullConfig> {
return mergeConfig(defaultConfig, config);
}
export async function resolveCLIConfig(cliOptions: CLIOptions): Promise<FullConfig> {
const configInFile = await loadConfig(cliOptions.config);
const cliOverrides = await configFromCLIOptions(cliOptions); const cliOverrides = await configFromCLIOptions(cliOptions);
return mergeConfig(defaultConfig, mergeConfig(config, cliOverrides)); const result = mergeConfig(mergeConfig(defaultConfig, configInFile), cliOverrides);
// Derive artifact output directory from config.outputDir
result.browser.launchOptions.tracesDir = path.join(result.outputDir, 'traces');
return result;
} }
export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Config> { export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Config> {
@ -202,11 +222,10 @@ async function loadConfig(configFile: string | undefined): Promise<Config> {
} }
} }
export async function outputFile(config: Config, name: string): Promise<string> { export async function outputFile(config: FullConfig, name: string): Promise<string> {
const result = config.outputDir ?? os.tmpdir(); await fs.promises.mkdir(config.outputDir, { 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(config.outputDir, fileName);
} }
function pickDefined<T extends object>(obj: T | undefined): Partial<T> { function pickDefined<T extends object>(obj: T | undefined): Partial<T> {
@ -215,10 +234,10 @@ function pickDefined<T extends object>(obj: T | undefined): Partial<T> {
) as Partial<T>; ) as Partial<T>;
} }
function mergeConfig(base: Config, overrides: Config): Config { function mergeConfig(base: FullConfig, overrides: Config): FullConfig {
const browser: Config['browser'] = { const browser: FullConfig['browser'] = {
...pickDefined(base.browser), browserName: overrides.browser?.browserName ?? base.browser?.browserName ?? 'chromium',
...pickDefined(overrides.browser), isolated: overrides.browser?.isolated ?? base.browser?.isolated ?? false,
launchOptions: { launchOptions: {
...pickDefined(base.browser?.launchOptions), ...pickDefined(base.browser?.launchOptions),
...pickDefined(overrides.browser?.launchOptions), ...pickDefined(overrides.browser?.launchOptions),
@ -228,6 +247,9 @@ function mergeConfig(base: Config, overrides: Config): Config {
...pickDefined(base.browser?.contextOptions), ...pickDefined(base.browser?.contextOptions),
...pickDefined(overrides.browser?.contextOptions), ...pickDefined(overrides.browser?.contextOptions),
}, },
userDataDir: overrides.browser?.userDataDir ?? base.browser?.userDataDir,
cdpEndpoint: overrides.browser?.cdpEndpoint ?? base.browser?.cdpEndpoint,
remoteEndpoint: overrides.browser?.remoteEndpoint ?? base.browser?.remoteEndpoint,
}; };
if (browser.browserName !== 'chromium' && browser.launchOptions) if (browser.browserName !== 'chromium' && browser.launchOptions)
@ -241,5 +263,6 @@ function mergeConfig(base: Config, overrides: Config): Config {
...pickDefined(base.network), ...pickDefined(base.network),
...pickDefined(overrides.network), ...pickDefined(overrides.network),
}, },
outputDir: overrides.outputDir ?? base.outputDir ?? defaultConfig.outputDir,
}; };
} }

View File

@ -21,10 +21,10 @@ import { zodToJsonSchema } from 'zod-to-json-schema';
import { Context, packageJSON } from './context.js'; import { Context, packageJSON } from './context.js';
import { snapshotTools, visionTools } from './tools.js'; import { snapshotTools, visionTools } from './tools.js';
import type { Config } from '../config.js';
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
import { FullConfig } from './config.js';
export async function createConnection(config: Config): Promise<Connection> { export async function createConnection(config: FullConfig): Promise<Connection> {
const allTools = config.vision ? visionTools : snapshotTools; const allTools = config.vision ? visionTools : snapshotTools;
const tools = allTools.filter(tool => !config.capabilities || tool.capability === 'core' || config.capabilities.includes(tool.capability)); const tools = allTools.filter(tool => !config.capabilities || tool.capability === 'core' || config.capabilities.includes(tool.capability));

View File

@ -24,11 +24,11 @@ import * as playwright from 'playwright';
import { waitForCompletion } from './tools/utils.js'; import { waitForCompletion } from './tools/utils.js';
import { ManualPromise } from './manualPromise.js'; import { ManualPromise } from './manualPromise.js';
import { Tab } from './tab.js'; import { Tab } from './tab.js';
import { outputFile } from './config.js';
import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js'; import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js';
import type { ModalState, Tool, ToolActionResult } from './tools/tool.js'; import type { ModalState, Tool, ToolActionResult } from './tools/tool.js';
import type { Config } from '../config.js'; import type { FullConfig } from './config.js';
import { outputFile } from './config.js';
type PendingAction = { type PendingAction = {
dialogShown: ManualPromise<void>; dialogShown: ManualPromise<void>;
@ -41,7 +41,7 @@ type BrowserContextAndBrowser = {
export class Context { export class Context {
readonly tools: Tool[]; readonly tools: Tool[];
readonly config: Config; readonly config: FullConfig;
private _browserContextPromise: Promise<BrowserContextAndBrowser> | undefined; private _browserContextPromise: Promise<BrowserContextAndBrowser> | undefined;
private _tabs: Tab[] = []; private _tabs: Tab[] = [];
private _currentTab: Tab | undefined; private _currentTab: Tab | undefined;
@ -49,7 +49,7 @@ export class Context {
private _pendingAction: PendingAction | undefined; private _pendingAction: PendingAction | undefined;
private _downloads: { download: playwright.Download, finished: boolean, outputFile: string }[] = []; private _downloads: { download: playwright.Download, finished: boolean, outputFile: string }[] = [];
constructor(tools: Tool[], config: Config) { constructor(tools: Tool[], config: FullConfig) {
this.tools = tools; this.tools = tools;
this.config = config; this.config = config;
} }
@ -351,12 +351,12 @@ ${code.join('\n')}
} }
} }
async function createIsolatedContext(browserConfig: Config['browser']): Promise<BrowserContextAndBrowser> { async function createIsolatedContext(browserConfig: FullConfig['browser']): Promise<BrowserContextAndBrowser> {
try { try {
const browserName = browserConfig?.browserName ?? 'chromium'; const browserName = browserConfig?.browserName ?? 'chromium';
const browserType = playwright[browserName]; const browserType = playwright[browserName];
const browser = await browserType.launch(browserConfig?.launchOptions); const browser = await browserType.launch(browserConfig.launchOptions);
const browserContext = await browser.newContext(browserConfig?.contextOptions); const browserContext = await browser.newContext(browserConfig.contextOptions);
return { browser, browserContext }; return { browser, browserContext };
} catch (error: any) { } catch (error: any) {
if (error.message.includes('Executable doesn\'t exist')) if (error.message.includes('Executable doesn\'t exist'))
@ -365,12 +365,12 @@ async function createIsolatedContext(browserConfig: Config['browser']): Promise<
} }
} }
async function launchPersistentContext(browserConfig: Config['browser']): Promise<BrowserContextAndBrowser> { async function launchPersistentContext(browserConfig: FullConfig['browser']): Promise<BrowserContextAndBrowser> {
try { try {
const browserName = browserConfig?.browserName ?? 'chromium'; const browserName = browserConfig.browserName ?? 'chromium';
const userDataDir = browserConfig?.userDataDir ?? await createUserDataDir({ ...browserConfig, browserName }); const userDataDir = browserConfig.userDataDir ?? await createUserDataDir({ ...browserConfig, browserName });
const browserType = playwright[browserName]; const browserType = playwright[browserName];
const browserContext = await browserType.launchPersistentContext(userDataDir, { ...browserConfig?.launchOptions, ...browserConfig?.contextOptions }); const browserContext = await browserType.launchPersistentContext(userDataDir, { ...browserConfig.launchOptions, ...browserConfig.contextOptions });
return { browserContext }; return { browserContext };
} catch (error: any) { } catch (error: any) {
if (error.message.includes('Executable doesn\'t exist')) if (error.message.includes('Executable doesn\'t exist'))
@ -379,7 +379,7 @@ async function launchPersistentContext(browserConfig: Config['browser']): Promis
} }
} }
async function createUserDataDir(browserConfig: Config['browser']) { async function createUserDataDir(browserConfig: FullConfig['browser']) {
let cacheDirectory: string; let cacheDirectory: string;
if (process.platform === 'linux') if (process.platform === 'linux')
cacheDirectory = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache'); cacheDirectory = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
@ -389,7 +389,7 @@ async function createUserDataDir(browserConfig: Config['browser']) {
cacheDirectory = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'); cacheDirectory = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
else else
throw new Error('Unsupported platform: ' + process.platform); throw new Error('Unsupported platform: ' + process.platform);
const result = path.join(cacheDirectory, 'ms-playwright', `mcp-${browserConfig?.launchOptions?.channel ?? browserConfig?.browserName}-profile`); const result = path.join(cacheDirectory, 'ms-playwright', `mcp-${browserConfig.launchOptions?.channel ?? browserConfig?.browserName}-profile`);
await fs.promises.mkdir(result, { recursive: true }); await fs.promises.mkdir(result, { recursive: true });
return result; return result;
} }

View File

@ -15,9 +15,11 @@
*/ */
import { Connection, createConnection as createConnectionImpl } from './connection.js'; import { Connection, createConnection as createConnectionImpl } from './connection.js';
import { resolveConfig } from './config.js';
import type { Config } from '../config.js'; import type { Config } from '../config.js';
export async function createConnection(config: Config = {}): Promise<Connection> { export async function createConnection(userConfig: Config = {}): Promise<Connection> {
const config = await resolveConfig(userConfig);
return createConnectionImpl(config); return createConnectionImpl(config);
} }

View File

@ -17,7 +17,7 @@
import { program } from 'commander'; import { program } from 'commander';
import { startHttpTransport, startStdioTransport } from './transport.js'; import { startHttpTransport, startStdioTransport } from './transport.js';
import { resolveConfig } from './config.js'; import { resolveCLIConfig } from './config.js';
import type { Connection } from './connection.js'; import type { Connection } from './connection.js';
import { packageJSON } from './context.js'; import { packageJSON } from './context.js';
@ -50,7 +50,7 @@ program
.option('--viewport-size <size>', 'specify browser viewport size in pixels, for example "1280, 720"') .option('--viewport-size <size>', 'specify browser viewport size in pixels, for example "1280, 720"')
.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)')
.action(async options => { .action(async options => {
const config = await resolveConfig(options); const config = await resolveCLIConfig(options);
const connectionList: Connection[] = []; const connectionList: Connection[] = [];
setupExitWatchdog(connectionList); setupExitWatchdog(connectionList);

View File

@ -24,16 +24,16 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import { createConnection } from './connection.js'; import { createConnection } from './connection.js';
import type { Config } from '../config.js';
import type { Connection } from './connection.js'; import type { Connection } from './connection.js';
import type { FullConfig } from './config.js';
export async function startStdioTransport(config: Config, connectionList: Connection[]) { export async function startStdioTransport(config: FullConfig, connectionList: Connection[]) {
const connection = await createConnection(config); const connection = await createConnection(config);
await connection.connect(new StdioServerTransport()); await connection.connect(new StdioServerTransport());
connectionList.push(connection); connectionList.push(connection);
} }
async function handleSSE(config: Config, req: http.IncomingMessage, res: http.ServerResponse, url: URL, sessions: Map<string, SSEServerTransport>, connectionList: Connection[]) { async function handleSSE(config: FullConfig, req: http.IncomingMessage, res: http.ServerResponse, url: URL, sessions: Map<string, SSEServerTransport>, connectionList: Connection[]) {
if (req.method === 'POST') { if (req.method === 'POST') {
const sessionId = url.searchParams.get('sessionId'); const sessionId = url.searchParams.get('sessionId');
if (!sessionId) { if (!sessionId) {
@ -68,7 +68,7 @@ async function handleSSE(config: Config, req: http.IncomingMessage, res: http.Se
res.end('Method not allowed'); res.end('Method not allowed');
} }
async function handleStreamable(config: Config, req: http.IncomingMessage, res: http.ServerResponse, sessions: Map<string, StreamableHTTPServerTransport>, connectionList: Connection[]) { async function handleStreamable(config: FullConfig, req: http.IncomingMessage, res: http.ServerResponse, sessions: Map<string, StreamableHTTPServerTransport>, connectionList: Connection[]) {
const sessionId = req.headers['mcp-session-id'] as string | undefined; const sessionId = req.headers['mcp-session-id'] as string | undefined;
if (sessionId) { if (sessionId) {
const transport = sessions.get(sessionId); const transport = sessions.get(sessionId);
@ -104,7 +104,7 @@ async function handleStreamable(config: Config, req: http.IncomingMessage, res:
res.end('Invalid request'); res.end('Invalid request');
} }
export function startHttpTransport(config: Config, port: number, hostname: string | undefined, connectionList: Connection[]) { export function startHttpTransport(config: FullConfig, port: number, hostname: string | undefined, connectionList: Connection[]) {
const sseSessions = new Map<string, SSEServerTransport>(); const sseSessions = new Map<string, SSEServerTransport>();
const streamableSessions = new Map<string, StreamableHTTPServerTransport>(); const streamableSessions = new Map<string, StreamableHTTPServerTransport>();
const httpServer = http.createServer(async (req, res) => { const httpServer = http.createServer(async (req, res) => {

View File

@ -73,6 +73,7 @@ test('save as pdf (filename: output.pdf)', async ({ startClient, mcpBrowser, ser
const files = [...fs.readdirSync(outputDir)]; const files = [...fs.readdirSync(outputDir)];
expect(fs.existsSync(outputDir)).toBeTruthy(); expect(fs.existsSync(outputDir)).toBeTruthy();
expect(files).toHaveLength(1); const pdfFiles = files.filter(f => f.endsWith('.pdf'));
expect(files[0]).toMatch(/^output.pdf$/); expect(pdfFiles).toHaveLength(1);
expect(pdfFiles[0]).toMatch(/^output.pdf$/);
}); });

View File

@ -83,7 +83,9 @@ test('--output-dir should work', async ({ startClient, localOutputPath, server }
}); });
expect(fs.existsSync(outputDir)).toBeTruthy(); expect(fs.existsSync(outputDir)).toBeTruthy();
expect([...fs.readdirSync(outputDir)]).toHaveLength(1); const files = [...fs.readdirSync(outputDir)].filter(f => f.endsWith('.jpeg'));
expect(files).toHaveLength(1);
expect(files[0]).toMatch(/^page-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z\.jpeg$/);
}); });
for (const raw of [undefined, true]) { for (const raw of [undefined, true]) {
@ -117,7 +119,7 @@ for (const raw of [undefined, true]) {
], ],
}); });
const files = [...fs.readdirSync(outputDir)]; const files = [...fs.readdirSync(outputDir)].filter(f => f.endsWith(`.${ext}`));
expect(fs.existsSync(outputDir)).toBeTruthy(); expect(fs.existsSync(outputDir)).toBeTruthy();
expect(files).toHaveLength(1); expect(files).toHaveLength(1);
@ -157,11 +159,11 @@ test('browser_take_screenshot (filename: "output.jpeg")', async ({ startClient,
], ],
}); });
const files = [...fs.readdirSync(outputDir)]; const files = [...fs.readdirSync(outputDir)].filter(f => f.endsWith('.jpeg'));
expect(fs.existsSync(outputDir)).toBeTruthy(); expect(fs.existsSync(outputDir)).toBeTruthy();
expect(files).toHaveLength(1); expect(files).toHaveLength(1);
expect(files[0]).toMatch(/^output.jpeg$/); expect(files[0]).toMatch(/^output\.jpeg$/);
}); });
test('browser_take_screenshot (noImageResponses)', async ({ startClient, server }) => { test('browser_take_screenshot (noImageResponses)', async ({ startClient, server }) => {