diff --git a/README.md b/README.md index 05b92f64b..2e1ce1203 100644 --- a/README.md +++ b/README.md @@ -447,6 +447,10 @@ The Chrome DevTools MCP server supports the following configuration option: - **Type:** boolean - **Default:** `true` +- **`--profileDirectory`/ `--profile-directory`, `-profile-dir`** + Specify which Chrome profile to use by specifying its directory name (e.g., "Profile 1", "Default") inside a chrome user data directory. Only works with --autoConnect or when launching Chrome via the Chrome DevTools MCP server. + - **Type:** string + Pass them via the `args` property in the JSON configuration. For example: diff --git a/src/browser.ts b/src/browser.ts index 628007f6b..6ec90ae1a 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -11,6 +11,7 @@ import path from 'node:path'; import {logger} from './logger.js'; import type { Browser, + BrowserContext, ChromeReleaseChannel, LaunchOptions, Target, @@ -43,6 +44,84 @@ function makeTargetFilter() { }; } +//Extracts the profile directory name from a user data dir path. +function getProfileNameFromUserDataDir(userDataDir: string): string { + const normalized = userDataDir.replace(/\\/g, '/'); + const parts = normalized.split('/'); + return parts[parts.length - 1] || 'Default'; +} + +async function getBrowserContextForProfile( + browser: Browser, + profileDirectory?: string, +): Promise { + if (!profileDirectory) { + return browser.defaultBrowserContext(); + } + + try { + const contexts = browser.browserContexts(); + logger(`Found ${contexts.length} browser context(s)`); + + for (const context of contexts) { + let page; + try { + page = await context.newPage(); + + await page.goto('chrome://version', { + waitUntil: 'domcontentloaded', + timeout: 3_000, + }); + + const profilePath: string | null = await page.evaluate(() => { + const body = document.querySelector('body'); + if (!body) return null; + const text = (body.innerText || ''); + const match = text.match(/Profile Path:\s*(.+)/i); + return match ? match[1].trim() : null; + }); + + try { + await page.close(); + } catch { + //ignore close errors + } + + if (!profilePath) { + continue; + } + + const actualProfile = getProfileNameFromUserDataDir(profilePath); + logger(`Probed context: profilePath=${profilePath} => profileName=${actualProfile}`); + + if (actualProfile === profileDirectory) { + logger(`Matched profile directory "${profileDirectory}" to a browser context`); + return context; + } + } catch (error) { + logger('Error probing a browser context for profile: ', error); + try { + if (page && !page.isClosed()) { + await page.close(); + } + } catch { + // ignore + } + } + } + + logger( + `Profile directory "${profileDirectory}" specified. ` + + `Using default browser context. Full profile support will be added in a future update.`, + ); + + return browser.defaultBrowserContext(); + } catch (error) { + logger('Error getting browser contexts: ', error); + return browser.defaultBrowserContext(); + } +} + export async function ensureBrowserConnected(options: { browserURL?: string; wsEndpoint?: string; @@ -50,6 +129,7 @@ export async function ensureBrowserConnected(options: { devtools: boolean; channel?: Channel; userDataDir?: string; + profileDirectory?: string; }) { const {channel} = options; if (browser?.connected) { @@ -126,7 +206,13 @@ export async function ensureBrowserConnected(options: { }, ); } + logger('Connected Puppeteer'); + + if (options.profileDirectory) { + await getBrowserContextForProfile(browser, options.profileDirectory); + logger(`Using browser context for profile: ${options.profileDirectory}`); + } return browser; } @@ -145,6 +231,7 @@ interface McpLaunchOptions { chromeArgs?: string[]; ignoreDefaultChromeArgs?: string[]; devtools: boolean; + profileDirectory?: string; } export async function launch(options: McpLaunchOptions): Promise { @@ -171,6 +258,12 @@ export async function launch(options: McpLaunchOptions): Promise { ...(options.chromeArgs ?? []), '--hide-crash-restore-bubble', ]; + if (options.profileDirectory) { + args.push(`--profile-directory=${options.profileDirectory}`); + logger( + `Launcing Chrome with profile directory: ${options.profileDirectory}`, + ); + } const ignoreDefaultArgs: LaunchOptions['ignoreDefaultArgs'] = options.ignoreDefaultChromeArgs ?? false; @@ -215,6 +308,36 @@ export async function launch(options: McpLaunchOptions): Promise { contentHeight: options.viewport.height, }); } + + if (options.profileDirectory && userDataDir) { + try { + await new Promise(resolve => setTimeout(resolve, 500)); + + const portPath = path.join(userDataDir, 'DevToolsActivePort'); + const fileContent = await fs.promises.readFile(portPath, 'utf8'); + const lines = fileContent + .split('\n') + .map(line => line.trim()) + .filter(line => line); + + if (lines.length >= 2) { + const browserPath = lines[1]; + const actualProfile = getProfileNameFromUserDataDir(browserPath); + const requestedProfile = options.profileDirectory; + + if (actualProfile !== requestedProfile) { + logger( + `Warning: Requested profile "${requestedProfile}" but Chrome may be using profile "${actualProfile}". ` + + `This could happen if Chrome is managing profiles differently.`, + ); + } else { + logger(`Successfully validated profile: ${actualProfile}`); + } + } + } catch (error) { + logger('Could not validate profile directory after launch: ', error); + } + } return browser; } catch (error) { if ( diff --git a/src/cli.ts b/src/cli.ts index 13c497685..a2ed4d693 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -198,6 +198,13 @@ export const cliOptions = { default: true, describe: 'Set to false to exclude tools related to network.', }, + profileDirectory: { + type: 'string', + description: + 'Specify which Chrome profile to use by specifying its directory name (e.g., "Profile 1", "Default") inside a chrome user data directory. Only works with --autoConnect or when launching Chrome via the Chrome DevTools MCP server.', + alias: 'profile-dir', + conflicts: ['browserUrl', 'wsEndpoint'], + }, usageStatistics: { type: 'boolean', // Marked as `false` until the feature is ready to be enabled by default. @@ -273,6 +280,14 @@ export function parseArguments(version: string, argv = process.argv) { '$0 --auto-connect --channel=canary', 'Connect to a canary Chrome instance (Chrome 145+) running instead of launching a new instance', ], + [ + '$0 --auto-connect --profile-directory="Profile 1"', + 'Connect to Chrome using a specific profile (requires Chrome 145+)', + ], + [ + '$0 --channel=stable --profile-directory="Work Profile"', + 'Launch stable Chrome with a specific profile', + ], ]); return yargsInstance diff --git a/src/main.ts b/src/main.ts index a16ba4dc8..5821e11bd 100644 --- a/src/main.ts +++ b/src/main.ts @@ -72,6 +72,7 @@ async function getContext(): Promise { channel: args.autoConnect ? (args.channel as Channel) : undefined, userDataDir: args.userDataDir, devtools, + profileDirectory: args.profileDirectory, }) : await ensureBrowserLaunched({ headless: args.headless, @@ -85,6 +86,7 @@ async function getContext(): Promise { ignoreDefaultChromeArgs, acceptInsecureCerts: args.acceptInsecureCerts, devtools, + profileDirectory: args.profileDirectory, }); if (context?.browser !== browser) {