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` - Default: `chrome`
- `--caps <caps>`: Comma-separated list of capabilities to enable, possible values: tabs, pdf, history, wait, files, install. Default is all. - `--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 - `--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 - `--executable-path <path>`: Path to the browser executable
- `--headless`: Run browser in headless mode (headed by default) - `--headless`: Run browser in headless mode (headed by default)
- `--device`: Emulate mobile device - `--device`: Emulate mobile device
@ -131,15 +132,45 @@ Playwright MCP server supports following arguments. They can be provided in the
### User profile ### 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.
``` **Persistent profile**
- `%USERPROFILE%\AppData\Local\ms-playwright\mcp-{channel}-profile` on Windows
- `~/Library/Caches/ms-playwright/mcp-{channel}-profile` on macOS 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.
- `~/.cache/ms-playwright/mcp-{channel}-profile` on Linux 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 ### Configuration file
@ -161,7 +192,7 @@ npx @playwright/mcp@latest --config path/to/config.json
browserName?: 'chromium' | 'firefox' | 'webkit'; browserName?: 'chromium' | 'firefox' | 'webkit';
// Keep the browser profile in memory, do not save it to disk. // 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 // Path to user data directory for browser profile persistence
userDataDir?: string; 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. * 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. * Path to a user data directory for browser profile persistence.

View File

@ -28,11 +28,12 @@ export type CLIOptions = {
browser?: string; browser?: string;
caps?: string; caps?: string;
cdpEndpoint?: string; cdpEndpoint?: string;
ephemeral?: boolean; isolated?: boolean;
executablePath?: string; executablePath?: string;
headless?: boolean; headless?: boolean;
device?: string; device?: string;
userDataDir?: string; userDataDir?: string;
storageState?: string;
port?: number; port?: number;
host?: string; host?: string;
vision?: boolean; vision?: boolean;
@ -102,12 +103,14 @@ export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Conf
if (browserName === 'chromium') if (browserName === 'chromium')
(launchOptions as any).cdpPort = await findFreePort(); (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 { return {
browser: { browser: {
browserName, browserName,
ephemeral: cliOptions.ephemeral, isolated: cliOptions.isolated,
userDataDir: cliOptions.userDataDir, userDataDir: cliOptions.userDataDir,
launchOptions, launchOptions,
contextOptions, contextOptions,

View File

@ -341,22 +341,22 @@ ${code.join('\n')}
if (this.config.browser?.cdpEndpoint) { if (this.config.browser?.cdpEndpoint) {
const browser = await playwright.chromium.connectOverCDP(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 { browser, browserContext };
} }
return this.config.browser?.ephemeral ? return this.config.browser?.isolated ?
await launchEphemeralContext(this.config.browser) : await createIsolatedContext(this.config.browser) :
await launchPersistentContext(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 { try {
const browserName = browserConfig?.browserName ?? 'chromium'; const browserName = browserConfig?.browserName ?? 'chromium';
const browserType = playwright[browserName]; const browserType = playwright[browserName];
const browser = await browserType.launch(browserConfig?.launchOptions); const browser = await browserType.launch(browserConfig?.launchOptions);
const browserContext = await browser.newContext(); const browserContext = await browser.newContext(browserConfig?.contextOptions);
return { browser, browserContext }; return { browser, browserContext };
} catch (error: any) { } catch (error: any) {
if (error.message.includes('Executable doesn\'t exist')) 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('--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('--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('--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('--executable-path <path>', 'Path to the browser executable.')
.option('--headless', 'Run browser in headless mode, headed by default') .option('--headless', 'Run browser in headless mode, headed by default')
.option('--device <device>', 'Device to emulate, for example: "iPhone 15"') .option('--device <device>', 'Device to emulate, for example: "iPhone 15"')

View File

@ -14,6 +14,8 @@
* limitations under the License. * limitations under the License.
*/ */
import fs from 'fs';
import { test, expect } from './fixtures.js'; import { test, expect } from './fixtures.js';
test('test reopen browser', async ({ client, server }) => { test('test reopen browser', async ({ client, server }) => {
@ -73,7 +75,7 @@ test('persistent context', async ({ startClient, server }) => {
expect(response2).toContainTextContent(`Storage: YES`); expect(response2).toContainTextContent(`Storage: YES`);
}); });
test('ephemeral context', async ({ startClient, server }) => { test('isolated context', async ({ startClient, server }) => {
server.setContent('/', ` server.setContent('/', `
<body> <body>
</body> </body>
@ -83,7 +85,7 @@ test('ephemeral context', async ({ startClient, server }) => {
</script> </script>
`, 'text/html'); `, 'text/html');
const client = await startClient({ args: [`--ephemeral`] }); const client = await startClient({ args: [`--isolated`] });
const response = await client.callTool({ const response = await client.callTool({
name: 'browser_navigate', name: 'browser_navigate',
arguments: { url: server.PREFIX }, arguments: { url: server.PREFIX },
@ -94,10 +96,40 @@ test('ephemeral context', async ({ startClient, server }) => {
name: 'browser_close', name: 'browser_close',
}); });
const client2 = await startClient({ args: [`--ephemeral`] }); const client2 = await startClient({ args: [`--isolated`] });
const response2 = await client2.callTool({ const response2 = await client2.callTool({
name: 'browser_navigate', name: 'browser_navigate',
arguments: { url: server.PREFIX }, arguments: { url: server.PREFIX },
}); });
expect(response2).toContainTextContent(`Storage: NO`); 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`);
});