Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/src/getting-started-cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,8 @@ playwright-cli kill-all # forcefully kill all browser processes
playwright-cli -s=name delete-data # delete user data for a named session
```

A headless session shuts itself down after an hour without commands, so a session an agent forgot to close does not keep a browser running. Headed and attached browsers are never closed automatically. Change the timeout with `open --idle-timeout=<ms>` or `timeouts.idle` in the config file, and pass `0` to disable it.

## Monitoring

Use `playwright-cli show` to open a visual dashboard for observing and controlling all running browser sessions:
Expand Down
6 changes: 3 additions & 3 deletions docs/src/getting-started-mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ Playwright MCP supports three profile modes:

### Idle timeout

The browser is launched by the first tool call and stays open until the MCP server exits, so a page that keeps animating or rendering costs CPU for as long as the agent's session lasts. Pass `--timeout-idle` to close the browser after a period without tool calls, in milliseconds:
A page that keeps animating or rendering costs CPU for as long as the browser is open, and an agent session can last hours. A headless browser launched by the server is therefore closed after an hour without tool calls. Headed browsers, and browsers attached over `--cdp-endpoint` or `--extension`, are never closed automatically. Pass `--idle-timeout` to set the timeout in milliseconds for any mode, or `0` to disable it:

```json
{
Expand All @@ -205,7 +205,7 @@ The browser is launched by the first tool call and stays open until the MCP serv
"command": "npx",
"args": [
"@playwright/mcp@latest",
"--timeout-idle=300000"
"--idle-timeout=300000"
]
}
}
Expand All @@ -215,7 +215,7 @@ The browser is launched by the first tool call and stays open until the MCP serv
When no tool call has completed for that long, the browser is closed, headed or not, the same way as if you had closed it by hand. The next tool call launches a new browser, so the agent navigates again. The timer never fires while a tool call is running.

- With `--isolated`, cookies and storage kept in memory are lost on an idle close. Use the persistent profile or `--storage-state` to keep them.
- With `--cdp-endpoint` or `--extension`, the browser is not owned by the server, so an idle close only disconnects from it and its pages stay open. With `--extension`, the next tool call goes through the connect flow again.
- With `--cdp-endpoint` or `--extension`, the browser is not owned by the server, so there is no default timeout, and an explicit one only disconnects from it, leaving its pages open. With `--extension`, the next tool call goes through the connect flow again.
- With `--shared-browser-context`, the timer spans all clients: a client that is idle while others keep working keeps its tabs and state, and the shared browser is closed only once every client has been idle for the timeout.

### Configuration file
Expand Down
10 changes: 1 addition & 9 deletions packages/playwright-core/src/tools/backend/browserBackend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,15 +82,7 @@ export class BrowserBackend extends EventEmitter<{ disconnected: [] }> implement
}

async callTool(name: string, rawArguments: mcpServer.CallToolRequest['params']['arguments'] & { _meta?: Record<string, any> } = {}, signal?: AbortSignal): Promise<mcpServer.CallToolResult> {
this._idleTimer?.callStarted();
try {
return await this._callTool(name, rawArguments, signal);
} finally {
this._idleTimer?.callFinished();
}
}

