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": [ "args": [
"@playwright/mcp@latest", "@playwright/mcp@latest",
"--isolated", "--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 { Server } from '@modelcontextprotocol/sdk/server/index.js';
import type { Config } from './config'; import type { Config } from './config';
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
import type { BrowserContext } from 'playwright';
export type Connection = { export type Connection = {
server: Server; server: Server;
@@ -25,5 +26,5 @@ export type Connection = {
close(): Promise<void>; close(): Promise<void>;
}; };
export declare function createConnection(config?: Config): Promise<Connection>; export declare function createConnection(config?: Config, contextGetter?: () => Promise<BrowserContext>): Promise<Connection>;
export {}; export {};

112
package-lock.json generated
View File

@@ -1,16 +1,17 @@
{ {
"name": "@playwright/mcp", "name": "@playwright/mcp",
"version": "0.0.27", "version": "0.0.28",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@playwright/mcp", "name": "@playwright/mcp",
"version": "0.0.27", "version": "0.0.28",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.11.0", "@modelcontextprotocol/sdk": "^1.11.0",
"commander": "^13.1.0", "commander": "^13.1.0",
"debug": "^4.4.1",
"playwright": "1.53.0-alpha-2025-05-27", "playwright": "1.53.0-alpha-2025-05-27",
"zod-to-json-schema": "^3.24.4" "zod-to-json-schema": "^3.24.4"
}, },
@@ -22,6 +23,7 @@
"@eslint/js": "^9.19.0", "@eslint/js": "^9.19.0",
"@playwright/test": "1.53.0-alpha-2025-05-27", "@playwright/test": "1.53.0-alpha-2025-05-27",
"@stylistic/eslint-plugin": "^3.0.1", "@stylistic/eslint-plugin": "^3.0.1",
"@types/debug": "^4.1.12",
"@types/node": "^22.13.10", "@types/node": "^22.13.10",
"@typescript-eslint/eslint-plugin": "^8.26.1", "@typescript-eslint/eslint-plugin": "^8.26.1",
"@typescript-eslint/parser": "^8.26.1", "@typescript-eslint/parser": "^8.26.1",
@@ -354,6 +356,16 @@
"url": "https://github.com/sponsors/jonschlinkert" "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": { "node_modules/@types/estree": {
"version": "1.0.6", "version": "1.0.6",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
@@ -375,6 +387,13 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@types/node": {
"version": "22.13.10", "version": "22.13.10",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.10.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.10.tgz",
@@ -853,29 +872,6 @@
"node": ">=18" "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": { "node_modules/body-parser/node_modules/qs": {
"version": "6.14.0", "version": "6.14.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
@@ -1156,12 +1152,12 @@
} }
}, },
"node_modules/debug": { "node_modules/debug": {
"version": "4.3.6", "version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
"integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ms": "2.1.2" "ms": "^2.1.3"
}, },
"engines": { "engines": {
"node": ">=6.0" "node": ">=6.0"
@@ -1826,6 +1822,29 @@
"express": "^4.11 || 5 || ^5.0.0-beta.1" "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": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -1930,29 +1949,6 @@
"node": ">= 0.8" "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": { "node_modules/find-root": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz",
@@ -3003,9 +2999,9 @@
} }
}, },
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.2", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/natural-compare": { "node_modules/natural-compare": {
@@ -3713,12 +3709,6 @@
"node": ">= 0.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": { "node_modules/serve-static": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.1.0.tgz", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.1.0.tgz",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@playwright/mcp", "name": "@playwright/mcp",
"version": "0.0.27", "version": "0.0.28",
"description": "Playwright Tools for MCP", "description": "Playwright Tools for MCP",
"type": "module", "type": "module",
"repository": { "repository": {
@@ -37,6 +37,7 @@
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.11.0", "@modelcontextprotocol/sdk": "^1.11.0",
"commander": "^13.1.0", "commander": "^13.1.0",
"debug": "^4.4.1",
"playwright": "1.53.0-alpha-2025-05-27", "playwright": "1.53.0-alpha-2025-05-27",
"zod-to-json-schema": "^3.24.4" "zod-to-json-schema": "^3.24.4"
}, },
@@ -45,6 +46,7 @@
"@eslint/js": "^9.19.0", "@eslint/js": "^9.19.0",
"@playwright/test": "1.53.0-alpha-2025-05-27", "@playwright/test": "1.53.0-alpha-2025-05-27",
"@stylistic/eslint-plugin": "^3.0.1", "@stylistic/eslint-plugin": "^3.0.1",
"@types/debug": "^4.1.12",
"@types/node": "^22.13.10", "@types/node": "^22.13.10",
"@typescript-eslint/eslint-plugin": "^8.26.1", "@typescript-eslint/eslint-plugin": "^8.26.1",
"@typescript-eslint/parser": "^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, allowedOrigins: undefined,
blockedOrigins: undefined, blockedOrigins: undefined,
}, },
server: {},
outputDir: path.join(os.tmpdir(), 'playwright-mcp-output', sanitizeForFilePath(new Date().toISOString())), outputDir: path.join(os.tmpdir(), 'playwright-mcp-output', sanitizeForFilePath(new Date().toISOString())),
}; };
type BrowserUserConfig = NonNullable<Config['browser']>; type BrowserUserConfig = NonNullable<Config['browser']>;
export type FullConfig = Config & { export type FullConfig = Config & {
browser: BrowserUserConfig & { browser: Omit<BrowserUserConfig, 'browserName'> & {
browserName: NonNullable<BrowserUserConfig['browserName']>; browserName: 'chromium' | 'firefox' | 'webkit';
launchOptions: NonNullable<BrowserUserConfig['launchOptions']>; launchOptions: NonNullable<BrowserUserConfig['launchOptions']>;
contextOptions: NonNullable<BrowserUserConfig['contextOptions']>; contextOptions: NonNullable<BrowserUserConfig['contextOptions']>;
}, },
network: NonNullable<Config['network']>, network: NonNullable<Config['network']>,
outputDir: string; outputDir: string;
server: NonNullable<Config['server']>,
}; };
export async function resolveConfig(config: Config): Promise<FullConfig> { export async function resolveConfig(config: Config): Promise<FullConfig> {
@@ -256,6 +258,10 @@ function mergeConfig(base: FullConfig, overrides: Config): FullConfig {
network: { network: {
...pickDefined(base.network), ...pickDefined(base.network),
...pickDefined(overrides.network), ...pickDefined(overrides.network),
} },
server: {
...pickDefined(base.server),
...pickDefined(overrides.server),
},
} as FullConfig; } as FullConfig;
} }

