diff --git a/README.md b/README.md index f5e153b..6932df1 100644 --- a/README.md +++ b/README.md @@ -192,7 +192,6 @@ Playwright MCP server supports following arguments. They can be provided in the --block-service-workers block service workers --browser browser or chrome channel to use, possible values: chrome, firefox, webkit, msedge. - --browser-agent Use browser agent (experimental). --caps comma-separated list of additional capabilities to enable, possible values: vision, pdf. --cdp-endpoint CDP endpoint to connect to. diff --git a/config.d.ts b/config.d.ts index c36d5fe..7a654f9 100644 --- a/config.d.ts +++ b/config.d.ts @@ -23,11 +23,6 @@ export type Config = { * The browser to use. */ browser?: { - /** - * Use browser agent (experimental). - */ - browserAgent?: string; - /** * The type of browser to use. */ diff --git a/src/browserContextFactory.ts b/src/browserContextFactory.ts index f14cd7d..d8ee2a7 100644 --- a/src/browserContextFactory.ts +++ b/src/browserContextFactory.ts @@ -21,10 +21,8 @@ import os from 'node:os'; import debug from 'debug'; import * as playwright from 'playwright'; -import { userDataDir } from './fileUtils.js'; import type { FullConfig } from './config.js'; -import type { BrowserInfo, LaunchBrowserRequest } from './browserServer.js'; const testDebug = debug('pw:mcp:test'); @@ -35,8 +33,6 @@ export function contextFactory(browserConfig: FullConfig['browser']): BrowserCon return new CdpContextFactory(browserConfig); if (browserConfig.isolated) return new IsolatedContextFactory(browserConfig); - if (browserConfig.browserAgent) - return new BrowserServerContextFactory(browserConfig); return new PersistentContextFactory(browserConfig); } @@ -217,38 +213,6 @@ class PersistentContextFactory implements BrowserContextFactory { } } -export class BrowserServerContextFactory extends BaseContextFactory { - constructor(browserConfig: FullConfig['browser']) { - super('persistent', browserConfig); - } - - protected override async _doObtainBrowser(): Promise { - const response = await fetch(new URL(`/json/launch`, this.browserConfig.browserAgent), { - method: 'POST', - body: JSON.stringify({ - browserType: this.browserConfig.browserName, - userDataDir: this.browserConfig.userDataDir ?? await this._createUserDataDir(), - launchOptions: this.browserConfig.launchOptions, - contextOptions: this.browserConfig.contextOptions, - } as LaunchBrowserRequest), - }); - const info = await response.json() as BrowserInfo; - if (info.error) - throw new Error(info.error); - return await playwright.chromium.connectOverCDP(`http://localhost:${info.cdpPort}/`); - } - - protected override async _doCreateContext(browser: playwright.Browser): Promise { - return this.browserConfig.isolated ? await browser.newContext() : browser.contexts()[0]; - } - - private async _createUserDataDir() { - const dir = await userDataDir(this.browserConfig); - await fs.promises.mkdir(dir, { recursive: true }); - return dir; - } -} - async function injectCdpPort(browserConfig: FullConfig['browser']) { if (browserConfig.browserName === 'chromium') (browserConfig.launchOptions as any).cdpPort = await findFreePort(); diff --git a/src/browserServer.ts b/src/browserServer.ts deleted file mode 100644 index 85c908d..0000000 --- a/src/browserServer.ts +++ /dev/null @@ -1,197 +0,0 @@ -/** - * 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. - */ - -/* eslint-disable no-console */ - -import net from 'net'; - -import { program } from 'commander'; -import playwright from 'playwright'; - -import { HttpServer } from './httpServer.js'; -import { packageJSON } from './package.js'; - -import type http from 'http'; - -export type LaunchBrowserRequest = { - browserType: string; - userDataDir: string; - launchOptions: playwright.LaunchOptions; - contextOptions: playwright.BrowserContextOptions; -}; - -export type BrowserInfo = { - browserType: string; - userDataDir: string; - cdpPort: number; - launchOptions: playwright.LaunchOptions; - contextOptions: playwright.BrowserContextOptions; - error?: string; -}; - -type BrowserEntry = { - browser?: playwright.Browser; - info: BrowserInfo; -}; - -class BrowserServer { - private _server = new HttpServer(); - private _entries: BrowserEntry[] = []; - - constructor() { - this._setupExitHandler(); - } - - async start(port: number) { - await this._server.start({ port }); - this._server.routePath('/json/list', (req, res) => { - this._handleJsonList(res); - }); - this._server.routePath('/json/launch', async (req, res) => { - void this._handleLaunchBrowser(req, res).catch(e => console.error(e)); - }); - this._setEntries([]); - } - - private _handleJsonList(res: http.ServerResponse) { - const list = this._entries.map(browser => browser.info); - res.end(JSON.stringify(list)); - } - - private async _handleLaunchBrowser(req: http.IncomingMessage, res: http.ServerResponse) { - const request = await readBody(req); - let info = this._entries.map(entry => entry.info).find(info => info.userDataDir === request.userDataDir); - if (!info || info.error) - info = await this._newBrowser(request); - res.end(JSON.stringify(info)); - } - - private async _newBrowser(request: LaunchBrowserRequest): Promise { - const cdpPort = await findFreePort(); - (request.launchOptions as any).cdpPort = cdpPort; - const info: BrowserInfo = { - browserType: request.browserType, - userDataDir: request.userDataDir, - cdpPort, - launchOptions: request.launchOptions, - contextOptions: request.contextOptions, - }; - - const browserType = playwright[request.browserType as 'chromium' | 'firefox' | 'webkit']; - const { browser, error } = await browserType.launchPersistentContext(request.userDataDir, { - ...request.launchOptions, - ...request.contextOptions, - handleSIGINT: false, - handleSIGTERM: false, - }).then(context => { - return { browser: context.browser()!, error: undefined }; - }).catch(error => { - return { browser: undefined, error: error.message }; - }); - this._setEntries([...this._entries, { - browser, - info: { - browserType: request.browserType, - userDataDir: request.userDataDir, - cdpPort, - launchOptions: request.launchOptions, - contextOptions: request.contextOptions, - error, - }, - }]); - browser?.on('disconnected', () => { - this._setEntries(this._entries.filter(entry => entry.browser !== browser)); - }); - return info; - } - - private _updateReport() { - // Clear the current line and move cursor to top of screen - process.stdout.write('\x1b[2J\x1b[H'); - process.stdout.write(`Playwright Browser Server v${packageJSON.version}\n`); - process.stdout.write(`Listening on ${this._server.urlPrefix('human-readable')}\n\n`); - - if (this._entries.length === 0) { - process.stdout.write('No browsers currently running\n'); - return; - } - - process.stdout.write('Running browsers:\n'); - for (const entry of this._entries) { - const status = entry.browser ? 'running' : 'error'; - const statusColor = entry.browser ? '\x1b[32m' : '\x1b[31m'; // green for running, red for error - process.stdout.write(`${statusColor}${entry.info.browserType}\x1b[0m (${entry.info.userDataDir}) - ${statusColor}${status}\x1b[0m\n`); - if (entry.info.error) - process.stdout.write(` Error: ${entry.info.error}\n`); - } - - } - - private _setEntries(entries: BrowserEntry[]) { - this._entries = entries; - this._updateReport(); - } - - private _setupExitHandler() { - let isExiting = false; - const handleExit = async () => { - if (isExiting) - return; - isExiting = true; - setTimeout(() => process.exit(0), 15000); - for (const entry of this._entries) - await entry.browser?.close().catch(() => {}); - process.exit(0); - }; - - process.stdin.on('close', handleExit); - process.on('SIGINT', handleExit); - process.on('SIGTERM', handleExit); - } -} - -program - .name('browser-agent') - .option('-p, --port ', 'Port to listen on', '9224') - .action(async options => { - await main(options); - }); - -void program.parseAsync(process.argv); - -async function main(options: { port: string }) { - const server = new BrowserServer(); - await server.start(+options.port); -} - -function readBody(req: http.IncomingMessage): Promise { - return new Promise((resolve, reject) => { - const chunks: Buffer[] = []; - req.on('data', (chunk: Buffer) => chunks.push(chunk)); - req.on('end', () => resolve(JSON.parse(Buffer.concat(chunks).toString()))); - }); -} - -async function findFreePort(): Promise { - return new Promise((resolve, reject) => { - const server = net.createServer(); - server.listen(0, () => { - const { port } = server.address() as net.AddressInfo; - server.close(() => resolve(port)); - }); - server.on('error', reject); - }); -} diff --git a/src/config.ts b/src/config.ts index f9773da..a0b7685 100644 --- a/src/config.ts +++ b/src/config.ts @@ -28,7 +28,6 @@ export type CLIOptions = { blockedOrigins?: string[]; blockServiceWorkers?: boolean; browser?: string; - browserAgent?: string; caps?: string; cdpEndpoint?: string; config?: string; @@ -171,7 +170,6 @@ export async function configFromCLIOptions(cliOptions: CLIOptions): Promise', 'semicolon-separated list of origins to block the browser from requesting. Blocklist is evaluated before allowlist. If used without the allowlist, requests not matching the blocklist are still allowed.', semicolonSeparatedList) .option('--block-service-workers', 'block service workers') .option('--browser ', 'browser or chrome channel to use, possible values: chrome, firefox, webkit, msedge.') - .option('--browser-agent ', 'Use browser agent (experimental).') .option('--caps ', 'comma-separated list of additional capabilities to enable, possible values: vision, pdf.') .option('--cdp-endpoint ', 'CDP endpoint to connect to.') .option('--config ', 'path to the configuration file.') diff --git a/tests/browser-server.spec.ts b/tests/browser-server.spec.ts deleted file mode 100644 index 60e8f60..0000000 --- a/tests/browser-server.spec.ts +++ /dev/null @@ -1,77 +0,0 @@ -/** - * 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 path from 'path'; -import url from 'node:url'; - -import { spawn } from 'child_process'; -import { test as baseTest, expect } from './fixtures.js'; - -import type { ChildProcess } from 'child_process'; - -const __filename = url.fileURLToPath(import.meta.url); - -const test = baseTest.extend<{ agentEndpoint: (options?: { args?: string[] }) => Promise<{ url: URL, stdout: () => string }> }>({ - agentEndpoint: async ({}, use) => { - let cp: ChildProcess | undefined; - await use(async (options?: { args?: string[] }) => { - if (cp) - throw new Error('Process already running'); - - cp = spawn('node', [ - path.join(path.dirname(__filename), '../lib/browserServer.js'), - ...(options?.args || []), - ], { - stdio: 'pipe', - env: { - ...process.env, - DEBUG: 'pw:mcp:test', - DEBUG_COLORS: '0', - DEBUG_HIDE_DATE: '1', - }, - }); - let stdout = ''; - const url = await new Promise(resolve => cp!.stdout?.on('data', data => { - stdout += data.toString(); - const match = stdout.match(/Listening on (http:\/\/.*)/); - if (match) - resolve(match[1]); - })); - - return { url: new URL(url), stdout: () => stdout }; - }); - cp?.kill('SIGTERM'); - }, -}); - -test.skip(({ mcpBrowser }) => mcpBrowser !== 'chrome', 'Agent is CDP-only for now'); - -test('browser lifecycle', async ({ agentEndpoint, startClient, server }) => { - const { url: agentUrl } = await agentEndpoint(); - const { client: client1 } = await startClient({ args: ['--browser-agent', agentUrl.toString()] }); - expect(await client1.callTool({ - name: 'browser_navigate', - arguments: { url: server.HELLO_WORLD }, - })).toContainTextContent('Hello, world!'); - - const { client: client2 } = await startClient({ args: ['--browser-agent', agentUrl.toString()] }); - expect(await client2.callTool({ - name: 'browser_navigate', - arguments: { url: server.HELLO_WORLD }, - })).toContainTextContent('Hello, world!'); - - await client1.close(); - await client2.close(); -});