7 Commits

Author SHA1 Message Date
Pavel Feldman
675b083db3 chore: mark v0.0.28 (#503) 2025-06-01 14:30:42 -07:00
Pavel Feldman
0b74cdaaf8 chore: sort out signal handling (#506) 2025-06-01 14:11:42 -07:00
Pavel Feldman
f31ef598bc test: verify the log in close/navigate test (#505) 2025-06-01 12:49:30 -07:00
Pavel Feldman
656779531c chore: respect server settings from config (#502) 2025-05-30 18:17:51 -07:00
Pavel Feldman
eec177d3ac chore: reuse browser in server mode (#495) 2025-05-30 15:15:37 -07:00
Pavel Feldman
54ed7c3200 chore: refactor server, prepare for browser reuse (#490) 2025-05-28 16:55:47 -07:00
nabepa
3cd74a824a docs: fixed typo in README.md (#487) 2025-05-27 20:33:36 -07:00
26 changed files with 717 additions and 326 deletions

View File

@@ -199,7 +199,7 @@ state [here](https://playwright.dev/docs/auth).
"args": [
"@playwright/mcp@latest",
"--isolated",
"--storage-state={path/to/storage.json}
"--storage-state={path/to/storage.json}"
]
}
}

3
index.d.ts vendored
View File

@@ -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<void>;
};
export declare function createConnection(config?: Config): Promise<Connection>;
export declare function createConnection(config?: Config, contextGetter?: () => Promise<BrowserContext>): Promise<Connection>;
export {};

112
package-lock.json generated
View File

@@ -1,16 +1,17 @@
{
"name": "@playwright/mcp",
"version": "0.0.27",
"version": "0.0.28",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@playwright/mcp",
"version": "0.0.27",
"version": "0.0.28",
"license": "Apache-2.0",
"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",

View File

@@ -1,6 +1,6 @@
{
"name": "@playwright/mcp",
"version": "0.0.27",
"version": "0.0.28",
"description": "Playwright Tools for MCP",
"type": "module",
"repository": {
@@ -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",

View File

@@ -0,0 +1,211 @@
/**
* 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,
handleSIGINT: false,
handleSIGTERM: false,
}).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,
handleSIGINT: false,
handleSIGTERM: false,
});
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;
}
}

View File

@@ -68,19 +68,21 @@ const defaultConfig: FullConfig = {
allowedOrigins: undefined,
blockedOrigins: undefined,
},
server: {},
outputDir: path.join(os.tmpdir(), 'playwright-mcp-output', sanitizeForFilePath(new Date().toISOString())),
};
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']>;
},
network: NonNullable<Config['network']>,
outputDir: string;
server: NonNullable<Config['server']>,
};
export async function resolveConfig(config: Config): Promise<FullConfig> {
@@ -256,6 +258,10 @@ function mergeConfig(base: FullConfig, overrides: Config): FullConfig {
network: {
...pickDefined(base.network),
...pickDefined(overrides.network),
}
},
server: {
...pickDefined(base.server),
...pickDefined(overrides.server),
},
} as FullConfig;
}

View File

@@ -14,22 +14,24 @@
* limitations under the License.
*/
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { Server as McpServer } from '@modelcontextprotocol/sdk/server/index.js';
import { CallToolRequestSchema, ListToolsRequestSchema, Tool as McpTool } from '@modelcontextprotocol/sdk/types.js';
import { zodToJsonSchema } from 'zod-to-json-schema';
import { Context, packageJSON } from './context.js';
import { Context } from './context.js';
import { snapshotTools, visionTools } from './tools.js';
import { packageJSON } from './package.js';
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
import { FullConfig } from './config.js';
export async function createConnection(config: FullConfig): Promise<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 server = new Server({ name: 'Playwright', version: packageJSON.version }, {
const context = new Context(tools, config, browserContextFactory);
const server = new McpServer({ name: 'Playwright', version: packageJSON.version }, {
capabilities: {
tools: {},
}
@@ -74,25 +76,19 @@ export async function createConnection(config: FullConfig): Promise<Connection>
}
});
const connection = new Connection(server, context);
return connection;
return new Connection(server, context);
}
export class Connection {
readonly server: Server;
readonly server: McpServer;
readonly context: Context;
constructor(server: Server, context: Context) {
constructor(server: McpServer, context: Context) {
this.server = server;
this.context = context;
}
async connect(transport: Transport) {
await this.server.connect(transport);
await new Promise<void>(resolve => {
this.server.oninitialized = () => resolve();
});
this.context.clientVersion = this.server.getClientVersion();
this.server.oninitialized = () => {
this.context.clientVersion = this.server.getClientVersion();
};
}
async close() {

View File

@@ -14,11 +14,7 @@
* limitations under the License.
*/
import fs from 'node:fs';
import url from 'node:url';
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';
@@ -29,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 })[] = [];
@@ -50,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 {
@@ -297,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();
});
}
@@ -333,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);
@@ -347,75 +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;
}
const __filename = url.fileURLToPath(import.meta.url);
export const packageJSON = JSON.parse(fs.readFileSync(path.join(path.dirname(__filename), '..', 'package.json'), 'utf8'));

View File

@@ -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()
};
}
}

22
src/package.ts Normal file
View File

@@ -0,0 +1,22 @@
/**
* 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 url from 'node:url';
import path from 'node:path';
const __filename = url.fileURLToPath(import.meta.url);
export const packageJSON = JSON.parse(fs.readFileSync(path.join(path.dirname(__filename), '..', 'package.json'), 'utf8'));

View File

@@ -15,14 +15,13 @@
*/
import { program } from 'commander';
import { startHttpTransport, startStdioTransport } from './transport.js';
import { resolveCLIConfig } from './config.js';
// @ts-ignore
import { startTraceViewerServer } from 'playwright-core/lib/server';
import type { Connection } from './connection.js';
import { packageJSON } from './context.js';
import { startHttpTransport, startStdioTransport } from './transport.js';
import { resolveCLIConfig } from './config.js';
import { Server } from './server.js';
import { packageJSON } from './package.js';
program
.version('Version ' + packageJSON.version)
@@ -54,13 +53,13 @@ program
.option('--vision', 'Run server that uses screenshots (Aria snapshots are used by default)')
.action(async options => {
const config = await resolveCLIConfig(options);
const connectionList: Connection[] = [];
setupExitWatchdog(connectionList);
const server = new Server(config);
server.setupExitWatchdog();
if (options.port)
startHttpTransport(config, +options.port, options.host, connectionList);
if (config.server.port !== undefined)
startHttpTransport(server);
else
await startStdioTransport(config, connectionList);
await startStdioTransport(server);
if (config.saveTrace) {
const server = await startTraceViewerServer();
@@ -71,21 +70,8 @@ program
}
});
function setupExitWatchdog(connectionList: Connection[]) {
const handleExit = async () => {
setTimeout(() => process.exit(0), 15000);
for (const connection of connectionList)
await connection.close();
process.exit(0);
};
process.stdin.on('close', handleExit);
process.on('SIGINT', handleExit);
process.on('SIGTERM', handleExit);
}
function semicolonSeparatedList(value: string): string[] {
return value.split(';').map(v => v.trim());
}
program.parse(process.argv);
void program.parseAsync(process.argv);

59
src/server.ts Normal file
View File

@@ -0,0 +1,59 @@
/**
* 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 { 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, this._contextFactory);
this._connectionList.push(connection);
await connection.server.connect(transport);
return connection;
}
setupExitWatchdog() {
let isExiting = false;
const handleExit = async () => {
if (isExiting)
return;
isExiting = true;
setTimeout(() => process.exit(0), 15000);
await Promise.all(this._connectionList.map(connection => connection.close()));
process.exit(0);
};
process.stdin.on('close', handleExit);
process.on('SIGINT', handleExit);
process.on('SIGTERM', handleExit);
}
}

View File

@@ -18,22 +18,20 @@ 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';
import { createConnection } from './connection.js';
import type { Server } from './server.js';
import type { Connection } from './connection.js';
import type { FullConfig } from './config.js';
export async function startStdioTransport(config: FullConfig, connectionList: Connection[]) {
const connection = await createConnection(config);
await connection.connect(new StdioServerTransport());
connectionList.push(connection);
export async function startStdioTransport(server: Server) {
await server.createConnection(new StdioServerTransport());
}
async function handleSSE(config: FullConfig, req: http.IncomingMessage, res: http.ServerResponse, url: URL, sessions: Map<string, SSEServerTransport>, connectionList: Connection[]) {
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');
if (!sessionId) {
@@ -51,15 +49,13 @@ async function handleSSE(config: FullConfig, req: http.IncomingMessage, res: htt
} else if (req.method === 'GET') {
const transport = new SSEServerTransport('/sse', res);
sessions.set(transport.sessionId, transport);
const connection = await createConnection(config);
await connection.connect(transport);
connectionList.push(connection);
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);
connection.close().catch(e => {
// eslint-disable-next-line no-console
console.error(e);
});
// eslint-disable-next-line no-console
void connection.close().catch(e => console.error(e));
});
return;
}
@@ -68,7 +64,7 @@ async function handleSSE(config: FullConfig, req: http.IncomingMessage, res: htt
res.end('Method not allowed');
}
async function handleStreamable(config: FullConfig, req: http.IncomingMessage, res: http.ServerResponse, sessions: Map<string, StreamableHTTPServerTransport>, connectionList: Connection[]) {
async function handleStreamable(server: Server, req: http.IncomingMessage, res: http.ServerResponse, sessions: Map<string, StreamableHTTPServerTransport>) {
const sessionId = req.headers['mcp-session-id'] as string | undefined;
if (sessionId) {
const transport = sessions.get(sessionId);
@@ -91,12 +87,8 @@ async function handleStreamable(config: FullConfig, req: http.IncomingMessage, r
if (transport.sessionId)
sessions.delete(transport.sessionId);
};
const connection = await createConnection(config);
connectionList.push(connection);
await Promise.all([
connection.connect(transport),
transport.handleRequest(req, res),
]);
await server.createConnection(transport);
await transport.handleRequest(req, res);
return;
}
@@ -104,17 +96,18 @@ async function handleStreamable(config: FullConfig, req: http.IncomingMessage, r
res.end('Invalid request');
}
export function startHttpTransport(config: FullConfig, port: number, hostname: string | undefined, connectionList: Connection[]) {
export function startHttpTransport(server: Server) {
const sseSessions = new Map<string, SSEServerTransport>();
const streamableSessions = new Map<string, StreamableHTTPServerTransport>();
const httpServer = http.createServer(async (req, res) => {
const url = new URL(`http://localhost${req.url}`);
if (url.pathname.startsWith('/mcp'))
await handleStreamable(config, req, res, streamableSessions, connectionList);
await handleStreamable(server, req, res, streamableSessions);
else
await handleSSE(config, req, res, url, sseSessions, connectionList);
await handleSSE(server, req, res, url, sseSessions);
});
httpServer.listen(port, hostname, () => {
const { host, port } = server.config.server;
httpServer.listen(port, host, () => {
const address = httpServer.address();
assert(address, 'Could not bind server socket');
let url: string;

View File

@@ -77,7 +77,7 @@ test('test vision tool list', async ({ visionClient }) => {
});
test('test capabilities', async ({ startClient }) => {
const client = await startClient({
const { client } = await startClient({
args: ['--caps="core"'],
});
const { tools } = await client.listTools();

View File

@@ -18,7 +18,7 @@ import { test, expect } from './fixtures.js';
test('cdp server', async ({ cdpServer, startClient, server }) => {
await cdpServer.start();
const client = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] });
const { client } = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] });
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
@@ -27,7 +27,7 @@ test('cdp server', async ({ cdpServer, startClient, server }) => {
test('cdp server reuse tab', async ({ cdpServer, startClient, server }) => {
const browserContext = await cdpServer.start();
const client = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] });
const { client } = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] });
const [page] = browserContext.pages();
await page.goto(server.HELLO_WORLD);
@@ -58,7 +58,7 @@ test('cdp server reuse tab', async ({ cdpServer, startClient, server }) => {
});
test('should throw connection error and allow re-connecting', async ({ cdpServer, startClient, server }) => {
const client = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] });
const { client } = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] });
server.setContent('/', `
<title>Title</title>

View File

@@ -33,7 +33,7 @@ test('config user data dir', async ({ startClient, server }, testInfo) => {
const configPath = testInfo.outputPath('config.json');
await fs.promises.writeFile(configPath, JSON.stringify(config, null, 2));
const client = await startClient({ args: ['--config', configPath] });
const { client } = await startClient({ args: ['--config', configPath] });
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
@@ -54,7 +54,7 @@ test.describe(() => {
const configPath = testInfo.outputPath('config.json');
await fs.promises.writeFile(configPath, JSON.stringify(config, null, 2));
const client = await startClient({ args: ['--config', configPath] });
const { client } = await startClient({ args: ['--config', configPath] });
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: 'data:text/html,<script>document.title = navigator.userAgent</script>' },

View File

@@ -17,7 +17,7 @@
import { test, expect } from './fixtures.js';
test('--device should work', async ({ startClient, server }) => {
const client = await startClient({
const { client } = await startClient({
args: ['--device', 'iPhone 15'],
});

View File

@@ -101,7 +101,7 @@ The tool "browser_file_upload" can only be used when there is related modal stat
});
test('clicking on download link emits download', async ({ startClient, server }, testInfo) => {
const client = await startClient({
const { client } = await startClient({
config: { outputDir: testInfo.outputPath('output') },
});
@@ -125,7 +125,7 @@ test('clicking on download link emits download', async ({ startClient, server },
});
test('navigating to download link emits download', async ({ startClient, server, mcpBrowser }, testInfo) => {
const client = await startClient({
const { client } = await startClient({
config: { outputDir: testInfo.outputPath('output') },
});

View File

@@ -40,7 +40,7 @@ type CDPServer = {
type TestFixtures = {
client: Client;
visionClient: Client;
startClient: (options?: { clientName?: string, args?: string[], config?: Config }) => Promise<Client>;
startClient: (options?: { clientName?: string, args?: string[], config?: Config }) => Promise<{ client: Client, stderr: () => string }>;
wsEndpoint: string;
cdpServer: CDPServer;
server: TestServer;
@@ -55,11 +55,13 @@ type WorkerFixtures = {
export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>({
client: async ({ startClient }, use) => {
await use(await startClient());
const { client } = await startClient();
await use(client);
},
visionClient: async ({ startClient }, use) => {
await use(await startClient({ args: ['--vision'] }));
const { client } = await startClient({ args: ['--vision'] });
await use(client);
},
startClient: async ({ mcpHeadless, mcpBrowser, mcpMode }, use, testInfo) => {
@@ -85,9 +87,13 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
client = new Client({ name: options?.clientName ?? 'test', version: '1.0.0' });
const transport = createTransport(args, mcpMode);
let stderr = '';
transport.stderr?.on('data', data => {
stderr += data.toString();
});
await client.connect(transport);
await client.ping();
return client;
return { client, stderr: () => stderr };
});
await client?.close();
@@ -168,7 +174,13 @@ function createTransport(args: string[], mcpMode: TestOptions['mcpMode']) {
command: 'node',
args: [path.join(path.dirname(__filename), '../cli.js'), ...args],
cwd: path.join(path.dirname(__filename), '..'),
env: process.env as Record<string, string>,
stderr: 'pipe',
env: {
...process.env,
DEBUG: 'pw:mcp:test',
DEBUG_COLORS: '0',
DEBUG_HIDE_DATE: '1',
},
});
}
@@ -225,3 +237,7 @@ export const expect = baseExpect.extend({
};
},
});
export function formatOutput(output: string): string[] {
return output.split('\n').map(line => line.replace(/^pw:mcp:test /, '').replace(/test-results.*/, '').trim()).filter(Boolean);
}