View File

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

View File

@@ -14,11 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
import fs from 'node:fs'; import debug from 'debug';
import url from 'node:url';
import os from 'node:os';
import path from 'node:path';
import * as playwright from 'playwright'; import * as playwright from 'playwright';
import { callOnPageNoTrace, waitForCompletion } from './tools/utils.js'; 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 { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js';
import type { ModalState, Tool, ToolActionResult } from './tools/tool.js'; import type { ModalState, Tool, ToolActionResult } from './tools/tool.js';
import type { FullConfig } from './config.js'; import type { FullConfig } from './config.js';
import type { BrowserContextFactory } from './browserContextFactory.js';
type PendingAction = { type PendingAction = {
dialogShown: ManualPromise<void>; dialogShown: ManualPromise<void>;
}; };
type BrowserContextAndBrowser = { const testDebug = debug('pw:mcp:test');
browser?: playwright.Browser;
browserContext: playwright.BrowserContext;
};
export class Context { export class Context {
readonly tools: Tool[]; readonly tools: Tool[];
readonly config: FullConfig; 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 _tabs: Tab[] = [];
private _currentTab: Tab | undefined; private _currentTab: Tab | undefined;
private _modalStates: (ModalState & { tab: Tab })[] = []; private _modalStates: (ModalState & { tab: Tab })[] = [];
@@ -50,9 +45,11 @@ export class Context {
private _downloads: { download: playwright.Download, finished: boolean, outputFile: string }[] = []; private _downloads: { download: playwright.Download, finished: boolean, outputFile: string }[] = [];
clientVersion: { name: string; version: string; } | undefined; clientVersion: { name: string; version: string; } | undefined;
constructor(tools: Tool[], config: FullConfig) { constructor(tools: Tool[], config: FullConfig, browserContextFactory: BrowserContextFactory) {
this.tools = tools; this.tools = tools;
this.config = config; this.config = config;
this._browserContextFactory = browserContextFactory;
testDebug('create context');
} }
clientSupportsImages(): boolean { clientSupportsImages(): boolean {
@@ -297,15 +294,15 @@ ${code.join('\n')}
if (!this._browserContextPromise) if (!this._browserContextPromise)
return; return;
testDebug('close context');
const promise = this._browserContextPromise; const promise = this._browserContextPromise;
this._browserContextPromise = undefined; this._browserContextPromise = undefined;
await promise.then(async ({ browserContext, browser }) => { await promise.then(async ({ browserContext, close }) => {
if (this.config.saveTrace) if (this.config.saveTrace)
await browserContext.tracing.stop(); await browserContext.tracing.stop();
await browserContext.close().then(async () => { await close();
await browser?.close();
}).catch(() => {});
}); });
} }
@@ -333,8 +330,10 @@ ${code.join('\n')}
return this._browserContextPromise; return this._browserContextPromise;
} }
private async _setupBrowserContext(): Promise<BrowserContextAndBrowser> { private async _setupBrowserContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
const { browser, browserContext } = await this._createBrowserContext(); // 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); await this._setupRequestInterception(browserContext);
for (const page of browserContext.pages()) for (const page of browserContext.pages())
this._onPageCreated(page); this._onPageCreated(page);
@@ -347,75 +346,6 @@ ${code.join('\n')}
sources: false, sources: false,
}); });
} }
return { browser, browserContext }; return result;
}
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);
} }
} }
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 { Connection, createConnection as createConnectionImpl } from './connection.js';
import { resolveConfig } from './config.js'; import { resolveConfig } from './config.js';
import { contextFactory } from './browserContextFactory.js';
import type { Config } from '../config.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); 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 { program } from 'commander';
import { startHttpTransport, startStdioTransport } from './transport.js';
import { resolveCLIConfig } from './config.js';
// @ts-ignore // @ts-ignore
import { startTraceViewerServer } from 'playwright-core/lib/server'; import { startTraceViewerServer } from 'playwright-core/lib/server';
import type { Connection } from './connection.js'; import { startHttpTransport, startStdioTransport } from './transport.js';
import { packageJSON } from './context.js'; import { resolveCLIConfig } from './config.js';
import { Server } from './server.js';
import { packageJSON } from './package.js';
program program
.version('Version ' + packageJSON.version) .version('Version ' + packageJSON.version)
@@ -54,13 +53,13 @@ program
.option('--vision', 'Run server that uses screenshots (Aria snapshots are used by default)') .option('--vision', 'Run server that uses screenshots (Aria snapshots are used by default)')
.action(async options => { .action(async options => {
const config = await resolveCLIConfig(options); const config = await resolveCLIConfig(options);
const connectionList: Connection[] = []; const server = new Server(config);
setupExitWatchdog(connectionList); server.setupExitWatchdog();
if (options.port) if (config.server.port !== undefined)
startHttpTransport(config, +options.port, options.host, connectionList); startHttpTransport(server);
else else
await startStdioTransport(config, connectionList); await startStdioTransport(server);
if (config.saveTrace) { if (config.saveTrace) {
const server = await startTraceViewerServer(); 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[] { function semicolonSeparatedList(value: string): string[] {
return value.split(';').map(v => v.trim()); 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 assert from 'node:assert';
import crypto from 'node:crypto'; import crypto from 'node:crypto';
import debug from 'debug';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.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'; export async function startStdioTransport(server: Server) {
import type { FullConfig } from './config.js'; await server.createConnection(new StdioServerTransport());
export async function startStdioTransport(config: FullConfig, connectionList: Connection[]) {
const connection = await createConnection(config);
await connection.connect(new StdioServerTransport());
connectionList.push(connection);
} }
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') { if (req.method === 'POST') {
const sessionId = url.searchParams.get('sessionId'); const sessionId = url.searchParams.get('sessionId');
if (!sessionId) { if (!sessionId) {
@@ -51,15 +49,13 @@ async function handleSSE(config: FullConfig, req: http.IncomingMessage, res: htt
} else if (req.method === 'GET') { } else if (req.method === 'GET') {
const transport = new SSEServerTransport('/sse', res); const transport = new SSEServerTransport('/sse', res);
sessions.set(transport.sessionId, transport); sessions.set(transport.sessionId, transport);
const connection = await createConnection(config); testDebug(`create SSE session: ${transport.sessionId}`);
await connection.connect(transport); const connection = await server.createConnection(transport);
connectionList.push(connection);
res.on('close', () => { res.on('close', () => {
testDebug(`delete SSE session: ${transport.sessionId}`);
sessions.delete(transport.sessionId); sessions.delete(transport.sessionId);
connection.close().catch(e => { // eslint-disable-next-line no-console
// eslint-disable-next-line no-console void connection.close().catch(e => console.error(e));
console.error(e);
});
}); });
return; return;
} }
@@ -68,7 +64,7 @@ async function handleSSE(config: FullConfig, req: http.IncomingMessage, res: htt
res.end('Method not allowed'); 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; const sessionId = req.headers['mcp-session-id'] as string | undefined;
if (sessionId) { if (sessionId) {
const transport = sessions.get(sessionId); const transport = sessions.get(sessionId);
@@ -91,12 +87,8 @@ async function handleStreamable(config: FullConfig, req: http.IncomingMessage, r
if (transport.sessionId) if (transport.sessionId)
sessions.delete(transport.sessionId); sessions.delete(transport.sessionId);
}; };
const connection = await createConnection(config); await server.createConnection(transport);
connectionList.push(connection); await transport.handleRequest(req, res);
await Promise.all([
connection.connect(transport),
transport.handleRequest(req, res),
]);
return; return;
} }
@@ -104,17 +96,18 @@ async function handleStreamable(config: FullConfig, req: http.IncomingMessage, r
res.end('Invalid request'); 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 sseSessions = new Map<string, SSEServerTransport>();
const streamableSessions = new Map<string, StreamableHTTPServerTransport>(); const streamableSessions = new Map<string, StreamableHTTPServerTransport>();
const httpServer = http.createServer(async (req, res) => { const httpServer = http.createServer(async (req, res) => {
const url = new URL(`http://localhost${req.url}`); const url = new URL(`http://localhost${req.url}`);
if (url.pathname.startsWith('/mcp')) if (url.pathname.startsWith('/mcp'))
await handleStreamable(config, req, res, streamableSessions, connectionList); await handleStreamable(server, req, res, streamableSessions);
else 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(); const address = httpServer.address();
assert(address, 'Could not bind server socket'); assert(address, 'Could not bind server socket');
let url: string; let url: string;

View File

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

View File

@@ -18,7 +18,7 @@ import { test, expect } from './fixtures.js';
test('cdp server', async ({ cdpServer, startClient, server }) => { test('cdp server', async ({ cdpServer, startClient, server }) => {
await cdpServer.start(); 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({ expect(await client.callTool({
name: 'browser_navigate', name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD }, 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 }) => { test('cdp server reuse tab', async ({ cdpServer, startClient, server }) => {
const browserContext = await cdpServer.start(); 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(); const [page] = browserContext.pages();
await page.goto(server.HELLO_WORLD); 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 }) => { 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('/', ` server.setContent('/', `
<title>Title</title> <title>Title</title>

View File

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

View File

@@ -17,7 +17,7 @@
import { test, expect } from './fixtures.js'; import { test, expect } from './fixtures.js';
test('--device should work', async ({ startClient, server }) => { test('--device should work', async ({ startClient, server }) => {
const client = await startClient({ const { client } = await startClient({
args: ['--device', 'iPhone 15'], 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) => { test('clicking on download link emits download', async ({ startClient, server }, testInfo) => {
const client = await startClient({ const { client } = await startClient({
config: { outputDir: testInfo.outputPath('output') }, 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) => { 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') }, config: { outputDir: testInfo.outputPath('output') },
}); });

View File

@@ -40,7 +40,7 @@ type CDPServer = {
type TestFixtures = { type TestFixtures = {
client: Client; client: Client;
visionClient: 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; wsEndpoint: string;
cdpServer: CDPServer; cdpServer: CDPServer;
server: TestServer; server: TestServer;
@@ -55,11 +55,13 @@ type WorkerFixtures = {
export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>({ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>({
client: async ({ startClient }, use) => { client: async ({ startClient }, use) => {
await use(await startClient()); const { client } = await startClient();
await use(client);
}, },
visionClient: async ({ startClient }, use) => { 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) => { 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' }); client = new Client({ name: options?.clientName ?? 'test', version: '1.0.0' });
const transport = createTransport(args, mcpMode); const transport = createTransport(args, mcpMode);
let stderr = '';
transport.stderr?.on('data', data => {
stderr += data.toString();
});
await client.connect(transport); await client.connect(transport);
await client.ping(); await client.ping();
return client; return { client, stderr: () => stderr };
}); });
await client?.close(); await client?.close();
@@ -168,7 +174,13 @@ function createTransport(args: string[], mcpMode: TestOptions['mcpMode']) {
command: 'node', command: 'node',
args: [path.join(path.dirname(__filename), '../cli.js'), ...args], args: [path.join(path.dirname(__filename), '../cli.js'), ...args],
cwd: path.join(path.dirname(__filename), '..'), 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 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({ await client.callTool({
name: 'browser_navigate', name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD }, arguments: { url: server.HELLO_WORLD },
@@ -32,10 +33,31 @@ test('test reopen browser', async ({ client, server }) => {
name: 'browser_navigate', name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD }, arguments: { url: server.HELLO_WORLD },
})).toContainTextContent(`- generic [ref=e1]: 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 }) => { 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({ const response = await client.callTool({
name: 'browser_navigate', name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD }, arguments: { url: server.HELLO_WORLD },
@@ -53,7 +75,7 @@ test('persistent context', async ({ startClient, server }) => {
</script> </script>
`, 'text/html'); `, 'text/html');
const client = await startClient(); const { client } = await startClient();
const response = await client.callTool({ const response = await client.callTool({
name: 'browser_navigate', name: 'browser_navigate',
arguments: { url: server.PREFIX }, arguments: { url: server.PREFIX },
@@ -66,7 +88,7 @@ test('persistent context', async ({ startClient, server }) => {
name: 'browser_close', name: 'browser_close',
}); });
const client2 = await startClient(); const { client: client2 } = await startClient();
const response2 = await client2.callTool({ const response2 = await client2.callTool({
name: 'browser_navigate', name: 'browser_navigate',
arguments: { url: server.PREFIX }, arguments: { url: server.PREFIX },
@@ -85,18 +107,18 @@ test('isolated context', async ({ startClient, server }) => {
</script> </script>
`, 'text/html'); `, 'text/html');
const client = await startClient({ args: [`--isolated`] }); const { client: client1 } = await startClient({ args: [`--isolated`] });
const response = await client.callTool({ const response = await client1.callTool({
name: 'browser_navigate', name: 'browser_navigate',
arguments: { url: server.PREFIX }, arguments: { url: server.PREFIX },
}); });
expect(response).toContainTextContent(`Storage: NO`); expect(response).toContainTextContent(`Storage: NO`);
await client.callTool({ await client1.callTool({
name: 'browser_close', name: 'browser_close',
}); });
const client2 = await startClient({ args: [`--isolated`] }); const { client: client2 } = await startClient({ args: [`--isolated`] });
const response2 = await client2.callTool({ const response2 = await client2.callTool({
name: 'browser_navigate', name: 'browser_navigate',
arguments: { url: server.PREFIX }, arguments: { url: server.PREFIX },
@@ -123,7 +145,7 @@ test('isolated context with storage state', async ({ startClient, server }, test
</script> </script>
`, 'text/html'); `, 'text/html');
const client = await startClient({ args: [ const { client } = await startClient({ args: [
`--isolated`, `--isolated`,
`--storage-state=${storageStatePath}`, `--storage-state=${storageStatePath}`,
] }); ] });

View File

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

View File

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

View File

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

View File

@@ -14,96 +14,233 @@
* limitations under the License. * limitations under the License.
*/ */
import fs from 'node:fs';
import url from 'node:url'; 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 path from 'node:path';
import type { AddressInfo } from 'node:net';
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; 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 { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { createConnection } from '@playwright/mcp';
import { test as baseTest, expect } from './fixtures.js'; 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. // 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 __filename = url.fileURLToPath(import.meta.url);
const test = baseTest.extend<{ serverEndpoint: string }>({ const test = baseTest.extend<{ serverEndpoint: (options?: { args?: string[], noPort?: boolean }) => Promise<{ url: URL, stderr: () => string }> }>({
serverEndpoint: async ({}, use) => { serverEndpoint: async ({ mcpHeadless }, use, testInfo) => {
const cp = spawn('node', [path.join(path.dirname(__filename), '../cli.js'), '--port', '0'], { stdio: 'pipe' }); let cp: ChildProcess | undefined;
try { 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 = ''; 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(); stderr += data.toString();
const match = stderr.match(/Listening on (http:\/\/.*)/); const match = stderr.match(/Listening on (http:\/\/.*)/);
if (match) if (match)
resolve(match[1]); resolve(match[1]);
})); }));
await use(url); return { url: new URL(url), stderr: () => stderr };
} finally { });
cp.kill(); cp?.kill('SIGTERM');
}
}, },
}); });
test('sse transport', async ({ serverEndpoint }) => { 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' }); const client = new Client({ name: 'test', version: '1.0.0' });
await client.connect(transport); await client.connect(transport);
await client.ping(); 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 }) => { 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' }); const client = new Client({ name: 'test', version: '1.0.0' });
await client.connect(transport); await client.connect(transport);
await client.ping(); await client.ping();
expect(transport.sessionId, 'has session support').toBeDefined(); 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 browserContext = await cdpServer.start();
const pages = browserContext.pages(); 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({ await client.callTool({
name: 'browser_navigate', name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD }, 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) => { test('check that trace is saved', async ({ startClient, server }, testInfo) => {
const outputDir = testInfo.outputPath('output'); const outputDir = testInfo.outputPath('output');
const client = await startClient({ const { client } = await startClient({
args: ['--save-trace', `--output-dir=${outputDir}`], args: ['--save-trace', `--output-dir=${outputDir}`],
}); });