private async _callTool(name: string, rawArguments: mcpServer.CallToolRequest['params']['arguments'] & { _meta?: Record<string, any> }, signal?: AbortSignal): Promise<mcpServer.CallToolResult> {
this._idleTimer?.poke();
const json = !!rawArguments._meta?.json;
const formatError = (message: string): mcpServer.CallToolResult => ({
content: [{ type: 'text' as const, text: json ? JSON.stringify({ isError: true, error: message }, null, 2) : `### Error\n${message}` }],
Expand Down
12 changes: 4 additions & 8 deletions packages/playwright-core/src/tools/backend/idleTimer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,25 +14,21 @@
* limitations under the License.
*/

export const defaultIdleTimeout = 60 * 60 * 1000;

export class IdleTimer {
private _timeout: number;
private _onIdle: () => void;
private _running = 0;
private _timer: NodeJS.Timeout | undefined;

constructor(timeout: number, onIdle: () => void) {
this._timeout = timeout;
this._onIdle = onIdle;
}

callStarted() {
++this._running;
poke() {
this.dispose();
}

callFinished() {
if (!--this._running)
this._timer = setTimeout(this._onIdle, this._timeout).unref();
this._timer = setTimeout(this._onIdle, this._timeout);
}

dispose() {
Expand Down
2 changes: 2 additions & 0 deletions packages/playwright-core/src/tools/cli-client/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,8 @@ export class Session {
args.push(`--profile=${cliArgs.profile}`);
if (cliArgs.config)
args.push(`--config=${cliArgs.config}`);
if (cliArgs['idle-timeout'] !== undefined)
args.push(`--idle-timeout=${cliArgs['idle-timeout']}`);
if (cliArgs.extension)
args.push('--extension');
else if (cliArgs.cdp)
Expand Down
2 changes: 2 additions & 0 deletions packages/playwright-core/src/tools/cli-daemon/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ const open = declareCommand({
config: z.string().optional().describe('Path to the configuration file, defaults to .playwright/cli.config.json'),
device: z.string().optional().describe('Emulate a specific device, for example "iPhone 15".'),
headed: z.boolean().optional().describe('Run browser in headed mode'),
['idle-timeout']: numberArg.optional().describe('Shut the session down after this many milliseconds without a command. Defaults to one hour for headless browsers, never for headed ones. Pass 0 to disable.'),
mobile: z.boolean().optional().describe('Emulate a generic mobile device (Pixel 10 for Chromium, iPhone 17 for WebKit). Mobile pages are usually lighter, which saves tokens.'),
persistent: z.boolean().optional().describe('Use persistent browser profile'),
profile: z.string().optional().describe('Path to a persistent user data directory.'),
Expand All @@ -71,6 +72,7 @@ const attach = declareCommand({
extension: z.union([z.boolean(), z.string()]).optional().describe('Connect to browser extension, optionally specify browser name (e.g. --extension=chrome)'),
config: z.string().optional().describe('Path to the configuration file, defaults to .playwright/cli.config.json'),
session: z.string().optional().describe('Session name (defaults to bound browser name or "default")'),
['idle-timeout']: numberArg.optional().describe('Detach after this many milliseconds without a command. Attached browsers are never detached by default.'),
}),
toolName: 'browser_snapshot',
toolParams: () => ({ filename: '<auto>' }),
Expand Down
5 changes: 4 additions & 1 deletion packages/playwright-core/src/tools/cli-daemon/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { commands } from './commands';

import { SocketConnection } from '../utils/socketConnection';
import type * as playwright from '../../..';
import type { IdleTimer } from '../backend/idleTimer';
import type { SessionConfig, ClientInfo } from '../cli-client/registry';
import type { CallToolRequest, CallToolResult } from '../backend/tool';
import type { ContextConfig } from '../backend/context';
Expand Down Expand Up @@ -79,6 +80,7 @@ export async function startCliDaemonServer(
ownership?: 'attached' | 'own',
persistent?: boolean,
exitOnClose?: boolean,
idleTimer?: IdleTimer,
}
): Promise<string> {
const sessionConfig = createSessionConfig(clientInfo, sessionName, browserInfo, options);
Expand All @@ -93,7 +95,7 @@ export async function startCliDaemonServer(
}
}

const backend = new BrowserBackend(contextConfig, browserContext, browserTools);
const backend = new BrowserBackend(contextConfig, browserContext, browserTools, { idleTimer: options.idleTimer });
await backend.initialize(mcpClientInfo);

if (browserContext.isClosed())
Expand Down Expand Up @@ -141,6 +143,7 @@ export async function startCliDaemonServer(
});

await saveSessionFile(clientInfo, sessionConfig);
options.idleTimer?.poke();
await monitorSocketPath(socketPath);
return socketPath;
}
Expand Down
5 changes: 3 additions & 2 deletions packages/playwright-core/src/tools/cli-daemon/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export function decorateProgram(program: Command) {
.option('--config <path>', 'path to the config file; by default uses .playwright/cli.config.json in the project directory and ~/.playwright/cli.config.json as global config')
.option('--cdp <url>', 'connect to an existing browser via CDP endpoint URL')
.option('--endpoint <endpoint>', 'attach to a running Playwright browser endpoint')
.option('--idle-timeout <timeout>', 'shut the session down after this many milliseconds without a command, defaults to one hour for headless browsers', configUtils.numberParser)
.option('--init-workspace', 'initialize workspace')
.option('--init-skills <value>', 'install skills for the given agent type ("claude" or "agents")')
.option('--init-skills-global <value>', 'install skills for the given agent type ("claude" or "agents") into the home directory')
Expand All @@ -62,12 +63,12 @@ export function decorateProgram(program: Command) {
};

try {
const { browser, browserInfo, ownership } = await createBrowserWithInfo(mcpConfig, mcpClientInfo, options, { title: sessionName, workspaceDir: clientInfo.workspaceDir });
const { browser, browserInfo, ownership, idleTimer } = await createBrowserWithInfo(mcpConfig, mcpClientInfo, options, { title: sessionName, workspaceDir: clientInfo.workspaceDir });
const browserContext = mcpConfig.browser.isolated ? await browser.newContext(mcpConfig.browser.contextOptions) : browser.contexts()[0];
if (!browserContext)
throw new Error('Error: unable to connect to a browser that does not have any contexts');
const persistent = options.persistent || options.profile || mcpConfig.browser.userDataDir ? true : undefined;
const socketPath = await startCliDaemonServer(sessionName, browserContext, browserInfo, mcpConfig, clientInfo, mcpClientInfo, { persistent, exitOnClose: true, ownership });
const socketPath = await startCliDaemonServer(sessionName, browserContext, browserInfo, mcpConfig, clientInfo, mcpClientInfo, { persistent, exitOnClose: true, ownership, idleTimer });
console.log(`Daemon listening on ${socketPath}\n`);
} catch (error) {
console.log(error);
Expand Down
4 changes: 2 additions & 2 deletions packages/playwright-core/src/tools/mcp/browserFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import { playwright } from '../../inprocess';
import { defaultCacheDirectory } from '../../server/registry/index';
import { testDebug } from './log';
import { outputDir } from '../backend/context';
import { IdleTimer } from '../backend/idleTimer';
import { IdleTimer, defaultIdleTimeout } from '../backend/idleTimer';
import { createExtensionBrowser } from './extensionContextFactory';
import { connectToBrowserAcrossVersions, descriptorEndpoint } from '../utils/connect';
import { serverRegistry } from '../../serverRegistry';
Expand Down Expand Up @@ -52,7 +52,7 @@ export type BindOptions = {

export async function createBrowserWithInfo(config: FullConfig, clientInfo: ClientInfo, cliOptions: CLIOptions, bindOptions: BindOptions): Promise<BrowserWithInfo> {
const info = await createBrowser(config, clientInfo, cliOptions, bindOptions);
const idleTimeout = config.timeouts?.idle;
const idleTimeout = config.timeouts?.idle ?? (info.ownership === 'own' && config.browser.launchOptions.headless ? defaultIdleTimeout : undefined);
if (idleTimeout) {
info.idleTimer = new IdleTimer(idleTimeout, () => info.browser.close().catch(() => {}));
info.browser.once('disconnected', () => info.idleTimer?.dispose());
Expand Down
4 changes: 3 additions & 1 deletion packages/playwright-core/src/tools/mcp/config.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,9 @@ export type Config = {
settle?: number;

/**
* Close the browser after this many milliseconds without a tool call, and relaunch it on the next one. Disabled by default.
* Close the browser after this many milliseconds without a tool call, and relaunch it on the next one.
* Defaults to one hour for headless browsers Playwright launched, and to no timeout for headed or attached ones. Pass 0 to disable.
* The CLI shuts the whole session down instead of relaunching.
*/
idle?: number;
};
Expand Down
7 changes: 4 additions & 3 deletions packages/playwright-core/src/tools/mcp/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export type CLIOptions = {
initScript?: string[];
initPage?: string[];
isolated?: boolean;
idleTimeout?: number;
imageResponses?: 'allow' | 'omit' | 'only';
mobile?: boolean;
sandbox?: boolean;
Expand All @@ -75,7 +76,6 @@ export type CLIOptions = {
storageState?: string;
testIdAttribute?: string;
timeoutAction?: number;
timeoutIdle?: number;
timeoutNavigation?: number;
timeoutSettle?: number;
userAgent?: string;
Expand Down Expand Up @@ -172,6 +172,7 @@ export async function resolveCLIConfigForCLI(daemonProfilesDir: string, sessionN
mobile: options.mobile,
extension: options.extension,
userDataDir: options.profile,
idleTimeout: options.idleTimeout,
snapshotMode: 'full',
});

Expand Down Expand Up @@ -392,7 +393,7 @@ function configFromCLIOptions(cliOptions: CLIOptions): Config & { configFile?: s
testIdAttribute: cliOptions.testIdAttribute,
timeouts: {
action: cliOptions.timeoutAction,
idle: cliOptions.timeoutIdle,
idle: cliOptions.idleTimeout,
navigation: cliOptions.timeoutNavigation,
settle: cliOptions.timeoutSettle,
},
Expand Down Expand Up @@ -452,7 +453,7 @@ export function configFromEnv(env?: NodeJS.ProcessEnv): Config & { configFile?:
options.storageState = envToString(e.PLAYWRIGHT_MCP_STORAGE_STATE);
options.testIdAttribute = envToString(e.PLAYWRIGHT_MCP_TEST_ID_ATTRIBUTE);
options.timeoutAction = numberParser(e.PLAYWRIGHT_MCP_TIMEOUT_ACTION);
options.timeoutIdle = numberParser(e.PLAYWRIGHT_MCP_TIMEOUT_IDLE);
options.idleTimeout = numberParser(e.PLAYWRIGHT_MCP_IDLE_TIMEOUT);
options.timeoutNavigation = numberParser(e.PLAYWRIGHT_MCP_TIMEOUT_NAVIGATION);
options.timeoutSettle = numberParser(e.PLAYWRIGHT_MCP_TIMEOUT_SETTLE);
options.userAgent = envToString(e.PLAYWRIGHT_MCP_USER_AGENT);
Expand Down
2 changes: 1 addition & 1 deletion packages/playwright-core/src/tools/mcp/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export function decorateMCPCommand(command: Command) {
.option('--grant-permissions <permissions...>', 'List of permissions to grant to the browser context, for example "geolocation", "clipboard-read", "clipboard-write".', commaSeparatedList)
.option('--headless', 'run browser in headless mode, headed by default')
.option('--host <host>', 'host to bind server to. Default is localhost. Use 0.0.0.0 to bind to all interfaces.')
.option('--idle-timeout <timeout>', 'close the browser after this many milliseconds without a completed tool call, the next tool call relaunches it. Defaults to one hour for headless browsers, never for headed ones, 0 disables.', numberParser)
.option('--ignore-https-errors', 'ignore https errors')
.option('--init-page <path...>', 'path to TypeScript file to evaluate on Playwright page object')
.option('--init-script <path...>', 'path to JavaScript file to add as an initialization script. The script will be evaluated in every page before any of the page\'s scripts. Can be specified multiple times.')
Expand All @@ -76,7 +77,6 @@ export function decorateMCPCommand(command: Command) {
.option('--storage-state <path>', 'path to the storage state file for isolated sessions.')
.option('--test-id-attribute <attribute>', 'specify the attribute to use for test ids, defaults to "data-testid"')
.option('--timeout-action <timeout>', 'specify action timeout in milliseconds, defaults to 5000ms', numberParser)
.option('--timeout-idle <timeout>', 'close the browser after this many milliseconds without a completed tool call, the next tool call relaunches it. Disabled by default.', numberParser)
.option('--timeout-navigation <timeout>', 'specify navigation timeout in milliseconds, defaults to 60000ms', numberParser)
.option('--timeout-settle <timeout>', 'how long to wait after each action for triggered work to settle, in milliseconds, defaults to 500ms', numberParser)
.option('--user-agent <ua string>', 'specify user agent string')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ playwright-cli delete-data # delete default browser data
playwright-cli -s=mysession delete-data # delete named browser data
```

A headless session shuts down on its own after an hour without commands; the next command then reports that the browser is not open, so run `open` again. Headed browsers stay open. Use `open --idle-timeout=<ms>` to change the timeout, or `0` to disable it.

## Environment Variable

Set a default browser session name via environment variable:
Expand Down
10 changes: 10 additions & 0 deletions tests/mcp/cli-session.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,16 @@ test('close', async ({ cli, server }) => {
expect(listOutput).toContain('(no browsers)');
});

test('idle timeout shuts the session down', async ({ cli, server }) => {
await cli('open', '--idle-timeout=3000', server.HELLO_WORLD);
const { output } = await cli('list');
expect(output).toContain('- default:');

await expect.poll(async () => (await cli('list')).output).toContain('(no browsers)');
const { output: afterOutput } = await cli('snapshot');
expect(afterOutput).toContain(`The browser 'default' is not open, please run open first`);
});

test('close named session', async ({ cli, server }) => {
await cli('-s', 'mysession', 'open', server.HELLO_WORLD);

Expand Down
6 changes: 3 additions & 3 deletions tests/mcp/config-resolve.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -455,21 +455,21 @@ test.describe('resolveCLIConfigForMCP', () => {
});

test('cli timeout overrides defaults', async () => {
const config = await resolveCLIConfigForMCP({ timeoutAction: 10000, timeoutNavigation: 30000, timeoutIdle: 60000 }, emptyEnv);
const config = await resolveCLIConfigForMCP({ timeoutAction: 10000, timeoutNavigation: 30000, idleTimeout: 60000 }, emptyEnv);
expect(config.timeouts.action).toBe(10000);
expect(config.timeouts.navigation).toBe(30000);
expect(config.timeouts.expect).toBe(5000);
expect(config.timeouts.idle).toBe(60000);
});

test('idle timeout is off by default and comes from the config file or env', async ({}, testInfo) => {
test('idle timeout is unset by default and comes from the config file or env', async ({}, testInfo) => {
expect((await resolveCLIConfigForMCP({}, emptyEnv)).timeouts.idle).toBeUndefined();

const configFile = testInfo.outputPath('config.json');
await fs.promises.writeFile(configFile, JSON.stringify({ timeouts: { idle: 1000 } }));
expect((await resolveCLIConfigForMCP({ config: configFile }, emptyEnv)).timeouts.idle).toBe(1000);

expect((await resolveCLIConfigForMCP({ config: configFile }, { ...emptyEnv, PLAYWRIGHT_MCP_TIMEOUT_IDLE: '2000' })).timeouts.idle).toBe(2000);
expect((await resolveCLIConfigForMCP({ config: configFile }, { ...emptyEnv, PLAYWRIGHT_MCP_IDLE_TIMEOUT: '2000' })).timeouts.idle).toBe(2000);
});

test('cli timeout overrides config file timeout', async ({}, testInfo) => {
Expand Down
4 changes: 2 additions & 2 deletions tests/mcp/http.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -526,15 +526,15 @@ async function keepBusy(client: Client, ms: number) {
}

test('http transport shared context: one idle timer across clients', async ({ serverEndpoint, server }) => {
const { url, stderr } = await serverEndpoint({ args: ['--shared-browser-context', '--timeout-idle=500'] });
const { url, stderr } = await serverEndpoint({ args: ['--shared-browser-context', '--idle-timeout=1500'] });
const client1 = await connectClient(url, 'test1');
await client1.client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
});

const client2 = await connectClient(url, 'test2');
await keepBusy(client2.client, 1200);
await keepBusy(client2.client, 3000);
expect(formatLog(stderr())).toEqual({
'create browser (persistent)': 1,
'connect to shared browser': 2,
Expand Down
4 changes: 2 additions & 2 deletions tests/mcp/idle-timeout.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ test('closes the browser after the idle timeout and relaunches it on the next ca
annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/42548' },
}, async ({ startClient, server }) => {
const { client, stderr } = await startClient({
args: ['--timeout-idle=500'],
args: ['--idle-timeout=500'],
env: { DEBUG: 'pw:mcp:test' },
});

Expand Down Expand Up @@ -61,7 +61,7 @@ test('closes the browser after the idle timeout and relaunches it on the next ca
test('cdp endpoint only disconnects on idle and reconnects to the same pages', async ({ cdpServer, startClient, server }) => {
const browserContext = await cdpServer.start();
const { client, stderr } = await startClient({
args: [`--cdp-endpoint=${cdpServer.endpoint}`, '--timeout-idle=500'],
args: [`--cdp-endpoint=${cdpServer.endpoint}`, '--idle-timeout=500'],
env: { DEBUG: 'pw:mcp:test' },
});

Expand Down