From 6ff450021123c84c76b2d4b0bc8d831fa3222b52 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Wed, 26 Mar 2025 15:02:45 -0700 Subject: [PATCH] chore: use persistent profile by default (#41) Fixes https://github.com/microsoft/playwright-mcp/issues/29 --- index.d.ts | 9 ++++ src/context.ts | 93 +++++++++++++++++++++++----------------- src/index.ts | 17 +++++--- src/program.ts | 26 ++++++++++- src/resources/console.ts | 2 +- src/server.ts | 14 +++++- src/tools/common.ts | 17 +++----- src/tools/screenshot.ts | 4 +- src/tools/snapshot.ts | 2 +- src/tools/utils.ts | 2 +- tests/fixtures.ts | 5 ++- 11 files changed, 122 insertions(+), 69 deletions(-) diff --git a/index.d.ts b/index.d.ts index 6a79f62..c315dfe 100644 --- a/index.d.ts +++ b/index.d.ts @@ -19,7 +19,16 @@ import type { LaunchOptions } from 'playwright'; import type { Server } from '@modelcontextprotocol/sdk/server/index.js'; type Options = { + /** + * Path to the user data directory. + */ + userDataDir?: string; + + /** + * Launch options for the browser. + */ launchOptions?: LaunchOptions; + /** * Use screenshots instead of snapshots. Less accurate, reliable and overall * slower, but contains visual representation of the page. diff --git a/src/context.ts b/src/context.ts index 3534cc1..6828573 100644 --- a/src/context.ts +++ b/src/context.ts @@ -17,62 +17,75 @@ import * as playwright from 'playwright'; export class Context { + private _userDataDir: string; private _launchOptions: playwright.LaunchOptions | undefined; private _browser: playwright.Browser | undefined; private _page: playwright.Page | undefined; private _console: playwright.ConsoleMessage[] = []; - private _initializePromise: Promise | undefined; + private _createPagePromise: Promise | undefined; - constructor(launchOptions?: playwright.LaunchOptions) { + constructor(userDataDir: string, launchOptions?: playwright.LaunchOptions) { + this._userDataDir = userDataDir; this._launchOptions = launchOptions; } - async ensurePage(): Promise { - await this._initialize(); - return this._page!; + async createPage(): Promise { + if (this._createPagePromise) + return this._createPagePromise; + this._createPagePromise = (async () => { + const { browser, page } = await this._createPage(); + page.on('console', event => this._console.push(event)); + page.on('framenavigated', frame => { + if (!frame.parentFrame()) + this._console.length = 0; + }); + page.on('close', () => this._onPageClose()); + this._page = page; + this._browser = browser; + return page; + })(); + return this._createPagePromise; } - async ensureConsole(): Promise { - await this._initialize(); + private _onPageClose() { + const browser = this._browser; + const page = this._page; + void page?.context()?.close().then(() => browser?.close()).catch(() => {}); + + this._createPagePromise = undefined; + this._browser = undefined; + this._page = undefined; + this._console.length = 0; + } + + async existingPage(): Promise { + if (!this._page) + throw new Error('Navigate to a location to create a page'); + return this._page; + } + + async console(): Promise { return this._console; } async close() { - const page = await this.ensurePage(); - await page.close(); + if (!this._page) + return; + await this._page.close(); } - private async _initialize() { - if (this._initializePromise) - return this._initializePromise; - this._initializePromise = (async () => { - this._browser = await createBrowser(this._launchOptions); - this._page = await this._browser.newPage(); - this._page.on('console', event => this._console.push(event)); - this._page.on('framenavigated', frame => { - if (!frame.parentFrame()) - this._console.length = 0; - }); - this._page.on('close', () => this._reset()); - })(); - return this._initializePromise; - } + private async _createPage(): Promise<{ browser?: playwright.Browser, page: playwright.Page }> { + if (process.env.PLAYWRIGHT_WS_ENDPOINT) { + const url = new URL(process.env.PLAYWRIGHT_WS_ENDPOINT); + if (this._launchOptions) + url.searchParams.set('launch-options', JSON.stringify(this._launchOptions)); + const browser = await playwright.chromium.connect(String(url)); + const page = await browser.newPage(); + return { browser, page }; + } - private _reset() { - const browser = this._browser; - this._initializePromise = undefined; - this._browser = undefined; - this._page = undefined; - this._console.length = 0; - void browser?.close(); + const context = await playwright.chromium.launchPersistentContext(this._userDataDir, this._launchOptions); + const [page] = context.pages(); + return { page }; } } - -async function createBrowser(launchOptions?: playwright.LaunchOptions): Promise { - if (process.env.PLAYWRIGHT_WS_ENDPOINT) { - const url = new URL(process.env.PLAYWRIGHT_WS_ENDPOINT); - url.searchParams.set('launch-options', JSON.stringify(launchOptions)); - return await playwright.chromium.connect(String(url)); - } - return await playwright.chromium.launch({ channel: 'chrome', ...launchOptions }); -} diff --git a/src/index.ts b/src/index.ts index 6529786..a312aff 100644 --- a/src/index.ts +++ b/src/index.ts @@ -61,18 +61,21 @@ const resources: Resource[] = [ ]; type Options = { - vision?: boolean; + userDataDir?: string; launchOptions?: LaunchOptions; + vision?: boolean; }; const packageJSON = require('../package.json'); export function createServer(options?: Options): Server { const tools = options?.vision ? screenshotTools : snapshotTools; - return createServerWithTools( - 'Playwright', - packageJSON.version, - tools, - resources, - options?.launchOptions); + return createServerWithTools({ + name: 'Playwright', + version: packageJSON.version, + tools, + resources, + userDataDir: options?.userDataDir ?? '', + launchOptions: options?.launchOptions, + }); } diff --git a/src/program.ts b/src/program.ts index fb97211..95fa800 100644 --- a/src/program.ts +++ b/src/program.ts @@ -14,6 +14,10 @@ * limitations under the License. */ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + import { program } from 'commander'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; @@ -28,12 +32,17 @@ program .version('Version ' + packageJSON.version) .name(packageJSON.name) .option('--headless', 'Run browser in headless mode, headed by default') + .option('--user-data-dir ', 'Path to the user data directory') .option('--vision', 'Run server that uses screenshots (Aria snapshots are used by default)') .action(async options => { const launchOptions: LaunchOptions = { headless: !!options.headless, + channel: 'chrome', }; - const server = createServer({ launchOptions }); + const server = createServer({ + userDataDir: options.userDataDir ?? await userDataDir(), + launchOptions, + }); setupExitWatchdog(server); const transport = new StdioServerTransport(); @@ -49,3 +58,18 @@ function setupExitWatchdog(server: Server) { } program.parse(process.argv); + +async function userDataDir() { + 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-chromium-profile'); + await fs.promises.mkdir(result, { recursive: true }); + return result; +} diff --git a/src/resources/console.ts b/src/resources/console.ts index ab361cb..ca9bea8 100644 --- a/src/resources/console.ts +++ b/src/resources/console.ts @@ -24,7 +24,7 @@ export const console: Resource = { }, read: async (context, uri) => { - const messages = await context.ensureConsole(); + const messages = await context.console(); const log = messages.map(message => `[${message.type().toUpperCase()}] ${message.text()}`).join('\n'); return [{ uri, diff --git a/src/server.ts b/src/server.ts index 842ccbb..c0e45d4 100644 --- a/src/server.ts +++ b/src/server.ts @@ -23,8 +23,18 @@ import type { Tool } from './tools/tool'; import type { Resource } from './resources/resource'; import type { LaunchOptions } from 'playwright'; -export function createServerWithTools(name: string, version: string, tools: Tool[], resources: Resource[], launchOption?: LaunchOptions): Server { - const context = new Context(launchOption); +type Options = { + name: string; + version: string; + tools: Tool[]; + resources: Resource[], + userDataDir: string; + launchOptions?: LaunchOptions; +}; + +export function createServerWithTools(options: Options): Server { + const { name, version, tools, resources, userDataDir, launchOptions } = options; + const context = new Context(userDataDir, launchOptions); const server = new Server({ name, version }, { capabilities: { tools: {}, diff --git a/src/tools/common.ts b/src/tools/common.ts index 868be95..cb8ce65 100644 --- a/src/tools/common.ts +++ b/src/tools/common.ts @@ -36,7 +36,7 @@ export const navigate: ToolFactory = snapshot => ({ }, handle: async (context, params) => { const validatedParams = navigateSchema.parse(params); - const page = await context.ensurePage(); + const page = await context.createPage(); await page.goto(validatedParams.url, { waitUntil: 'domcontentloaded' }); // Cap load event to 5 seconds, the page is operational at this point. await page.waitForLoadState('load', { timeout: 5000 }).catch(() => {}); @@ -60,10 +60,7 @@ export const goBack: ToolFactory = snapshot => ({ inputSchema: zodToJsonSchema(goBackSchema), }, handle: async context => { - return await runAndWait(context, 'Navigated back', async () => { - const page = await context.ensurePage(); - await page.goBack(); - }, snapshot); + return await runAndWait(context, 'Navigated back', async page => page.goBack(), snapshot); }, }); @@ -76,10 +73,7 @@ export const goForward: ToolFactory = snapshot => ({ inputSchema: zodToJsonSchema(goForwardSchema), }, handle: async context => { - return await runAndWait(context, 'Navigated forward', async () => { - const page = await context.ensurePage(); - await page.goForward(); - }, snapshot); + return await runAndWait(context, 'Navigated forward', async page => page.goForward(), snapshot); }, }); @@ -95,8 +89,7 @@ export const wait: Tool = { }, handle: async (context, params) => { const validatedParams = waitSchema.parse(params); - const page = await context.ensurePage(); - await page.waitForTimeout(Math.min(10000, validatedParams.time * 1000)); + await new Promise(f => setTimeout(f, Math.min(10000, validatedParams.time * 1000))); return { content: [{ type: 'text', @@ -133,7 +126,7 @@ export const pdf: Tool = { inputSchema: zodToJsonSchema(pdfSchema), }, handle: async context => { - const page = await context.ensurePage(); + const page = await context.existingPage(); const fileName = path.join(os.tmpdir(), `/page-${new Date().toISOString()}.pdf`); await page.pdf({ path: fileName }); return { diff --git a/src/tools/screenshot.ts b/src/tools/screenshot.ts index 7a5cf2a..261ac71 100644 --- a/src/tools/screenshot.ts +++ b/src/tools/screenshot.ts @@ -29,7 +29,7 @@ export const screenshot: Tool = { }, handle: async context => { - const page = await context.ensurePage(); + const page = await context.existingPage(); const screenshot = await page.screenshot({ type: 'jpeg', quality: 50, scale: 'css' }); return { content: [{ type: 'image', data: screenshot.toString('base64'), mimeType: 'image/jpeg' }], @@ -55,7 +55,7 @@ export const moveMouse: Tool = { handle: async (context, params) => { const validatedParams = moveMouseSchema.parse(params); - const page = await context.ensurePage(); + const page = await context.existingPage(); await page.mouse.move(validatedParams.x, validatedParams.y); return { content: [{ type: 'text', text: `Moved mouse to (${validatedParams.x}, ${validatedParams.y})` }], diff --git a/src/tools/snapshot.ts b/src/tools/snapshot.ts index 8ccca69..a505659 100644 --- a/src/tools/snapshot.ts +++ b/src/tools/snapshot.ts @@ -30,7 +30,7 @@ export const snapshot: Tool = { }, handle: async context => { - return await captureAriaSnapshot(await context.ensurePage()); + return await captureAriaSnapshot(await context.existingPage()); }, }; diff --git a/src/tools/utils.ts b/src/tools/utils.ts index 4db7016..b374f78 100644 --- a/src/tools/utils.ts +++ b/src/tools/utils.ts @@ -72,7 +72,7 @@ async function waitForCompletion(page: playwright.Page, callback: () => Promi } export async function runAndWait(context: Context, status: string, callback: (page: playwright.Page) => Promise, snapshot: boolean = false): Promise { - const page = await context.ensurePage(); + const page = await context.existingPage(); await waitForCompletion(page, () => callback(page)); return snapshot ? captureAriaSnapshot(page, status) : { content: [{ type: 'text', text: status }], diff --git a/tests/fixtures.ts b/tests/fixtures.ts index 948f155..d8f0f1e 100644 --- a/tests/fixtures.ts +++ b/tests/fixtures.ts @@ -121,11 +121,12 @@ export const test = baseTest.extend({ await use(await startServer()); }, - startServer: async ({ }, use) => { + startServer: async ({ }, use, testInfo) => { let server: MCPServer | undefined; + const userDataDir = testInfo.outputPath('user-data-dir'); use(async options => { - server = new MCPServer('node', [path.join(__dirname, '../cli.js'), '--headless'], options); + server = new MCPServer('node', [path.join(__dirname, '../cli.js'), '--headless', '--user-data-dir', userDataDir], options); const initialize = await server.send({ jsonrpc: '2.0', id: 0,