View File

@@ -16,9 +16,10 @@
import fs from 'fs';
import { test, expect } from './fixtures.js';
import { test, expect, formatOutput } from './fixtures.js';
test('test reopen browser', async ({ client, server }) => {
test('test reopen browser', async ({ startClient, server }) => {
const { client, stderr } = await startClient();
await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
@@ -32,10 +33,31 @@ test('test reopen browser', async ({ client, server }) => {
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
})).toContainTextContent(`- generic [ref=e1]: Hello, world!`);
await client.close();
if (process.platform === 'win32')
return;
await expect.poll(() => formatOutput(stderr()), { timeout: 0 }).toEqual([
'create context',
'create browser context (persistent)',
'lock user data dir',
'close context',
'close browser context (persistent)',
'release user data dir',
'close browser context complete (persistent)',
'create browser context (persistent)',
'lock user data dir',
'close context',
'close browser context (persistent)',
'release user data dir',
'close browser context complete (persistent)',
]);
});
test('executable path', async ({ startClient, server }) => {
const client = await startClient({ args: [`--executable-path=bogus`] });
const { client } = await startClient({ args: [`--executable-path=bogus`] });
const response = await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
@@ -53,7 +75,7 @@ test('persistent context', async ({ startClient, server }) => {
</script>
`, 'text/html');
const client = await startClient();
const { client } = await startClient();
const response = await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
@@ -66,7 +88,7 @@ test('persistent context', async ({ startClient, server }) => {
name: 'browser_close',
});
const client2 = await startClient();
const { client: client2 } = await startClient();
const response2 = await client2.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
@@ -85,18 +107,18 @@ test('isolated context', async ({ startClient, server }) => {
</script>
`, 'text/html');
const client = await startClient({ args: [`--isolated`] });
const response = await client.callTool({
const { client: client1 } = await startClient({ args: [`--isolated`] });
const response = await client1.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
});
expect(response).toContainTextContent(`Storage: NO`);
await client.callTool({
await client1.callTool({
name: 'browser_close',
});
const client2 = await startClient({ args: [`--isolated`] });
const { client: client2 } = await startClient({ args: [`--isolated`] });
const response2 = await client2.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
@@ -123,7 +145,7 @@ test('isolated context with storage state', async ({ startClient, server }, test
</script>
`, 'text/html');
const client = await startClient({ args: [
const { client } = await startClient({ args: [
`--isolated`,
`--storage-state=${storageStatePath}`,
] });

View File

@@ -19,7 +19,7 @@ import fs from 'fs';
import { test, expect } from './fixtures.js';
test('save as pdf unavailable', async ({ startClient, server }) => {
const client = await startClient({ args: ['--caps="no-pdf"'] });
const { client } = await startClient({ args: ['--caps="no-pdf"'] });
await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
@@ -31,7 +31,7 @@ test('save as pdf unavailable', async ({ startClient, server }) => {
});
test('save as pdf', async ({ startClient, mcpBrowser, server }, testInfo) => {
const client = await startClient({
const { client } = await startClient({
config: { outputDir: testInfo.outputPath('output') },
});
@@ -51,7 +51,7 @@ test('save as pdf', async ({ startClient, mcpBrowser, server }, testInfo) => {
test('save as pdf (filename: output.pdf)', async ({ startClient, mcpBrowser, server }, testInfo) => {
const outputDir = testInfo.outputPath('output');
test.skip(!!mcpBrowser && !['chromium', 'chrome', 'msedge'].includes(mcpBrowser), 'Save as PDF is only supported in Chromium.');
const client = await startClient({
const { client } = await startClient({
config: { outputDir },
});

View File

@@ -37,7 +37,7 @@ test('default to allow all', async ({ server, client }) => {
});
test('blocked works', async ({ startClient }) => {
const client = await startClient({
const { client } = await startClient({
args: ['--blocked-origins', 'microsoft.com;example.com;playwright.dev']
});
const result = await fetchPage(client, 'https://example.com/');
@@ -46,7 +46,7 @@ test('blocked works', async ({ startClient }) => {
test('allowed works', async ({ server, startClient }) => {
server.setContent('/ppp', 'content:PPP', 'text/html');
const client = await startClient({
const { client } = await startClient({
args: ['--allowed-origins', `microsoft.com;${new URL(server.PREFIX).host};playwright.dev`]
});
const result = await fetchPage(client, server.PREFIX + 'ppp');
@@ -54,7 +54,7 @@ test('allowed works', async ({ server, startClient }) => {
});
test('blocked takes precedence', async ({ startClient }) => {
const client = await startClient({
const { client } = await startClient({
args: [
'--blocked-origins', 'example.com',
'--allowed-origins', 'example.com',
@@ -65,7 +65,7 @@ test('blocked takes precedence', async ({ startClient }) => {
});
test('allowed without blocked blocks all non-explicitly specified origins', async ({ startClient }) => {
const client = await startClient({
const { client } = await startClient({
args: ['--allowed-origins', 'playwright.dev'],
});
const result = await fetchPage(client, 'https://example.com/');
@@ -74,7 +74,7 @@ test('allowed without blocked blocks all non-explicitly specified origins', asyn
test('blocked without allowed allows non-explicitly specified origins', async ({ server, startClient }) => {
server.setContent('/ppp', 'content:PPP', 'text/html');
const client = await startClient({
const { client } = await startClient({
args: ['--blocked-origins', 'example.com'],
});
const result = await fetchPage(client, server.PREFIX + 'ppp');

View File

@@ -19,7 +19,7 @@ import fs from 'fs';
import { test, expect } from './fixtures.js';
test('browser_take_screenshot (viewport)', async ({ startClient, server }, testInfo) => {
const client = await startClient({
const { client } = await startClient({
config: { outputDir: testInfo.outputPath('output') },
});
expect(await client.callTool({
@@ -45,7 +45,7 @@ test('browser_take_screenshot (viewport)', async ({ startClient, server }, testI
});
test('browser_take_screenshot (element)', async ({ startClient, server }, testInfo) => {
const client = await startClient({
const { client } = await startClient({
config: { outputDir: testInfo.outputPath('output') },
});
expect(await client.callTool({
@@ -76,7 +76,7 @@ test('browser_take_screenshot (element)', async ({ startClient, server }, testIn
test('--output-dir should work', async ({ startClient, server }, testInfo) => {
const outputDir = testInfo.outputPath('output');
const client = await startClient({
const { client } = await startClient({
config: { outputDir },
});
expect(await client.callTool({
@@ -98,7 +98,7 @@ for (const raw of [undefined, true]) {
test(`browser_take_screenshot (raw: ${raw})`, async ({ startClient, server }, testInfo) => {
const outputDir = testInfo.outputPath('output');
const ext = raw ? 'png' : 'jpeg';
const client = await startClient({
const { client } = await startClient({
config: { outputDir },
});
expect(await client.callTool({
@@ -138,7 +138,7 @@ for (const raw of [undefined, true]) {
test('browser_take_screenshot (filename: "output.jpeg")', async ({ startClient, server }, testInfo) => {
const outputDir = testInfo.outputPath('output');
const client = await startClient({
const { client } = await startClient({
config: { outputDir },
});
expect(await client.callTool({
@@ -174,7 +174,7 @@ test('browser_take_screenshot (filename: "output.jpeg")', async ({ startClient,
test('browser_take_screenshot (imageResponses=omit)', async ({ startClient, server }, testInfo) => {
const outputDir = testInfo.outputPath('output');
const client = await startClient({
const { client } = await startClient({
config: {
outputDir,
imageResponses: 'omit',
@@ -205,7 +205,7 @@ test('browser_take_screenshot (imageResponses=omit)', async ({ startClient, serv
test('browser_take_screenshot (cursor)', async ({ startClient, server }, testInfo) => {
const outputDir = testInfo.outputPath('output');
const client = await startClient({
const { client } = await startClient({
clientName: 'cursor:vscode',
config: { outputDir },
});

View File

@@ -14,96 +14,233 @@
* limitations under the License.
*/
import fs from 'node:fs';
import url from 'node:url';
import http from 'node:http';
import { spawn } from 'node:child_process';
import { ChildProcess, spawn } from 'node:child_process';
import path from 'node:path';
import type { AddressInfo } from 'node:net';
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { createConnection } from '@playwright/mcp';
import { test as baseTest, expect } from './fixtures.js';
import type { Config } from '../config.d.ts';
// 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: (options?: { args?: string[], noPort?: boolean }) => Promise<{ url: URL, stderr: () => string }> }>({
serverEndpoint: async ({ mcpHeadless }, use, testInfo) => {
let cp: ChildProcess | undefined;
const userDataDir = testInfo.outputPath('user-data-dir');
await use(async (options?: { args?: string[], noPort?: boolean }) => {
if (cp)
throw new Error('Process already running');
cp = spawn('node', [
path.join(path.dirname(__filename), '../cli.js'),
...(options?.noPort ? [] : ['--port=0']),
'--user-data-dir=' + userDataDir,
...(mcpHeadless ? ['--headless'] : []),
...(options?.args || []),
], {
stdio: 'pipe',
env: {
...process.env,
DEBUG: 'pw:mcp:test',
DEBUG_COLORS: '0',
DEBUG_HIDE_DATE: '1',
},
});
let stderr = '';
const url = await new Promise<string>(resolve => cp.stderr?.on('data', data => {
const url = await new Promise<string>(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();
});
test('sse transport (config)', async ({ serverEndpoint }) => {
const config: Config = {
server: {
port: 0,
}
};
const configFile = test.info().outputPath('config.json');
await fs.promises.writeFile(configFile, JSON.stringify(config, null, 2));
const { url } = await serverEndpoint({ noPort: true, args: ['--config=' + configFile] });
const transport = new SSEClientTransport(url);
const client = new Client({ name: 'test', version: '1.0.0' });
await client.connect(transport);
await client.ping();
});
test('sse transport browser lifecycle (isolated)', async ({ serverEndpoint, server }) => {
const { url, stderr } = await serverEndpoint({ args: ['--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({ args: ['--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();
expect(transport.sessionId, 'has session support').toBeDefined();
});
test('sse transport via public API', async ({ server }, testInfo) => {
const userDataDir = testInfo.outputPath('user-data-dir');
const sessions = new Map<string, SSEServerTransport>();
const mcpServer = http.createServer(async (req, res) => {
if (req.method === 'GET') {
const connection = await createConnection({
browser: {
userDataDir,
launchOptions: { headless: true }
},
});
const transport = new SSEServerTransport('/sse', res);
sessions.set(transport.sessionId, transport);
await connection.connect(transport);
} else if (req.method === 'POST') {
const url = new URL(`http://localhost${req.url}`);
const sessionId = url.searchParams.get('sessionId');
if (!sessionId) {
res.statusCode = 400;
return res.end('Missing sessionId');
}
const transport = sessions.get(sessionId);
if (!transport) {
res.statusCode = 404;
return res.end('Session not found');
}
void transport.handlePostMessage(req, res);
}
});
await new Promise<void>(resolve => mcpServer.listen(0, () => resolve()));
const serverUrl = `http://localhost:${(mcpServer.address() as AddressInfo).port}/sse`;
const transport = new SSEClientTransport(new URL(serverUrl));
const client = new Client({ name: 'test', version: '1.0.0' });
await client.connect(transport);
await client.ping();
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
})).toContainTextContent(`- generic [ref=e1]: Hello, world!`);
await client.close();
mcpServer.close();
});

View File

@@ -141,7 +141,7 @@ test('reuse first tab when navigating', async ({ startClient, cdpServer, server
const browserContext = await cdpServer.start();
const pages = browserContext.pages();
const client = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] });
const { client } = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] });
await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },

View File

@@ -22,7 +22,7 @@ import { test, expect } from './fixtures.js';
test('check that trace is saved', async ({ startClient, server }, testInfo) => {
const outputDir = testInfo.outputPath('output');
const client = await startClient({
const { client } = await startClient({
args: ['--save-trace', `--output-dir=${outputDir}`],
});