diff --git a/index.d.ts b/index.d.ts index f66ca71..551ed55 100644 --- a/index.d.ts +++ b/index.d.ts @@ -18,6 +18,7 @@ import type { Server } from '@modelcontextprotocol/sdk/server/index.js'; import type { Config } from './config'; import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; +import type { BrowserContext } from 'playwright'; export type Connection = { server: Server; @@ -25,5 +26,5 @@ export type Connection = { close(): Promise; }; -export declare function createConnection(config?: Config): Promise; +export declare function createConnection(config?: Config, contextGetter?: () => Promise): Promise; export {}; diff --git a/package-lock.json b/package-lock.json index d37fe65..5ba8968 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.11.0", "commander": "^13.1.0", + "debug": "^4.4.1", "playwright": "1.53.0-alpha-2025-05-27", "zod-to-json-schema": "^3.24.4" }, @@ -22,6 +23,7 @@ "@eslint/js": "^9.19.0", "@playwright/test": "1.53.0-alpha-2025-05-27", "@stylistic/eslint-plugin": "^3.0.1", + "@types/debug": "^4.1.12", "@types/node": "^22.13.10", "@typescript-eslint/eslint-plugin": "^8.26.1", "@typescript-eslint/parser": "^8.26.1", @@ -354,6 +356,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", @@ -375,6 +387,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.13.10", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.10.tgz", @@ -853,29 +872,6 @@ "node": ">=18" } }, - "node_modules/body-parser/node_modules/debug": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/body-parser/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, "node_modules/body-parser/node_modules/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", @@ -1156,12 +1152,12 @@ } }, "node_modules/debug": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", - "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", "license": "MIT", "dependencies": { - "ms": "2.1.2" + "ms": "^2.1.3" }, "engines": { "node": ">=6.0" @@ -1826,6 +1822,29 @@ "express": "^4.11 || 5 || ^5.0.0-beta.1" } }, + "node_modules/express/node_modules/debug": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", + "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", + "license": "MIT", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -1930,29 +1949,6 @@ "node": ">= 0.8" } }, - "node_modules/finalhandler/node_modules/debug": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/finalhandler/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, "node_modules/find-root": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", @@ -3003,9 +2999,9 @@ } }, "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, "node_modules/natural-compare": { @@ -3713,12 +3709,6 @@ "node": ">= 0.6" } }, - "node_modules/send/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, "node_modules/serve-static": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.1.0.tgz", diff --git a/package.json b/package.json index 5285340..57c4b7f 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.11.0", "commander": "^13.1.0", + "debug": "^4.4.1", "playwright": "1.53.0-alpha-2025-05-27", "zod-to-json-schema": "^3.24.4" }, @@ -45,6 +46,7 @@ "@eslint/js": "^9.19.0", "@playwright/test": "1.53.0-alpha-2025-05-27", "@stylistic/eslint-plugin": "^3.0.1", + "@types/debug": "^4.1.12", "@types/node": "^22.13.10", "@typescript-eslint/eslint-plugin": "^8.26.1", "@typescript-eslint/parser": "^8.26.1", diff --git a/src/browserContextFactory.ts b/src/browserContextFactory.ts new file mode 100644 index 0000000..5c35fc1 --- /dev/null +++ b/src/browserContextFactory.ts @@ -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 }>; +} + +class BaseContextFactory implements BrowserContextFactory { + readonly browserConfig: FullConfig['browser']; + protected _browserPromise: Promise | undefined; + readonly name: string; + + constructor(name: string, browserConfig: FullConfig['browser']) { + this.name = name; + this.browserConfig = browserConfig; + } + + protected async _obtainBrowser(): Promise { + 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 { + throw new Error('Not implemented'); + } + + async createContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise }> { + 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 { + 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 { + 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 { + return browser.newContext(this.browserConfig.contextOptions); + } +} + +class CdpContextFactory extends BaseContextFactory { + constructor(browserConfig: FullConfig['browser']) { + super('cdp', browserConfig); + } + + protected override async _doObtainBrowser(): Promise { + return playwright.chromium.connectOverCDP(this.browserConfig.cdpEndpoint!); + } + + protected override async _doCreateContext(browser: playwright.Browser): Promise { + 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 { + 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 { + return browser.newContext(); + } +} + +class PersistentContextFactory implements BrowserContextFactory { + readonly browserConfig: FullConfig['browser']; + private _userDataDirs = new Set(); + + constructor(browserConfig: FullConfig['browser']) { + this.browserConfig = browserConfig; + } + + async createContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise }> { + 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; + } +} diff --git a/src/config.ts b/src/config.ts index 047fdc0..e550516 100644 --- a/src/config.ts +++ b/src/config.ts @@ -74,8 +74,8 @@ const defaultConfig: FullConfig = { type BrowserUserConfig = NonNullable; export type FullConfig = Config & { - browser: BrowserUserConfig & { - browserName: NonNullable; + browser: Omit & { + browserName: 'chromium' | 'firefox' | 'webkit'; launchOptions: NonNullable; contextOptions: NonNullable; }, diff --git a/src/connection.ts b/src/connection.ts index 8e49506..1c931f8 100644 --- a/src/connection.ts +++ b/src/connection.ts @@ -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: {}, diff --git a/src/context.ts b/src/context.ts index 01bd537..fda43f9 100644 --- a/src/context.ts +++ b/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; }; -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 | undefined; + private _browserContextPromise: Promise<{ browserContext: playwright.BrowserContext, close: () => Promise }> | 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 { - const { browser, browserContext } = await this._createBrowserContext(); + private async _setupBrowserContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise }> { + // 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 { - 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 { - 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 { - 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; -} diff --git a/src/index.ts b/src/index.ts index 2542616..3c865be 100644 --- a/src/index.ts +++ b/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 { +export async function createConnection(userConfig: Config = {}, contextGetter?: () => Promise): Promise { 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; + + constructor(contextGetter: () => Promise) { + this._contextGetter = contextGetter; + } + + async createContext(): Promise<{ browserContext: BrowserContext, close: () => Promise }> { + const browserContext = await this._contextGetter(); + return { + browserContext, + close: () => browserContext.close() + }; + } } diff --git a/src/server.ts b/src/server.ts index b5cce8b..b298099 100644 --- a/src/server.ts +++ b/src/server.ts @@ -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 { - const connection = createConnection(this.config); + const connection = createConnection(this.config, this._contextFactory); this._connectionList.push(connection); await connection.server.connect(transport); return connection; diff --git a/src/transport.ts b/src/transport.ts index 6598e5a..32a94c3 100644 --- a/src/transport.ts +++ b/src/transport.ts @@ -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) { 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)); diff --git a/tests/sse.spec.ts b/tests/sse.spec.ts index f1c3aa7..a164327 100644 --- a/tests/sse.spec.ts +++ b/tests/sse.spec.ts @@ -15,7 +15,7 @@ */ import url from 'node:url'; -import { spawn } from 'node:child_process'; +import { ChildProcess, spawn } from 'node:child_process'; import path from 'node:path'; import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; @@ -26,35 +26,200 @@ import { test as baseTest, expect } from './fixtures.js'; // NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename. const __filename = url.fileURLToPath(import.meta.url); -const test = baseTest.extend<{ serverEndpoint: string }>({ - serverEndpoint: async ({}, use) => { - const cp = spawn('node', [path.join(path.dirname(__filename), '../cli.js'), '--port', '0'], { stdio: 'pipe' }); - try { +const test = baseTest.extend<{ serverEndpoint: (args?: string[]) => Promise<{ url: URL, stderr: () => string }> }>({ + serverEndpoint: async ({ mcpHeadless }, use, testInfo) => { + let cp: ChildProcess | undefined; + const userDataDir = testInfo.outputPath('user-data-dir'); + await use(async (args?: string[]) => { + if (cp) + throw new Error('Process already running'); + + cp = spawn('node', [ + path.join(path.dirname(__filename), '../cli.js'), + '--port=0', + '--user-data-dir=' + userDataDir, + ...(mcpHeadless ? ['--headless'] : []), + ...(args || []), + ], { + stdio: 'pipe', + env: { + ...process.env, + DEBUG: 'pw:mcp:test', + DEBUG_COLORS: '0', + DEBUG_HIDE_DATE: '1', + }, + }); let stderr = ''; - const url = await new Promise(resolve => cp.stderr?.on('data', data => { + const url = await new Promise(resolve => cp!.stderr?.on('data', data => { stderr += data.toString(); const match = stderr.match(/Listening on (http:\/\/.*)/); if (match) resolve(match[1]); })); - await use(url); - } finally { - cp.kill(); - } + return { url: new URL(url), stderr: () => stderr }; + }); + cp?.kill('SIGTERM'); }, }); test('sse transport', async ({ serverEndpoint }) => { - const transport = new SSEClientTransport(new URL(serverEndpoint)); + const { url } = await serverEndpoint(); + const transport = new SSEClientTransport(url); const client = new Client({ name: 'test', version: '1.0.0' }); await client.connect(transport); await client.ping(); - await client.close(); +}); + +test('sse transport browser lifecycle (isolated)', async ({ serverEndpoint, server }) => { + const { url, stderr } = await serverEndpoint(['--isolated']); + + const transport1 = new SSEClientTransport(url); + const client1 = new Client({ name: 'test', version: '1.0.0' }); + await client1.connect(transport1); + await client1.callTool({ + name: 'browser_navigate', + arguments: { url: server.HELLO_WORLD }, + }); + await client1.close(); + + const transport2 = new SSEClientTransport(url); + const client2 = new Client({ name: 'test', version: '1.0.0' }); + await client2.connect(transport2); + await client2.callTool({ + name: 'browser_navigate', + arguments: { url: server.HELLO_WORLD }, + }); + await client2.close(); + + await expect(async () => { + const lines = stderr().split('\n'); + expect(lines.filter(line => line.match(/create SSE session/)).length).toBe(2); + expect(lines.filter(line => line.match(/delete SSE session/)).length).toBe(2); + + expect(lines.filter(line => line.match(/create context/)).length).toBe(2); + expect(lines.filter(line => line.match(/close context/)).length).toBe(2); + + expect(lines.filter(line => line.match(/create browser context \(isolated\)/)).length).toBe(2); + expect(lines.filter(line => line.match(/close browser context \(isolated\)/)).length).toBe(2); + + expect(lines.filter(line => line.match(/obtain browser \(isolated\)/)).length).toBe(2); + expect(lines.filter(line => line.match(/close browser \(isolated\)/)).length).toBe(2); + }).toPass(); +}); + +test('sse transport browser lifecycle (isolated, multiclient)', async ({ serverEndpoint, server }) => { + const { url, stderr } = await serverEndpoint(['--isolated']); + + const transport1 = new SSEClientTransport(url); + const client1 = new Client({ name: 'test', version: '1.0.0' }); + await client1.connect(transport1); + await client1.callTool({ + name: 'browser_navigate', + arguments: { url: server.HELLO_WORLD }, + }); + + const transport2 = new SSEClientTransport(url); + const client2 = new Client({ name: 'test', version: '1.0.0' }); + await client2.connect(transport2); + await client2.callTool({ + name: 'browser_navigate', + arguments: { url: server.HELLO_WORLD }, + }); + await client1.close(); + + const transport3 = new SSEClientTransport(url); + const client3 = new Client({ name: 'test', version: '1.0.0' }); + await client3.connect(transport3); + await client3.callTool({ + name: 'browser_navigate', + arguments: { url: server.HELLO_WORLD }, + }); + + await client2.close(); + await client3.close(); + + await expect(async () => { + const lines = stderr().split('\n'); + expect(lines.filter(line => line.match(/create SSE session/)).length).toBe(3); + expect(lines.filter(line => line.match(/delete SSE session/)).length).toBe(3); + + expect(lines.filter(line => line.match(/create context/)).length).toBe(3); + expect(lines.filter(line => line.match(/close context/)).length).toBe(3); + + expect(lines.filter(line => line.match(/create browser context \(isolated\)/)).length).toBe(3); + expect(lines.filter(line => line.match(/close browser context \(isolated\)/)).length).toBe(3); + + expect(lines.filter(line => line.match(/obtain browser \(isolated\)/)).length).toBe(1); + expect(lines.filter(line => line.match(/close browser \(isolated\)/)).length).toBe(1); + }).toPass(); +}); + +test('sse transport browser lifecycle (persistent)', async ({ serverEndpoint, server }) => { + const { url, stderr } = await serverEndpoint(); + + const transport1 = new SSEClientTransport(url); + const client1 = new Client({ name: 'test', version: '1.0.0' }); + await client1.connect(transport1); + await client1.callTool({ + name: 'browser_navigate', + arguments: { url: server.HELLO_WORLD }, + }); + await client1.close(); + + const transport2 = new SSEClientTransport(url); + const client2 = new Client({ name: 'test', version: '1.0.0' }); + await client2.connect(transport2); + await client2.callTool({ + name: 'browser_navigate', + arguments: { url: server.HELLO_WORLD }, + }); + await client2.close(); + + await expect(async () => { + const lines = stderr().split('\n'); + expect(lines.filter(line => line.match(/create SSE session/)).length).toBe(2); + expect(lines.filter(line => line.match(/delete SSE session/)).length).toBe(2); + + expect(lines.filter(line => line.match(/create context/)).length).toBe(2); + expect(lines.filter(line => line.match(/close context/)).length).toBe(2); + + expect(lines.filter(line => line.match(/create browser context \(persistent\)/)).length).toBe(2); + expect(lines.filter(line => line.match(/close browser context \(persistent\)/)).length).toBe(2); + + expect(lines.filter(line => line.match(/lock user data dir/)).length).toBe(2); + expect(lines.filter(line => line.match(/release user data dir/)).length).toBe(2); + }).toPass(); +}); + +test('sse transport browser lifecycle (persistent, multiclient)', async ({ serverEndpoint, server }) => { + const { url } = await serverEndpoint(); + + const transport1 = new SSEClientTransport(url); + const client1 = new Client({ name: 'test', version: '1.0.0' }); + await client1.connect(transport1); + await client1.callTool({ + name: 'browser_navigate', + arguments: { url: server.HELLO_WORLD }, + }); + + const transport2 = new SSEClientTransport(url); + const client2 = new Client({ name: 'test', version: '1.0.0' }); + await client2.connect(transport2); + const response = await client2.callTool({ + name: 'browser_navigate', + arguments: { url: server.HELLO_WORLD }, + }); + expect(response.isError).toBe(true); + expect(response.content?.[0].text).toContain('use --isolated to run multiple instances of the same browser'); + + await client1.close(); + await client2.close(); }); test('streamable http transport', async ({ serverEndpoint }) => { - const transport = new StreamableHTTPClientTransport(new URL('/mcp', serverEndpoint)); + const { url } = await serverEndpoint(); + const transport = new StreamableHTTPClientTransport(new URL('/mcp', url)); const client = new Client({ name: 'test', version: '1.0.0' }); await client.connect(transport); await client.ping();