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
This commit is contained in:
Pavel Feldman 2025-05-13 13:14:04 -07:00 committed by GitHub
parent 949f956378
commit ce72367208
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 87 additions and 20 deletions

View File

@ -117,6 +117,7 @@ Playwright MCP server supports following arguments. They can be provided in the
- Default: `chrome`
- `--caps <caps>`: Comma-separated list of capabilities to enable, possible values: tabs, pdf, history, wait, files, install. Default is all.
- `--cdp-endpoint <endpoint>`: CDP endpoint to connect to
- `--isolated`: Keep the browser profile in memory, do not save it to disk
- `--executable-path <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;

2
config.d.ts vendored
View File

@ -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.

View File

@ -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<Conf
if (browserName === 'chromium')
(launchOptions as any).cdpPort = await findFreePort();
const contextOptions: BrowserContextOptions | undefined = cliOptions.device ? devices[cliOptions.device] : undefined;
const contextOptions: BrowserContextOptions = cliOptions.device ? devices[cliOptions.device] : {};
if (cliOptions.storageState)
contextOptions.storageState = cliOptions.storageState;
return {
browser: {
browserName,
ephemeral: cliOptions.ephemeral,
isolated: cliOptions.isolated,
userDataDir: cliOptions.userDataDir,
launchOptions,
contextOptions,

View File

@ -341,22 +341,22 @@ ${code.join('\n')}
if (this.config.browser?.cdpEndpoint) {
const browser = await playwright.chromium.connectOverCDP(this.config.browser.cdpEndpoint);
const browserContext = this.config.browser.ephemeral ? await browser.newContext() : browser.contexts()[0];
const browserContext = this.config.browser.isolated ? await browser.newContext() : browser.contexts()[0];
return { browser, browserContext };
}
return this.config.browser?.ephemeral ?
await launchEphemeralContext(this.config.browser) :
return this.config.browser?.isolated ?
await createIsolatedContext(this.config.browser) :
await launchPersistentContext(this.config.browser);
}
}
async function launchEphemeralContext(browserConfig: Config['browser']): Promise<BrowserContextAndBrowser> {
async function createIsolatedContext(browserConfig: Config['browser']): Promise<BrowserContextAndBrowser> {
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'))

View File

@ -28,7 +28,8 @@ program
.option('--browser <browser>', 'Browser or chrome channel to use, possible values: chrome, firefox, webkit, msedge.')
.option('--caps <caps>', 'Comma-separated list of capabilities to enable, possible values: tabs, pdf, history, wait, files, install. Default is all.')
.option('--cdp-endpoint <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>', 'Path to the storage state file for isolated sessions.')
.option('--executable-path <path>', 'Path to the browser executable.')
.option('--headless', 'Run browser in headless mode, headed by default')
.option('--device <device>', 'Device to emulate, for example: "iPhone 15"')

View File

@ -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('/', `
<body>
</body>
@ -83,7 +85,7 @@ test('ephemeral context', async ({ startClient, server }) => {
</script>
`, '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('/', `
<body>
</body>
<script>
document.body.textContent = 'Storage: ' + localStorage.getItem('test');
</script>
`, '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`);
});