From ce7236720874e55e9055c191aa16f440d9204346 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Tue, 13 May 2025 13:14:04 -0700 Subject: [PATCH] feat(storage): allow passing storage state for isolated contexts (#409) Fixes https://github.com/microsoft/playwright-mcp/issues/403 Ref https://github.com/microsoft/playwright-mcp/issues/367 --- README.md | 45 +++++++++++++++++++++++++++++++++++++------- config.d.ts | 2 +- src/config.ts | 9 ++++++--- src/context.ts | 10 +++++----- src/program.ts | 3 ++- tests/launch.spec.ts | 38 ++++++++++++++++++++++++++++++++++--- 6 files changed, 87 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 899891d..a74d3ae 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,7 @@ Playwright MCP server supports following arguments. They can be provided in the - Default: `chrome` - `--caps `: Comma-separated list of capabilities to enable, possible values: tabs, pdf, history, wait, files, install. Default is all. - `--cdp-endpoint `: CDP endpoint to connect to +- `--isolated`: Keep the browser profile in memory, do not save it to disk - `--executable-path `: Path to the browser executable - `--headless`: Run browser in headless mode (headed by default) - `--device`: Emulate mobile device @@ -131,15 +132,45 @@ Playwright MCP server supports following arguments. They can be provided in the ### User profile -Playwright MCP will launch the browser with the new profile, located at +You can run Playwright MCP with persistent profile like a regular browser (default), or in the isolated contexts for the testing sessions. -``` -- `%USERPROFILE%\AppData\Local\ms-playwright\mcp-{channel}-profile` on Windows -- `~/Library/Caches/ms-playwright/mcp-{channel}-profile` on macOS -- `~/.cache/ms-playwright/mcp-{channel}-profile` on Linux +**Persistent profile** + +All the logged in information will be stored in the persistent profile, you can delete it between sessions if you'd like to clear the offline state. +Persistent profile is located at the following locations and you can override it with the `--user-data-dir` argument. + +```bash +# Windows +%USERPROFILE%\AppData\Local\ms-playwright\mcp-{channel}-profile + +# macOS +- ~/Library/Caches/ms-playwright/mcp-{channel}-profile + +# Linux +- ~/.cache/ms-playwright/mcp-{channel}-profile ``` -All the logged in information will be stored in that profile, you can delete it between sessions if you'd like to clear the offline state. +**Isolated** + +In the isolated mode, each session is started in the isolated profile. Every time you ask MCP to close the browser, +the session is closed and all the storage state for this session is lost. You can provide initial storage state +to the browser via the config's `contextOptions` or via the `--storage-state` argument. Learn more about the storage +state [here](https://playwright.dev/docs/auth). + +```js +{ + "mcpServers": { + "playwright": { + "command": "npx", + "args": [ + "@playwright/mcp@latest", + "--isolated", + "--storage-state={path/to/storage.json} + ] + } + } +} +``` ### Configuration file @@ -161,7 +192,7 @@ npx @playwright/mcp@latest --config path/to/config.json browserName?: 'chromium' | 'firefox' | 'webkit'; // Keep the browser profile in memory, do not save it to disk. - ephemeral?: boolean; + isolated?: boolean; // Path to user data directory for browser profile persistence userDataDir?: string; diff --git a/config.d.ts b/config.d.ts index 6a243eb..68bed4e 100644 --- a/config.d.ts +++ b/config.d.ts @@ -31,7 +31,7 @@ export type Config = { /** * Keep the browser profile in memory, do not save it to disk. */ - ephemeral?: boolean; + isolated?: boolean; /** * Path to a user data directory for browser profile persistence. diff --git a/src/config.ts b/src/config.ts index 12399e9..3b90f81 100644 --- a/src/config.ts +++ b/src/config.ts @@ -28,11 +28,12 @@ export type CLIOptions = { browser?: string; caps?: string; cdpEndpoint?: string; - ephemeral?: boolean; + isolated?: boolean; executablePath?: string; headless?: boolean; device?: string; userDataDir?: string; + storageState?: string; port?: number; host?: string; vision?: boolean; @@ -102,12 +103,14 @@ export async function configFromCLIOptions(cliOptions: CLIOptions): Promise { +async function createIsolatedContext(browserConfig: Config['browser']): Promise { try { const browserName = browserConfig?.browserName ?? 'chromium'; const browserType = playwright[browserName]; const browser = await browserType.launch(browserConfig?.launchOptions); - const browserContext = await browser.newContext(); + const browserContext = await browser.newContext(browserConfig?.contextOptions); return { browser, browserContext }; } catch (error: any) { if (error.message.includes('Executable doesn\'t exist')) diff --git a/src/program.ts b/src/program.ts index 197a4e7..6c9cefd 100644 --- a/src/program.ts +++ b/src/program.ts @@ -28,7 +28,8 @@ program .option('--browser ', 'Browser or chrome channel to use, possible values: chrome, firefox, webkit, msedge.') .option('--caps ', 'Comma-separated list of capabilities to enable, possible values: tabs, pdf, history, wait, files, install. Default is all.') .option('--cdp-endpoint ', 'CDP endpoint to connect to.') - .option('--ephemeral', 'Keep the browser profile in memory, do not save it to disk.') + .option('--isolated', 'Keep the browser profile in memory, do not save it to disk.') + .option('--storage-state ', 'Path to the storage state file for isolated sessions.') .option('--executable-path ', 'Path to the browser executable.') .option('--headless', 'Run browser in headless mode, headed by default') .option('--device ', 'Device to emulate, for example: "iPhone 15"') diff --git a/tests/launch.spec.ts b/tests/launch.spec.ts index 26e662b..3670ef1 100644 --- a/tests/launch.spec.ts +++ b/tests/launch.spec.ts @@ -14,6 +14,8 @@ * limitations under the License. */ +import fs from 'fs'; + import { test, expect } from './fixtures.js'; test('test reopen browser', async ({ client, server }) => { @@ -73,7 +75,7 @@ test('persistent context', async ({ startClient, server }) => { expect(response2).toContainTextContent(`Storage: YES`); }); -test('ephemeral context', async ({ startClient, server }) => { +test('isolated context', async ({ startClient, server }) => { server.setContent('/', ` @@ -83,7 +85,7 @@ test('ephemeral context', async ({ startClient, server }) => { `, 'text/html'); - const client = await startClient({ args: [`--ephemeral`] }); + const client = await startClient({ args: [`--isolated`] }); const response = await client.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX }, @@ -94,10 +96,40 @@ test('ephemeral context', async ({ startClient, server }) => { name: 'browser_close', }); - const client2 = await startClient({ args: [`--ephemeral`] }); + const client2 = await startClient({ args: [`--isolated`] }); const response2 = await client2.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX }, }); expect(response2).toContainTextContent(`Storage: NO`); }); + +test('isolated context with storage state', async ({ startClient, server, localOutputPath }) => { + const storageStatePath = localOutputPath('storage-state.json'); + await fs.promises.writeFile(storageStatePath, JSON.stringify({ + origins: [ + { + origin: server.PREFIX, + localStorage: [{ name: 'test', value: 'session-value' }], + }, + ], + })); + + server.setContent('/', ` + + + + `, 'text/html'); + + const client = await startClient({ args: [ + `--isolated`, + `--storage-state=${storageStatePath}`, + ] }); + const response = await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.PREFIX }, + }); + expect(response).toContainTextContent(`Storage: session-value`); +});