Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions packages/extension/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.


4 changes: 2 additions & 2 deletions packages/playwright-core/src/tools/mcp/browserFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
6 changes: 4 additions & 2 deletions packages/playwright-core/src/tools/mcp/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export type CLIOptions = {
outputDir?: string;
outputMaxSize?: number;
port?: number;
profileDirName?: string;
proxyBypass?: string;
proxyServer?: string;
remoteHeader?: Record<string, string>;
Expand Down Expand Up @@ -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<FullConfig['browser']> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<playwrightTypes.Browser> {
export async function createExtensionBrowser(channel: string, executablePath: string | undefined, customUserDataDir: string | undefined, profileDirName: string | undefined, clientName: string): Promise<playwrightTypes.Browser> {
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();
Expand Down
1 change: 1 addition & 0 deletions packages/playwright-core/src/tools/mcp/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ export function decorateMCPCommand(command: Command) {
.option('--output-dir <path>', 'path to the directory for output files.')
.option('--output-max-size <bytes>', 'Threshold for evicting old output files, in bytes.', numberParser)
.option('--port <port>', 'port to listen on for SSE transport.')
.option('--profile-dir-name <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 <bypass>', 'comma-separated domains to bypass proxy, for example ".com,chromium.org,.domain.com"')
.option('--proxy-server <proxy>', 'specify proxy server, for example "http://myproxy:3128" or "socks5://myproxy:8080"')
.addOption(new ProgramOption('--remote-header <headers...>', 'headers to send with the remote endpoint connect request, multiple can be specified.').argParser(headerParser).hideHelp())
Expand Down
2 changes: 1 addition & 1 deletion packages/playwright-core/src/tools/utils/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ export async function isPlaywrightExtensionInstalled(userDataDir: string): Promi
return await findPlaywrightExtensionProfile(userDataDir) !== undefined;
}

async function isExtensionInstalledInProfile(profileDir: string): Promise<boolean> {
export async function isExtensionInstalledInProfile(profileDir: string): Promise<boolean> {
// Web store installs unpack into <profile>/Extensions/<id>; `--load-extension` only
// leaves a settings record in the preferences.
if (await pathExists(path.join(profileDir, 'Extensions', playwrightExtensionId)))
Expand Down
42 changes: 42 additions & 0 deletions tests/extension/extension.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -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 <channel> selects channel-specific userDataDir`, {
annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright-mcp/issues/1589' },
}, async ({ startClient, server }) => {
Expand Down
Loading