feat(ephemeral): allow for non-persistent context operation (#405)

Ref: https://github.com/microsoft/playwright-mcp/issues/367
Ref: https://github.com/microsoft/playwright-mcp/issues/393
This commit is contained in:
Pavel Feldman 2025-05-12 18:18:53 -07:00 committed by GitHub
parent a1eee8351e
commit 949f956378
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 130 additions and 42 deletions

View File

@ -160,6 +160,9 @@ npx @playwright/mcp@latest --config path/to/config.json
// Browser type to use (chromium, firefox, or webkit)
browserName?: 'chromium' | 'firefox' | 'webkit';
// Keep the browser profile in memory, do not save it to disk.
ephemeral?: boolean;
// Path to user data directory for browser profile persistence
userDataDir?: string;

5
config.d.ts vendored
View File

@ -28,6 +28,11 @@ export type Config = {
*/
browserName?: 'chromium' | 'firefox' | 'webkit';
/**
* Keep the browser profile in memory, do not save it to disk.
*/
ephemeral?: boolean;
/**
* Path to a user data directory for browser profile persistence.
* Temporary directory is created by default.

View File

@ -28,6 +28,7 @@ export type CLIOptions = {
browser?: string;
caps?: string;
cdpEndpoint?: string;
ephemeral?: boolean;
executablePath?: string;
headless?: boolean;
device?: string;
@ -106,6 +107,7 @@ export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Conf
return {
browser: {
browserName,
ephemeral: cliOptions.ephemeral,
userDataDir: cliOptions.userDataDir,
launchOptions,
contextOptions,

View File

@ -34,12 +34,15 @@ type PendingAction = {
dialogShown: ManualPromise<void>;
};
type BrowserContextAndBrowser = {
browser?: playwright.Browser;
browserContext: playwright.BrowserContext;
};
export class Context {
readonly tools: Tool[];
readonly config: Config;
private _browser: playwright.Browser | undefined;
private _browserContext: playwright.BrowserContext | undefined;
private _createBrowserContextPromise: Promise<{ browser?: playwright.Browser, browserContext: playwright.BrowserContext }> | undefined;
private _browserContextPromise: Promise<BrowserContextAndBrowser> | undefined;
private _tabs: Tab[] = [];
private _currentTab: Tab | undefined;
private _modalStates: (ModalState & { tab: Tab })[] = [];
@ -85,7 +88,7 @@ export class Context {
}
async newTab(): Promise<Tab> {
const browserContext = await this._ensureBrowserContext();
const { browserContext } = await this._ensureBrowserContext();
const page = await browserContext.newPage();
this._currentTab = this._tabs.find(t => t.page === page)!;
return this._currentTab;
@ -97,9 +100,9 @@ export class Context {
}
async ensureTab(): Promise<Tab> {
const context = await this._ensureBrowserContext();
const { browserContext } = await this._ensureBrowserContext();
if (!this._currentTab)
await context.newPage();
await browserContext.newPage();
return this._currentTab!;
}
@ -273,22 +276,22 @@ ${code.join('\n')}
if (this._currentTab === tab)
this._currentTab = this._tabs[Math.min(index, this._tabs.length - 1)];
if (this._browserContext && !this._tabs.length)
if (!this._tabs.length)
void this.close();
}
async close() {
if (!this._browserContext)
if (!this._browserContextPromise)
return;
const browserContext = this._browserContext;
const browser = this._browser;
this._createBrowserContextPromise = undefined;
this._browserContext = undefined;
this._browser = undefined;
await browserContext?.close().then(async () => {
await browser?.close();
}).catch(() => {});
const promise = this._browserContextPromise;
this._browserContextPromise = undefined;
await promise.then(async ({ browserContext, browser }) => {
await browserContext.close().then(async () => {
await browser?.close();
}).catch(() => {});
});
}
private async _setupRequestInterception(context: playwright.BrowserContext) {
@ -305,30 +308,26 @@ ${code.join('\n')}
}
}
private async _ensureBrowserContext() {
if (!this._browserContext) {
const context = await this._createBrowserContext();
this._browser = context.browser;
this._browserContext = context.browserContext;
await this._setupRequestInterception(this._browserContext);
for (const page of this._browserContext.pages())
this._onPageCreated(page);
this._browserContext.on('page', page => this._onPageCreated(page));
}
return this._browserContext;
}
private async _createBrowserContext(): Promise<{ browser?: playwright.Browser, browserContext: playwright.BrowserContext }> {
if (!this._createBrowserContextPromise) {
this._createBrowserContextPromise = this._innerCreateBrowserContext();
void this._createBrowserContextPromise.catch(() => {
this._createBrowserContextPromise = undefined;
private _ensureBrowserContext() {
if (!this._browserContextPromise) {
this._browserContextPromise = this._setupBrowserContext();
this._browserContextPromise.catch(() => {
this._browserContextPromise = undefined;
});
}
return this._createBrowserContextPromise;
return this._browserContextPromise;
}
private async _innerCreateBrowserContext(): Promise<{ browser?: playwright.Browser, browserContext: playwright.BrowserContext }> {
private async _setupBrowserContext(): Promise<BrowserContextAndBrowser> {
const { browser, browserContext } = await this._createBrowserContext();
await this._setupRequestInterception(browserContext);
for (const page of browserContext.pages())
this._onPageCreated(page);
browserContext.on('page', page => this._onPageCreated(page));
return { browser, browserContext };
}
private async _createBrowserContext(): Promise<BrowserContextAndBrowser> {
if (this.config.browser?.remoteEndpoint) {
const url = new URL(this.config.browser?.remoteEndpoint);
if (this.config.browser.browserName)
@ -342,21 +341,37 @@ ${code.join('\n')}
if (this.config.browser?.cdpEndpoint) {
const browser = await playwright.chromium.connectOverCDP(this.config.browser.cdpEndpoint);
const browserContext = browser.contexts()[0];
const browserContext = this.config.browser.ephemeral ? await browser.newContext() : browser.contexts()[0];
return { browser, browserContext };
}
const browserContext = await launchPersistentContext(this.config.browser);
return { browserContext };
return this.config.browser?.ephemeral ?
await launchEphemeralContext(this.config.browser) :
await launchPersistentContext(this.config.browser);
}
}
async function launchPersistentContext(browserConfig: Config['browser']): Promise<playwright.BrowserContext> {
async function launchEphemeralContext(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();
return { browser, browserContext };
} catch (error: any) {
if (error.message.includes('Executable doesn\'t exist'))
throw new Error(`Browser specified in your config is not installed. Either install it (likely) or change the config.`);
throw error;
}
}
async function launchPersistentContext(browserConfig: Config['browser']): Promise<BrowserContextAndBrowser> {
try {
const browserName = browserConfig?.browserName ?? 'chromium';
const userDataDir = browserConfig?.userDataDir ?? await createUserDataDir({ ...browserConfig, browserName });
const browserType = playwright[browserName];
return await browserType.launchPersistentContext(userDataDir, { ...browserConfig?.launchOptions, ...browserConfig?.contextOptions });
const browserContext = await browserType.launchPersistentContext(userDataDir, { ...browserConfig?.launchOptions, ...browserConfig?.contextOptions });
return { browserContext };
} catch (error: any) {
if (error.message.includes('Executable doesn\'t exist'))
throw new Error(`Browser specified in your config is not installed. Either install it (likely) or change the config.`);

View File

@ -28,6 +28,7 @@ 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('--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

@ -31,7 +31,7 @@ const close = defineTool({
handle: async context => {
await context.close();
return {
code: [`// Internal to close the page`],
code: [`await page.close()`],
captureSnapshot: false,
waitForNetwork: false,
};

View File

@ -183,6 +183,7 @@ function createTransport(args: string[], mcpMode: TestOptions['mcpMode']) {
command: 'node',
args: [path.join(path.dirname(__filename), '../cli.js'), ...args],
cwd: path.join(path.dirname(__filename), '..'),
env: process.env as Record<string, string>,
});
}

View File

@ -40,3 +40,64 @@ test('executable path', async ({ startClient, server }) => {
});
expect(response).toContainTextContent(`executable doesn't exist`);
});
test('persistent context', async ({ startClient, server }) => {
server.setContent('/', `
<body>
</body>
<script>
document.body.textContent = localStorage.getItem('test') ? 'Storage: YES' : 'Storage: NO';
localStorage.setItem('test', 'test');
</script>
`, 'text/html');
const client = await startClient();
const response = await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
});
expect(response).toContainTextContent(`Storage: NO`);
await new Promise(resolve => setTimeout(resolve, 3000));
await client.callTool({
name: 'browser_close',
});
const client2 = await startClient();
const response2 = await client2.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
});
expect(response2).toContainTextContent(`Storage: YES`);
});
test('ephemeral context', async ({ startClient, server }) => {
server.setContent('/', `
<body>
</body>
<script>
document.body.textContent = localStorage.getItem('test') ? 'Storage: YES' : 'Storage: NO';
localStorage.setItem('test', 'test');
</script>
`, 'text/html');
const client = await startClient({ args: [`--ephemeral`] });
const response = await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
});
expect(response).toContainTextContent(`Storage: NO`);
await client.callTool({
name: 'browser_close',
});
const client2 = await startClient({ args: [`--ephemeral`] });
const response2 = await client2.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
});
expect(response2).toContainTextContent(`Storage: NO`);
});