mirror of
https://github.com/microsoft/playwright-mcp.git
synced 2025-07-26 08:32:26 +08:00
chore: remove server experiment (#681)
This commit is contained in:
parent
d61aa16fee
commit
e9f6433241
@ -192,7 +192,6 @@ Playwright MCP server supports following arguments. They can be provided in the
|
|||||||
--block-service-workers block service workers
|
--block-service-workers block service workers
|
||||||
--browser <browser> browser or chrome channel to use, possible
|
--browser <browser> browser or chrome channel to use, possible
|
||||||
values: chrome, firefox, webkit, msedge.
|
values: chrome, firefox, webkit, msedge.
|
||||||
--browser-agent <endpoint> Use browser agent (experimental).
|
|
||||||
--caps <caps> comma-separated list of additional capabilities
|
--caps <caps> comma-separated list of additional capabilities
|
||||||
to enable, possible values: vision, pdf.
|
to enable, possible values: vision, pdf.
|
||||||
--cdp-endpoint <endpoint> CDP endpoint to connect to.
|
--cdp-endpoint <endpoint> CDP endpoint to connect to.
|
||||||
|
5
config.d.ts
vendored
5
config.d.ts
vendored
@ -23,11 +23,6 @@ export type Config = {
|
|||||||
* The browser to use.
|
* The browser to use.
|
||||||
*/
|
*/
|
||||||
browser?: {
|
browser?: {
|
||||||
/**
|
|
||||||
* Use browser agent (experimental).
|
|
||||||
*/
|
|
||||||
browserAgent?: string;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The type of browser to use.
|
* The type of browser to use.
|
||||||
*/
|
*/
|
||||||
|
@ -21,10 +21,8 @@ import os from 'node:os';
|
|||||||
|
|
||||||
import debug from 'debug';
|
import debug from 'debug';
|
||||||
import * as playwright from 'playwright';
|
import * as playwright from 'playwright';
|
||||||
import { userDataDir } from './fileUtils.js';
|
|
||||||
|
|
||||||
import type { FullConfig } from './config.js';
|
import type { FullConfig } from './config.js';
|
||||||
import type { BrowserInfo, LaunchBrowserRequest } from './browserServer.js';
|
|
||||||
|
|
||||||
const testDebug = debug('pw:mcp:test');
|
const testDebug = debug('pw:mcp:test');
|
||||||
|
|
||||||
@ -35,8 +33,6 @@ export function contextFactory(browserConfig: FullConfig['browser']): BrowserCon
|
|||||||
return new CdpContextFactory(browserConfig);
|
return new CdpContextFactory(browserConfig);
|
||||||
if (browserConfig.isolated)
|
if (browserConfig.isolated)
|
||||||
return new IsolatedContextFactory(browserConfig);
|
return new IsolatedContextFactory(browserConfig);
|
||||||
if (browserConfig.browserAgent)
|
|
||||||
return new BrowserServerContextFactory(browserConfig);
|
|
||||||
return new PersistentContextFactory(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<playwright.Browser> {
|
|
||||||
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<playwright.BrowserContext> {
|
|
||||||
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']) {
|
async function injectCdpPort(browserConfig: FullConfig['browser']) {
|
||||||
if (browserConfig.browserName === 'chromium')
|
if (browserConfig.browserName === 'chromium')
|
||||||
(browserConfig.launchOptions as any).cdpPort = await findFreePort();
|
(browserConfig.launchOptions as any).cdpPort = await findFreePort();
|
||||||
|
@ -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<LaunchBrowserRequest>(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<BrowserInfo> {
|
|
||||||
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>', '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<T>(req: http.IncomingMessage): Promise<T> {
|
|
||||||
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<number> {
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
|
@ -28,7 +28,6 @@ export type CLIOptions = {
|
|||||||
blockedOrigins?: string[];
|
blockedOrigins?: string[];
|
||||||
blockServiceWorkers?: boolean;
|
blockServiceWorkers?: boolean;
|
||||||
browser?: string;
|
browser?: string;
|
||||||
browserAgent?: string;
|
|
||||||
caps?: string;
|
caps?: string;
|
||||||
cdpEndpoint?: string;
|
cdpEndpoint?: string;
|
||||||
config?: string;
|
config?: string;
|
||||||
@ -171,7 +170,6 @@ export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Conf
|
|||||||
|
|
||||||
const result: Config = {
|
const result: Config = {
|
||||||
browser: {
|
browser: {
|
||||||
browserAgent: cliOptions.browserAgent ?? process.env.PW_BROWSER_AGENT,
|
|
||||||
browserName,
|
browserName,
|
||||||
isolated: cliOptions.isolated,
|
isolated: cliOptions.isolated,
|
||||||
userDataDir: cliOptions.userDataDir,
|
userDataDir: cliOptions.userDataDir,
|
||||||
|
@ -30,7 +30,6 @@ program
|
|||||||
.option('--blocked-origins <origins>', '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('--blocked-origins <origins>', '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('--block-service-workers', 'block service workers')
|
||||||
.option('--browser <browser>', 'browser or chrome channel to use, possible values: chrome, firefox, webkit, msedge.')
|
.option('--browser <browser>', 'browser or chrome channel to use, possible values: chrome, firefox, webkit, msedge.')
|
||||||
.option('--browser-agent <endpoint>', 'Use browser agent (experimental).')
|
|
||||||
.option('--caps <caps>', 'comma-separated list of additional capabilities to enable, possible values: vision, pdf.')
|
.option('--caps <caps>', 'comma-separated list of additional capabilities to enable, possible values: vision, pdf.')
|
||||||
.option('--cdp-endpoint <endpoint>', 'CDP endpoint to connect to.')
|
.option('--cdp-endpoint <endpoint>', 'CDP endpoint to connect to.')
|
||||||
.option('--config <path>', 'path to the configuration file.')
|
.option('--config <path>', 'path to the configuration file.')
|
||||||
|
@ -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<string>(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();
|
|
||||||
});
|
|
Loading…
x
Reference in New Issue
Block a user