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
24 changes: 24 additions & 0 deletions docs/src/getting-started-mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,30 @@ Playwright MCP supports three profile modes:
- **Isolated**: Each session starts fresh. Pass `--isolated` to enable. You can load initial state with `--storage-state`.
- **Browser extension**: Connect to your existing browser tabs with the [Playwright Extension](https://github.com/microsoft/playwright/blob/main/packages/extension/README.md). Pass `--extension` to enable.

### 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:

```json
{
"mcpServers": {
"playwright": {
"command": "npx",
"args": [
"@playwright/mcp@latest",
"--timeout-idle=300000"
]
}
}
}
```

When no tool call has completed for that long, the browser is closed the same way `browser_close` closes it, headed or not. The next tool call relaunches the browser, and its response starts with a note about the idle close, so the agent checks the open tabs or navigates again instead of assuming the old page is still open. 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: the pages stay open, and the note tells the agent to check the open tabs instead. 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

For advanced configuration, use a JSON config file:
Expand Down
46 changes: 32 additions & 14 deletions packages/playwright-core/src/tools/backend/browserBackend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,31 +29,31 @@ import type { ClientInfo, ServerBackend } from '../utils/mcp/server';

const backendDebug = debug('pw:mcp:backend');

export class BrowserBackend extends EventEmitter<{ disconnected: [] }> implements ServerBackend {
export type BrowserBackendCallbacks = {
dispose?: () => Promise<void>;
// Bracket every tool call, for example to drive an idle timer.
callStarted?: () => void;
callFinished?: () => void;
};

export class BrowserBackend extends EventEmitter<{ disconnected: [notice?: string] }> implements ServerBackend {
private _tools: Tool[];
private _context: Context | undefined;
private _sessionLog: SessionLog | undefined;
private _config: ContextConfig;
private _disconnected = false;
private _disposed = false;
private _browserContext: playwright.BrowserContext;
private _disposeCallback: (() => Promise<void>) | undefined;
private _callbacks: BrowserBackendCallbacks;

constructor(config: ContextConfig, browserContext: playwright.BrowserContext, tools: Tool[], disposeCallback?: () => Promise<void>) {
constructor(config: ContextConfig, browserContext: playwright.BrowserContext, tools: Tool[], callbacks: BrowserBackendCallbacks = {}) {
super();
this._config = config;
this._tools = tools;
this._browserContext = browserContext;
this._disposeCallback = disposeCallback;
const markDisconnected = () => {
if (this._disconnected)
return;
backendDebug('browser disconnected');
this._disconnected = true;
this.emit('disconnected');
};
this._browserContext.once('close', markDisconnected);
this._browserContext.browser()?.once('disconnected', markDisconnected);
this._callbacks = callbacks;
this._browserContext.once('close', () => this.markDisconnected());
this._browserContext.browser()?.once('disconnected', () => this.markDisconnected());
}

async initialize(clientInfo: ClientInfo): Promise<void> {
Expand All @@ -65,15 +65,33 @@ export class BrowserBackend extends EventEmitter<{ disconnected: [] }> implement
});
}

// Detaches the backend from its session, the notice is delivered with the session's next response.
markDisconnected(notice?: string) {
if (this._disconnected)
return;
backendDebug('browser disconnected');
this._disconnected = true;
this.emit('disconnected', notice);
}

async dispose() {
if (this._disposed)
return;
this._disposed = true;
await this._context?.dispose().catch(e => debug('pw:tools:error')(e));
await this._disposeCallback?.().catch(e => debug('pw:tools:error')(e));
await this._callbacks.dispose?.().catch(e => debug('pw:tools:error')(e));
}

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

private async _callTool(name: string, rawArguments: mcpServer.CallToolRequest['params']['arguments'] & { _meta?: Record<string, any> }, signal?: AbortSignal): Promise<mcpServer.CallToolResult> {
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
5 changes: 5 additions & 0 deletions packages/playwright-core/src/tools/mcp/config.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,11 @@ export type Config = {
* How long to wait after each action for triggered work (navigations, requests) to settle before responding. Defaults to 500ms.
*/
settle?: number;

/**
* Close the browser after this many milliseconds without a tool call, and relaunch it on the next one. Disabled by default.
*/
idle?: number;
};

/**
Expand Down
3 changes: 3 additions & 0 deletions packages/playwright-core/src/tools/mcp/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ export type CLIOptions = {
storageState?: string;
testIdAttribute?: string;
timeoutAction?: number;
timeoutIdle?: number;
timeoutNavigation?: number;
timeoutSettle?: number;
userAgent?: string;
Expand Down Expand Up @@ -391,6 +392,7 @@ function configFromCLIOptions(cliOptions: CLIOptions): Config & { configFile?: s
testIdAttribute: cliOptions.testIdAttribute,
timeouts: {
action: cliOptions.timeoutAction,
idle: cliOptions.timeoutIdle,
navigation: cliOptions.timeoutNavigation,
settle: cliOptions.timeoutSettle,
},
Expand Down Expand Up @@ -450,6 +452,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.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
1 change: 1 addition & 0 deletions packages/playwright-core/src/tools/mcp/configIni.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ const longhandTypes: Record<string, LonghandType> = {

// timeouts
'timeouts.action': 'number',
'timeouts.idle': 'number',
'timeouts.navigation': 'number',
'timeouts.settle': 'number',

Expand Down
101 changes: 81 additions & 20 deletions packages/playwright-core/src/tools/mcp/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ 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 All @@ -101,6 +102,14 @@ export function decorateMCPCommand(command: Command) {
let sharedBrowserPromise: Promise<BrowserWithInfo> | undefined;
let clientCount = 0;
const clientNameCounters = new Map<string, number>();
const idleTimeout = config.timeouts?.idle;
// A shared context has one idle timer for all clients, it closes the browser once none of them has been active for the timeout.
const backends = new Set<BrowserBackend>();
let sharedIdleNotice: string | undefined;
const sharedIdleTimer = config.sharedBrowserContext && idleTimeout ? new IdleTimer(idleTimeout, () => {
for (const backend of backends)
backend.markDisconnected(sharedIdleNotice);
}) : undefined;

const factory: mcpServer.ServerBackendFactory = {
name: 'Playwright',
Expand All @@ -125,17 +134,20 @@ export function decorateMCPCommand(command: Command) {
clientCount++;
const promise = sharedBrowserPromise;
let shared: BrowserWithInfo | undefined;
let info: BrowserWithInfo;
let browser: BrowserWithInfo['browser'];
try {
shared = await promise;
if (shared) {
testDebug('connect to shared browser');
info = shared;
browser = await connectToBrowserEndpoint(config, shared.browser, shared.endpoint);
} else {
const count = (clientNameCounters.get(clientInfo.clientName) ?? 0) + 1;
clientNameCounters.set(clientInfo.clientName, count);
const sessionName = count > 1 ? `${clientInfo.clientName} (${count})` : clientInfo.clientName;
browser = (await createBrowserWithInfo(config, clientInfo, options, { title: sessionName, workspaceDir: clientInfo.cwd })).browser;
info = await createBrowserWithInfo(config, clientInfo, options, { title: sessionName, workspaceDir: clientInfo.cwd });
browser = info.browser;
}
} catch (error) {
// The dispose callback never runs for a failed create.
Expand All @@ -153,30 +165,79 @@ export function decorateMCPCommand(command: Command) {
throw error;
}

return new BrowserBackend(config, browserContext, tools, async () => {
clientCount--;
const last = !shared || !clientCount;
if (last && sharedBrowserPromise === promise)
sharedBrowserPromise = undefined;

if (!last) {
if (config.browser.isolated) {
testDebug('close context');
await browserContext.close().catch(() => { });
} else {
testDebug('disconnect from shared browser');
// Pages outlive the connection when the browser is not ours, for example over --cdp-endpoint.
const notice = idleTimeout ? idleNotice(idleTimeout, !config.browser.isolated && info.ownership === 'attached') : undefined;
if (shared)
sharedIdleNotice = notice;
const ownIdleTimer = idleTimeout && !sharedIdleTimer ? new IdleTimer(idleTimeout, () => backend.markDisconnected(notice)) : undefined;
const idleTimer = sharedIdleTimer ?? ownIdleTimer;

const backend: BrowserBackend = new BrowserBackend(config, browserContext, tools, {
callStarted: () => idleTimer?.callStarted(),
callFinished: () => idleTimer?.callFinished(),
dispose: async () => {
backends.delete(backend);
ownIdleTimer?.dispose();
clientCount--;
const last = !shared || !clientCount;
if (last && sharedBrowserPromise === promise)
sharedBrowserPromise = undefined;

if (!last) {
if (config.browser.isolated) {
testDebug('close context');
await browserContext.close().catch(() => { });
} else {
testDebug('disconnect from shared browser');
}
await browser.close().catch(() => { });
return;
}
await browser.close().catch(() => { });
return;
}

testDebug('close browser');
await browserContext.close().catch(() => { });
await browser.close().catch(() => { });
await shared?.browser.close().catch(() => { });
testDebug('close browser');
await browserContext.close().catch(() => { });
await browser.close().catch(() => { });
await shared?.browser.close().catch(() => { });
},
});
backends.add(backend);
return backend;
},
};
await mcpServer.start(factory, config.server);
});
}

function idleNotice(timeout: number, pagesSurvive: boolean): string {
if (pagesSurvive)
return `Note: the browser connection was closed after ${timeout}ms of inactivity and has been reestablished. Check the open tabs before interacting with the page.`;
return `Note: the browser was closed after ${timeout}ms of inactivity and has been relaunched. Pages from before the idle close are gone, navigate again before interacting with the page.`;
}

// Fires once no tool call has been running for the timeout, the next call re-arms it.
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;
this.dispose();
}

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

dispose() {
clearTimeout(this._timer);
this._timer = undefined;
}
}
33 changes: 25 additions & 8 deletions packages/playwright-core/src/tools/utils/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ export interface ServerBackend {
initialize?(clientInfo: ClientInfo): Promise<void>;
callTool(name: string, args: CallToolRequest['params']['arguments'], signal: AbortSignal): Promise<CallToolResult>;
dispose?(): Promise<void>;
once(event: 'disconnected', listener: () => void): void;
// The notice, if any, is prepended to the next tool response, for example after an idle close.
once(event: 'disconnected', listener: (notice?: string) => void): void;
}

export type ServerBackendFactory = {
Expand Down Expand Up @@ -72,20 +73,27 @@ export function createServer(name: string, version: string, factory: ServerBacke

let backendPromise: Promise<ServerBackend> | undefined;
let heartbeatStarted = false;
let disposing: Promise<void> | undefined;
let pendingNotice: string | undefined;

const onClose = () => backendPromise?.then(b => b.dispose?.()).catch(serverDebug);
addServerListener(server, 'close', onClose);

server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
serverDebug('callTool', request);
let notice: string | undefined;
let result: CallToolResult;

try {
if (!backendPromise) {
// Let the previous backend finish closing before its replacement launches.
await disposing;
const promise = initializeServer(server, factory, transportInitialized).then(backend => {
backend.once('disconnected', () => {
backend.once('disconnected', disconnectNotice => {
if (backendPromise === promise)
backendPromise = undefined;
void backend.dispose?.().catch(serverDebug);
pendingNotice ??= disconnectNotice;
disposing = backend.dispose?.().catch(serverDebug);
});
if (runHeartbeat && !heartbeatStarted) {
heartbeatStarted = true;
Expand All @@ -101,16 +109,19 @@ export function createServer(name: string, version: string, factory: ServerBacke
}

const backend = await backendPromise;
const toolResult = await backend.callTool(request.params.name, request.params.arguments || {}, extra.signal);
const mergedResult = mergeTextParts(toolResult);
serverDebugResponse('callResult', mergedResult);
return mergedResult;
// Delivered once, by whichever call first reaches the replacement backend.
notice = pendingNotice;
pendingNotice = undefined;
result = await backend.callTool(request.params.name, request.params.arguments || {}, extra.signal);
} catch (error) {
return {
result = {
content: [{ type: 'text', text: '### Error\n' + String(error) }],
isError: true,
};
}
const mergedResult = mergeTextParts(prependText(result, notice));
serverDebugResponse('callResult', mergedResult);
return mergedResult;
});
return server;
}
Expand Down Expand Up @@ -227,6 +238,12 @@ export function allRootPaths(roots: Root[]): string[] {
return paths;
}

function prependText(result: CallToolResult, text: string | undefined): CallToolResult {
if (!text)
return result;
return { ...result, content: [{ type: 'text', text }, ...result.content] };
}

function mergeTextParts(result: CallToolResult): CallToolResult {
const content: CallToolResult['content'] = [];
const testParts: string[] = [];
Expand Down
13 changes: 12 additions & 1 deletion tests/mcp/config-resolve.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -455,10 +455,21 @@ test.describe('resolveCLIConfigForMCP', () => {
});

test('cli timeout overrides defaults', async () => {
const config = await resolveCLIConfigForMCP({ timeoutAction: 10000, timeoutNavigation: 30000 }, emptyEnv);
const config = await resolveCLIConfigForMCP({ timeoutAction: 10000, timeoutNavigation: 30000, timeoutIdle: 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) => {
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);
});

test('cli timeout overrides config file timeout', async ({}, testInfo) => {
Expand Down
Loading
Loading