mirror of
https://github.com/microsoft/playwright-mcp.git
synced 2026-02-02 20:43:39 +00:00
chore: reuse browser in server mode (#495)
This commit is contained in:
202
src/browserContextFactory.ts
Normal file
202
src/browserContextFactory.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import debug from 'debug';
|
||||
import * as playwright from 'playwright';
|
||||
|
||||
import type { FullConfig } from './config.js';
|
||||
|
||||
const testDebug = debug('pw:mcp:test');
|
||||
|
||||
export function contextFactory(browserConfig: FullConfig['browser']): BrowserContextFactory {
|
||||
if (browserConfig.remoteEndpoint)
|
||||
return new RemoteContextFactory(browserConfig);
|
||||
if (browserConfig.cdpEndpoint)
|
||||
return new CdpContextFactory(browserConfig);
|
||||
if (browserConfig.isolated)
|
||||
return new IsolatedContextFactory(browserConfig);
|
||||
return new PersistentContextFactory(browserConfig);
|
||||
}
|
||||
|
||||
export interface BrowserContextFactory {
|
||||
createContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }>;
|
||||
}
|
||||
|
||||
class BaseContextFactory implements BrowserContextFactory {
|
||||
readonly browserConfig: FullConfig['browser'];
|
||||
protected _browserPromise: Promise<playwright.Browser> | undefined;
|
||||
readonly name: string;
|
||||
|
||||
constructor(name: string, browserConfig: FullConfig['browser']) {
|
||||
this.name = name;
|
||||
this.browserConfig = browserConfig;
|
||||
}
|
||||
|
||||
protected async _obtainBrowser(): Promise<playwright.Browser> {
|
||||
if (this._browserPromise)
|
||||
return this._browserPromise;
|
||||
testDebug(`obtain browser (${this.name})`);
|
||||
this._browserPromise = this._doObtainBrowser();
|
||||
void this._browserPromise.then(browser => {
|
||||
browser.on('disconnected', () => {
|
||||
this._browserPromise = undefined;
|
||||
});
|
||||
}).catch(() => {
|
||||
this._browserPromise = undefined;
|
||||
});
|
||||
return this._browserPromise;
|
||||
}
|
||||
|
||||
protected async _doObtainBrowser(): Promise<playwright.Browser> {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
|
||||
async createContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
|
||||
testDebug(`create browser context (${this.name})`);
|
||||
const browser = await this._obtainBrowser();
|
||||
const browserContext = await this._doCreateContext(browser);
|
||||
return { browserContext, close: () => this._closeBrowserContext(browserContext, browser) };
|
||||
}
|
||||
|
||||
protected async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
|
||||
private async _closeBrowserContext(browserContext: playwright.BrowserContext, browser: playwright.Browser) {
|
||||
testDebug(`close browser context (${this.name})`);
|
||||
if (browser.contexts().length === 1)
|
||||
this._browserPromise = undefined;
|
||||
await browserContext.close().catch(() => {});
|
||||
if (browser.contexts().length === 0) {
|
||||
testDebug(`close browser (${this.name})`);
|
||||
await browser.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class IsolatedContextFactory extends BaseContextFactory {
|
||||
constructor(browserConfig: FullConfig['browser']) {
|
||||
super('isolated', browserConfig);
|
||||
}
|
||||
|
||||
protected override async _doObtainBrowser(): Promise<playwright.Browser> {
|
||||
const browserType = playwright[this.browserConfig.browserName];
|
||||
return browserType.launch(this.browserConfig.launchOptions).catch(error => {
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
protected override async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
|
||||
return browser.newContext(this.browserConfig.contextOptions);
|
||||
}
|
||||
}
|
||||
|
||||
class CdpContextFactory extends BaseContextFactory {
|
||||
constructor(browserConfig: FullConfig['browser']) {
|
||||
super('cdp', browserConfig);
|
||||
}
|
||||
|
||||
protected override async _doObtainBrowser(): Promise<playwright.Browser> {
|
||||
return playwright.chromium.connectOverCDP(this.browserConfig.cdpEndpoint!);
|
||||
}
|
||||
|
||||
protected override async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
|
||||
return this.browserConfig.isolated ? await browser.newContext() : browser.contexts()[0];
|
||||
}
|
||||
}
|
||||
|
||||
class RemoteContextFactory extends BaseContextFactory {
|
||||
constructor(browserConfig: FullConfig['browser']) {
|
||||
super('remote', browserConfig);
|
||||
}
|
||||
|
||||
protected override async _doObtainBrowser(): Promise<playwright.Browser> {
|
||||
const url = new URL(this.browserConfig.remoteEndpoint!);
|
||||
url.searchParams.set('browser', this.browserConfig.browserName);
|
||||
if (this.browserConfig.launchOptions)
|
||||
url.searchParams.set('launch-options', JSON.stringify(this.browserConfig.launchOptions));
|
||||
return playwright[this.browserConfig.browserName].connect(String(url));
|
||||
}
|
||||
|
||||
protected override async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
|
||||
return browser.newContext();
|
||||
}
|
||||
}
|
||||
|
||||
class PersistentContextFactory implements BrowserContextFactory {
|
||||
readonly browserConfig: FullConfig['browser'];
|
||||
private _userDataDirs = new Set<string>();
|
||||
|
||||
constructor(browserConfig: FullConfig['browser']) {
|
||||
this.browserConfig = browserConfig;
|
||||
}
|
||||
|
||||
async createContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
|
||||
testDebug('create browser context (persistent)');
|
||||
const userDataDir = this.browserConfig.userDataDir ?? await this._createUserDataDir();
|
||||
|
||||
this._userDataDirs.add(userDataDir);
|
||||
testDebug('lock user data dir', userDataDir);
|
||||
|
||||
const browserType = playwright[this.browserConfig.browserName];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
try {
|
||||
const browserContext = await browserType.launchPersistentContext(userDataDir, { ...this.browserConfig.launchOptions, ...this.browserConfig.contextOptions });
|
||||
const close = () => this._closeBrowserContext(browserContext, userDataDir);
|
||||
return { browserContext, close };
|
||||
} 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.`);
|
||||
if (error.message.includes('ProcessSingleton') || error.message.includes('Invalid URL')) {
|
||||
// User data directory is already in use, try again.
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
throw new Error(`Browser is already in use for ${userDataDir}, use --isolated to run multiple instances of the same browser`);
|
||||
}
|
||||
|
||||
private async _closeBrowserContext(browserContext: playwright.BrowserContext, userDataDir: string) {
|
||||
testDebug('close browser context (persistent)');
|
||||
testDebug('release user data dir', userDataDir);
|
||||
await browserContext.close().catch(() => {});
|
||||
this._userDataDirs.delete(userDataDir);
|
||||
testDebug('close browser context complete (persistent)');
|
||||
}
|
||||
|
||||
private async _createUserDataDir() {
|
||||
let cacheDirectory: string;
|
||||
if (process.platform === 'linux')
|
||||
cacheDirectory = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
|
||||
else if (process.platform === 'darwin')
|
||||
cacheDirectory = path.join(os.homedir(), 'Library', 'Caches');
|
||||
else if (process.platform === 'win32')
|
||||
cacheDirectory = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
|
||||
else
|
||||
throw new Error('Unsupported platform: ' + process.platform);
|
||||
const result = path.join(cacheDirectory, 'ms-playwright', `mcp-${this.browserConfig.launchOptions?.channel ?? this.browserConfig?.browserName}-profile`);
|
||||
await fs.promises.mkdir(result, { recursive: true });
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -74,8 +74,8 @@ const defaultConfig: FullConfig = {
|
||||
type BrowserUserConfig = NonNullable<Config['browser']>;
|
||||
|
||||
export type FullConfig = Config & {
|
||||
browser: BrowserUserConfig & {
|
||||
browserName: NonNullable<BrowserUserConfig['browserName']>;
|
||||
browser: Omit<BrowserUserConfig, 'browserName'> & {
|
||||
browserName: 'chromium' | 'firefox' | 'webkit';
|
||||
launchOptions: NonNullable<BrowserUserConfig['launchOptions']>;
|
||||
contextOptions: NonNullable<BrowserUserConfig['contextOptions']>;
|
||||
},
|
||||
|
||||
@@ -24,11 +24,13 @@ import { packageJSON } from './package.js';
|
||||
|
||||
import { FullConfig } from './config.js';
|
||||
|
||||
export function createConnection(config: FullConfig): Connection {
|
||||
import type { BrowserContextFactory } from './browserContextFactory.js';
|
||||
|
||||
export function createConnection(config: FullConfig, browserContextFactory: BrowserContextFactory): Connection {
|
||||
const allTools = config.vision ? visionTools : snapshotTools;
|
||||
const tools = allTools.filter(tool => !config.capabilities || tool.capability === 'core' || config.capabilities.includes(tool.capability));
|
||||
|
||||
const context = new Context(tools, config);
|
||||
const context = new Context(tools, config, browserContextFactory);
|
||||
const server = new McpServer({ name: 'Playwright', version: packageJSON.version }, {
|
||||
capabilities: {
|
||||
tools: {},
|
||||
|
||||
100
src/context.ts
100
src/context.ts
@@ -14,10 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import debug from 'debug';
|
||||
import * as playwright from 'playwright';
|
||||
|
||||
import { callOnPageNoTrace, waitForCompletion } from './tools/utils.js';
|
||||
@@ -28,20 +25,19 @@ import { outputFile } from './config.js';
|
||||
import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js';
|
||||
import type { ModalState, Tool, ToolActionResult } from './tools/tool.js';
|
||||
import type { FullConfig } from './config.js';
|
||||
import type { BrowserContextFactory } from './browserContextFactory.js';
|
||||
|
||||
type PendingAction = {
|
||||
dialogShown: ManualPromise<void>;
|
||||
};
|
||||
|
||||
type BrowserContextAndBrowser = {
|
||||
browser?: playwright.Browser;
|
||||
browserContext: playwright.BrowserContext;
|
||||
};
|
||||
const testDebug = debug('pw:mcp:test');
|
||||
|
||||
export class Context {
|
||||
readonly tools: Tool[];
|
||||
readonly config: FullConfig;
|
||||
private _browserContextPromise: Promise<BrowserContextAndBrowser> | undefined;
|
||||
private _browserContextPromise: Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> | undefined;
|
||||
private _browserContextFactory: BrowserContextFactory;
|
||||
private _tabs: Tab[] = [];
|
||||
private _currentTab: Tab | undefined;
|
||||
private _modalStates: (ModalState & { tab: Tab })[] = [];
|
||||
@@ -49,9 +45,11 @@ export class Context {
|
||||
private _downloads: { download: playwright.Download, finished: boolean, outputFile: string }[] = [];
|
||||
clientVersion: { name: string; version: string; } | undefined;
|
||||
|
||||
constructor(tools: Tool[], config: FullConfig) {
|
||||
constructor(tools: Tool[], config: FullConfig, browserContextFactory: BrowserContextFactory) {
|
||||
this.tools = tools;
|
||||
this.config = config;
|
||||
this._browserContextFactory = browserContextFactory;
|
||||
testDebug('create context');
|
||||
}
|
||||
|
||||
clientSupportsImages(): boolean {
|
||||
@@ -296,15 +294,15 @@ ${code.join('\n')}
|
||||
if (!this._browserContextPromise)
|
||||
return;
|
||||
|
||||
testDebug('close context');
|
||||
|
||||
const promise = this._browserContextPromise;
|
||||
this._browserContextPromise = undefined;
|
||||
|
||||
await promise.then(async ({ browserContext, browser }) => {
|
||||
await promise.then(async ({ browserContext, close }) => {
|
||||
if (this.config.saveTrace)
|
||||
await browserContext.tracing.stop();
|
||||
await browserContext.close().then(async () => {
|
||||
await browser?.close();
|
||||
}).catch(() => {});
|
||||
await close();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -332,8 +330,10 @@ ${code.join('\n')}
|
||||
return this._browserContextPromise;
|
||||
}
|
||||
|
||||
private async _setupBrowserContext(): Promise<BrowserContextAndBrowser> {
|
||||
const { browser, browserContext } = await this._createBrowserContext();
|
||||
private async _setupBrowserContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
|
||||
// TODO: move to the browser context factory to make it based on isolation mode.
|
||||
const result = await this._browserContextFactory.createContext();
|
||||
const { browserContext } = result;
|
||||
await this._setupRequestInterception(browserContext);
|
||||
for (const page of browserContext.pages())
|
||||
this._onPageCreated(page);
|
||||
@@ -346,72 +346,6 @@ ${code.join('\n')}
|
||||
sources: false,
|
||||
});
|
||||
}
|
||||
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)
|
||||
url.searchParams.set('browser', this.config.browser.browserName);
|
||||
if (this.config.browser.launchOptions)
|
||||
url.searchParams.set('launch-options', JSON.stringify(this.config.browser.launchOptions));
|
||||
const browser = await playwright[this.config.browser?.browserName ?? 'chromium'].connect(String(url));
|
||||
const browserContext = await browser.newContext();
|
||||
return { browser, browserContext };
|
||||
}
|
||||
|
||||
if (this.config.browser?.cdpEndpoint) {
|
||||
const browser = await playwright.chromium.connectOverCDP(this.config.browser.cdpEndpoint);
|
||||
const browserContext = this.config.browser.isolated ? await browser.newContext() : browser.contexts()[0];
|
||||
return { browser, browserContext };
|
||||
}
|
||||
|
||||
return this.config.browser?.isolated ?
|
||||
await createIsolatedContext(this.config.browser) :
|
||||
await launchPersistentContext(this.config.browser);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
async function createIsolatedContext(browserConfig: FullConfig['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(browserConfig.contextOptions);
|
||||
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: FullConfig['browser']): Promise<BrowserContextAndBrowser> {
|
||||
try {
|
||||
const browserName = browserConfig.browserName ?? 'chromium';
|
||||
const userDataDir = browserConfig.userDataDir ?? await createUserDataDir({ ...browserConfig, browserName });
|
||||
const browserType = playwright[browserName];
|
||||
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.`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function createUserDataDir(browserConfig: FullConfig['browser']) {
|
||||
let cacheDirectory: string;
|
||||
if (process.platform === 'linux')
|
||||
cacheDirectory = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
|
||||
else if (process.platform === 'darwin')
|
||||
cacheDirectory = path.join(os.homedir(), 'Library', 'Caches');
|
||||
else if (process.platform === 'win32')
|
||||
cacheDirectory = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
|
||||
else
|
||||
throw new Error('Unsupported platform: ' + process.platform);
|
||||
const result = path.join(cacheDirectory, 'ms-playwright', `mcp-${browserConfig.launchOptions?.channel ?? browserConfig?.browserName}-profile`);
|
||||
await fs.promises.mkdir(result, { recursive: true });
|
||||
return result;
|
||||
}
|
||||
|
||||
24
src/index.ts
24
src/index.ts
@@ -16,10 +16,30 @@
|
||||
|
||||
import { Connection, createConnection as createConnectionImpl } from './connection.js';
|
||||
import { resolveConfig } from './config.js';
|
||||
import { contextFactory } from './browserContextFactory.js';
|
||||
|
||||
import type { Config } from '../config.js';
|
||||
import type { BrowserContext } from 'playwright';
|
||||
import type { BrowserContextFactory } from './browserContextFactory.js';
|
||||
|
||||
export async function createConnection(userConfig: Config = {}): Promise<Connection> {
|
||||
export async function createConnection(userConfig: Config = {}, contextGetter?: () => Promise<BrowserContext>): Promise<Connection> {
|
||||
const config = await resolveConfig(userConfig);
|
||||
return createConnectionImpl(config);
|
||||
const factory = contextGetter ? new SimpleBrowserContextFactory(contextGetter) : contextFactory(config.browser);
|
||||
return createConnectionImpl(config, factory);
|
||||
}
|
||||
|
||||
class SimpleBrowserContextFactory implements BrowserContextFactory {
|
||||
private readonly _contextGetter: () => Promise<BrowserContext>;
|
||||
|
||||
constructor(contextGetter: () => Promise<BrowserContext>) {
|
||||
this._contextGetter = contextGetter;
|
||||
}
|
||||
|
||||
async createContext(): Promise<{ browserContext: BrowserContext, close: () => Promise<void> }> {
|
||||
const browserContext = await this._contextGetter();
|
||||
return {
|
||||
browserContext,
|
||||
close: () => browserContext.close()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,21 +15,27 @@
|
||||
*/
|
||||
|
||||
import { createConnection } from './connection.js';
|
||||
import { contextFactory } from './browserContextFactory.js';
|
||||
|
||||
import type { FullConfig } from './config.js';
|
||||
import type { Connection } from './connection.js';
|
||||
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||
import type { BrowserContextFactory } from './browserContextFactory.js';
|
||||
|
||||
export class Server {
|
||||
readonly config: FullConfig;
|
||||
private _connectionList: Connection[] = [];
|
||||
private _browserConfig: FullConfig['browser'];
|
||||
private _contextFactory: BrowserContextFactory;
|
||||
|
||||
constructor(config: FullConfig) {
|
||||
this.config = config;
|
||||
this._browserConfig = config.browser;
|
||||
this._contextFactory = contextFactory(this._browserConfig);
|
||||
}
|
||||
|
||||
async createConnection(transport: Transport): Promise<Connection> {
|
||||
const connection = createConnection(this.config);
|
||||
const connection = createConnection(this.config, this._contextFactory);
|
||||
this._connectionList.push(connection);
|
||||
await connection.server.connect(transport);
|
||||
return connection;
|
||||
|
||||
@@ -18,6 +18,7 @@ import http from 'node:http';
|
||||
import assert from 'node:assert';
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
import debug from 'debug';
|
||||
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
@@ -28,6 +29,8 @@ export async function startStdioTransport(server: Server) {
|
||||
await server.createConnection(new StdioServerTransport());
|
||||
}
|
||||
|
||||
const testDebug = debug('pw:mcp:test');
|
||||
|
||||
async function handleSSE(server: Server, req: http.IncomingMessage, res: http.ServerResponse, url: URL, sessions: Map<string, SSEServerTransport>) {
|
||||
if (req.method === 'POST') {
|
||||
const sessionId = url.searchParams.get('sessionId');
|
||||
@@ -46,8 +49,10 @@ async function handleSSE(server: Server, req: http.IncomingMessage, res: http.Se
|
||||
} else if (req.method === 'GET') {
|
||||
const transport = new SSEServerTransport('/sse', res);
|
||||
sessions.set(transport.sessionId, transport);
|
||||
testDebug(`create SSE session: ${transport.sessionId}`);
|
||||
const connection = await server.createConnection(transport);
|
||||
res.on('close', () => {
|
||||
testDebug(`delete SSE session: ${transport.sessionId}`);
|
||||
sessions.delete(transport.sessionId);
|
||||
// eslint-disable-next-line no-console
|
||||
void connection.close().catch(e => console.error(e));
|
||||
|
||||
Reference in New Issue
Block a user