diff --git a/packages/extension/README.md b/packages/extension/README.md index 521dd1be5d514..9fece1a317427 100644 --- a/packages/extension/README.md +++ b/packages/extension/README.md @@ -71,4 +71,26 @@ By default, you'll need to approve each connection when the MCP server tries to This token is unique to your browser profile and provides secure authentication between the MCP server and the extension. Once configured, you won't need to manually approve connections each time. +### Selecting a Chrome Profile + +If the extension is installed in several Chrome profiles, the connection is made to the one you used last. To always connect to a specific profile, pass the name of its directory, the last component of "Profile Path" at `chrome://version`, via `--profile-dir-name`: + +```json +{ + "mcpServers": { + "playwright-extension": { + "command": "npx", + "args": [ + "@playwright/mcp@latest", + "--extension", + "--profile-dir-name", + "Profile 2" + ] + } + } +} +``` + +The `PLAYWRIGHT_MCP_PROFILE_DIR_NAME` environment variable can be used instead of the option. The authentication token is specific to the profile, so use the one displayed in that profile. + diff --git a/packages/playwright-core/src/tools/mcp/browserFactory.ts b/packages/playwright-core/src/tools/mcp/browserFactory.ts index 5ca1c20b7ae5b..113370799d96f 100644 --- a/packages/playwright-core/src/tools/mcp/browserFactory.ts +++ b/packages/playwright-core/src/tools/mcp/browserFactory.ts @@ -59,8 +59,8 @@ export async function createBrowserWithInfo(config: FullConfig, clientInfo: Clie canBind = true; ownership = 'own'; } else if (config.extension) { - const { channel, executablePath } = resolveExtensionOptions(cliOptions); - browser = await createExtensionBrowser(channel, executablePath, config.browser.userDataDir, clientInfo.clientName); + const { channel, executablePath, profileDirName } = resolveExtensionOptions(cliOptions); + browser = await createExtensionBrowser(channel, executablePath, config.browser.userDataDir, profileDirName, clientInfo.clientName); ownership = 'attached'; } else { browser = await createPersistentBrowser(config, clientInfo); diff --git a/packages/playwright-core/src/tools/mcp/config.ts b/packages/playwright-core/src/tools/mcp/config.ts index 80669dfbc5967..eed9066e37db9 100644 --- a/packages/playwright-core/src/tools/mcp/config.ts +++ b/packages/playwright-core/src/tools/mcp/config.ts @@ -63,6 +63,7 @@ export type CLIOptions = { outputDir?: string; outputMaxSize?: number; port?: number; + profileDirName?: string; proxyBypass?: string; proxyServer?: string; remoteHeader?: Record; @@ -208,11 +209,12 @@ export async function resolveCLIConfigForCLI(daemonProfilesDir: string, sessionN return { ...result, browser, configFile, skillMode: true }; } -export function resolveExtensionOptions(cliOptions: CLIOptions): { channel: string, executablePath?: string } { +export function resolveExtensionOptions(cliOptions: CLIOptions): { channel: string, executablePath?: string, profileDirName?: string } { const browser = cliOptions.browser ?? envToString(process.env.PLAYWRIGHT_MCP_BROWSER); const { channel } = resolveBrowserParam(browser); const executablePath = cliOptions.executablePath ?? envToString(process.env.PLAYWRIGHT_MCP_EXECUTABLE_PATH); - return { channel: channel ?? 'chrome', executablePath }; + const profileDirName = cliOptions.profileDirName ?? envToString(process.env.PLAYWRIGHT_MCP_PROFILE_DIR_NAME); + return { channel: channel ?? 'chrome', executablePath, profileDirName }; } async function validateBrowserConfig(browser: MergedConfig['browser']): Promise { diff --git a/packages/playwright-core/src/tools/mcp/extensionContextFactory.ts b/packages/playwright-core/src/tools/mcp/extensionContextFactory.ts index fa2b147a05ff7..ec08c8d981ca7 100644 --- a/packages/playwright-core/src/tools/mcp/extensionContextFactory.ts +++ b/packages/playwright-core/src/tools/mcp/extensionContextFactory.ts @@ -14,23 +14,25 @@ * limitations under the License. */ +import path from 'path'; + import debug from 'debug'; import { defaultUserDataDirForChannel } from '@utils/chromiumChannels'; import { playwright } from '../../inprocess'; -import { findPlaywrightExtensionProfile, playwrightExtensionInstallUrl } from '../utils/extension'; +import { findPlaywrightExtensionProfile, isExtensionInstalledInProfile, playwrightExtensionInstallUrl } from '../utils/extension'; import { CDPRelayServer } from './cdpRelay'; import type * as playwrightTypes from '../../..'; const debugLogger = debug('pw:mcp:relay'); -export async function createExtensionBrowser(channel: string, executablePath: string | undefined, customUserDataDir: string | undefined, clientName: string): Promise { +export async function createExtensionBrowser(channel: string, executablePath: string | undefined, customUserDataDir: string | undefined, profileDirName: string | undefined, clientName: string): Promise { customUserDataDir ??= process.env.PWTEST_EXTENSION_USER_DATA_DIR; // Custom executablePath may target a browser in a different filesystem (e.g. Windows chrome.exe from WSL2), so the local profile path is not meaningful. const userDataDir = customUserDataDir ?? (executablePath ? undefined : defaultUserDataDirForChannel(channel)); - const profileDirectory = userDataDir ? await findPlaywrightExtensionProfile(userDataDir) : undefined; - if (!executablePath && userDataDir && !profileDirectory) - throw new Error(`Playwright Extension not found in "${userDataDir}". Install it from ${playwrightExtensionInstallUrl}, or set the PLAYWRIGHT_MCP_EXECUTABLE_PATH environment variable to use a browser at a custom location.`); + const profileDirectory = profileDirName ?? (userDataDir ? await findPlaywrightExtensionProfile(userDataDir) : undefined); + if (userDataDir && !executablePath && (!profileDirectory || !await isExtensionInstalledInProfile(path.join(userDataDir, profileDirectory)))) + throw new Error(`Playwright Extension not found in "${profileDirectory ? path.join(userDataDir, profileDirectory) : userDataDir}". Install it from ${playwrightExtensionInstallUrl}, or set the PLAYWRIGHT_MCP_EXECUTABLE_PATH environment variable to use a browser at a custom location.`); const relay = new CDPRelayServer(channel, executablePath, customUserDataDir, profileDirectory); await relay.start(); diff --git a/packages/playwright-core/src/tools/mcp/program.ts b/packages/playwright-core/src/tools/mcp/program.ts index 12fdf17f6ee4a..9203c9abdc372 100644 --- a/packages/playwright-core/src/tools/mcp/program.ts +++ b/packages/playwright-core/src/tools/mcp/program.ts @@ -62,6 +62,7 @@ export function decorateMCPCommand(command: Command) { .option('--output-dir ', 'path to the directory for output files.') .option('--output-max-size ', 'Threshold for evicting old output files, in bytes.', numberParser) .option('--port ', 'port to listen on for SSE transport.') + .option('--profile-dir-name ', 'name of the profile directory in the user data dir to connect to with --extension, for example "Profile 1". Defaults to the last used profile that has the extension installed.') .option('--proxy-bypass ', 'comma-separated domains to bypass proxy, for example ".com,chromium.org,.domain.com"') .option('--proxy-server ', 'specify proxy server, for example "http://myproxy:3128" or "socks5://myproxy:8080"') .addOption(new ProgramOption('--remote-header ', 'headers to send with the remote endpoint connect request, multiple can be specified.').argParser(headerParser).hideHelp()) diff --git a/packages/playwright-core/src/tools/utils/extension.ts b/packages/playwright-core/src/tools/utils/extension.ts index 9e2ed64d6a7e9..48a217113e9a6 100644 --- a/packages/playwright-core/src/tools/utils/extension.ts +++ b/packages/playwright-core/src/tools/utils/extension.ts @@ -65,7 +65,7 @@ export async function isPlaywrightExtensionInstalled(userDataDir: string): Promi return await findPlaywrightExtensionProfile(userDataDir) !== undefined; } -async function isExtensionInstalledInProfile(profileDir: string): Promise { +export async function isExtensionInstalledInProfile(profileDir: string): Promise { // Web store installs unpack into /Extensions/; `--load-extension` only // leaves a settings record in the preferences. if (await pathExists(path.join(profileDir, 'Extensions', playwrightExtensionId))) diff --git a/tests/extension/extension.spec.ts b/tests/extension/extension.spec.ts index d0145b7a1d1fa..7798b1731c103 100644 --- a/tests/extension/extension.spec.ts +++ b/tests/extension/extension.spec.ts @@ -343,6 +343,31 @@ test(`ignores orphaned preferences entries of an uninstalled extension`, { }).toPass(); }); +test(`--profile-dir-name selects the profile`, { + annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright-mcp/issues/1732' }, +}, async ({ startClient, server }, testInfo) => { + // Both profiles have the extension and Default was used last, so only the option selects Profile 1. + const userDataDir = testInfo.outputPath('multi-profile'); + await fs.mkdir(path.join(userDataDir, 'Default', 'Extensions', extensionId), { recursive: true }); + await fs.mkdir(path.join(userDataDir, 'Profile 1', 'Extensions', extensionId), { recursive: true }); + await fs.writeFile(path.join(userDataDir, 'Local State'), JSON.stringify({ + profile: { last_used: 'Default' }, + })); + + const executablePath = testInfo.outputPath('echo.sh'); + await fs.writeFile(executablePath, '#!/bin/bash\necho "Custom exec args: $@" > "$(dirname "$0")/output.txt"', { mode: 0o755 }); + + const { client } = await startClient({ + args: [`--extension`, `--executable-path=${executablePath}`, `--user-data-dir=${userDataDir}`, `--profile-dir-name=Profile 1`], + }); + + client.callTool({ name: 'browser_navigate', arguments: { url: server.HELLO_WORLD } }).catch(() => {}); + await expect(async () => { + const output = await fs.readFile(testInfo.outputPath('output.txt'), 'utf8'); + expect(output).toContain(`--user-data-dir=${userDataDir} --profile-directory=Profile 1`); + }).toPass(); +}); + test(`fails when extension is missing in custom userDataDir`, async ({ startClient, server }) => { const userDataDir = test.info().outputPath('empty-profile'); @@ -375,6 +400,23 @@ test(`navigate with extension via --user-data-dir`, { }); }); +test(`navigate with extension via PLAYWRIGHT_MCP_PROFILE_DIR_NAME`, async ({ browserWithExtension, startClient, server }) => { + const browserContext = await browserWithExtension.launch(); + + const { client } = await startClient({ + args: [`--extension`], + env: { + PLAYWRIGHT_MCP_PROFILE_DIR_NAME: 'Default', + PWTEST_EXTENSION_USER_DATA_DIR: browserWithExtension.userDataDir, + }, + }); + + const response = await connectAndNavigate(browserContext, client, server.HELLO_WORLD); + expect(response).toHaveResponse({ + snapshot: expect.stringContaining(`Hello, world!`), + }); +}); + test(`--browser selects channel-specific userDataDir`, { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright-mcp/issues/1589' }, }, async ({ startClient, server }) => {