10 Commits

Author SHA1 Message Date
Max Schmitt
128e75b9f4 devops: fix npm publishing due to proverance (#112)
Like
[upstream](3ad5c2731a/.github/workflows/publish_release_npm.yml (L15))
and in the
[docs](https://docs.npmjs.com/generating-provenance-statements#example-github-actions-workflow).
2025-04-02 00:37:13 +02:00
Pavel Feldman
2366dbf36c chore: mark v0.0.8 (#111) 2025-04-01 15:16:28 -07:00
Pavel Feldman
0de7c0d38c chore: follow up with iframe stitch (#110) 2025-04-01 15:10:23 -07:00
Simon Knott
0a5518b252 chore: stitch together iframes into one tree (#71) 2025-04-01 14:47:53 -07:00
Pavel Feldman
4f16786432 chore: merge browser and channel settings (#100) 2025-04-01 10:26:48 -07:00
Pavel Feldman
9042c03faa chore: support channel and executable path params (#90)
Fixes https://github.com/microsoft/playwright-mcp/issues/89
2025-03-31 15:30:08 -07:00
Pavel Feldman
d316441142 chore: sanitize file path when saving (#99)
Fixes https://github.com/microsoft/playwright-mcp/issues/96
2025-03-31 15:01:58 -07:00
Yoshiki Nakagawa
aeb4cf65e9 Fixed typo in README.md (#88) 2025-03-31 09:33:38 +01:00
Pavel Feldman
a7392fc266 chore: allow passing cdp endpoint (#86)
Fixes https://github.com/microsoft/playwright-mcp/issues/84
2025-03-30 09:05:58 -07:00
Max Schmitt
88fbf50841 devops: use --provenance when publishing to NPM (#83)
Similar to how we do it upstream:
e2c8163b14/utils/publish_all_packages.sh (L97)

Reference: https://docs.npmjs.com/generating-provenance-statements
2025-03-29 19:17:54 +01:00
12 changed files with 340 additions and 108 deletions

View File

@@ -5,6 +5,9 @@ on:
jobs: jobs:
publish-npm: publish-npm:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
@@ -15,6 +18,6 @@ jobs:
- run: npm run build - run: npm run build
- run: npm run lint - run: npm run lint
- run: npm run test - run: npm run test
- run: npm publish - run: npm publish --provenance
env: env:
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}

View File

@@ -59,9 +59,25 @@ code-insiders --add-mcp '{"name":"playwright","command":"npx","args":["@playwrig
After installation, the Playwright MCP server will be available for use with your GitHub Copilot agent in VS Code. After installation, the Playwright MCP server will be available for use with your GitHub Copilot agent in VS Code.
### CLI Options
The Playwright MCP server supports the following command-line options:
- `--browser <browser>`: Browser or chrome channel to use. Possible values:
- `chrome`, `firefox`, `webkit`, `msedge`
- Chrome channels: `chrome-beta`, `chrome-canary`, `chrome-dev`
- Edge channels: `msedge-beta`, `msedge-canary`, `msedge-dev`
- Default: `chrome`
- `--cdp-endpoint <endpoint>`: CDP endpoint to connect to
- `--executable-path <path>`: Path to the browser executable
- `--headless`: Run browser in headless mode (headed by default)
- `--port <port>`: Port to listen on for SSE transport
- `--user-data-dir <path>`: Path to the user data directory
- `--vision`: Run server that uses screenshots (Aria snapshots are used by default)
### User data directory ### User data directory
Playwright MCP will launch Chrome browser with the new profile, located at Playwright MCP will launch the browser with the new profile, located at
``` ```
- `%USERPROFILE%\AppData\Local\ms-playwright\mcp-chrome-profile` on Windows - `%USERPROFILE%\AppData\Local\ms-playwright\mcp-chrome-profile` on Windows
@@ -69,7 +85,7 @@ Playwright MCP will launch Chrome browser with the new profile, located at
- `~/.cache/ms-playwright/mcp-chrome-profile` on Linux - `~/.cache/ms-playwright/mcp-chrome-profile` on Linux
``` ```
All the logged in information will be stored in that profile, you can delete it between sessions if you'dlike to clear the offline state. All the logged in information will be stored in that profile, you can delete it between sessions if you'd like to clear the offline state.
### Running headless browser (Browser without GUI). ### Running headless browser (Browser without GUI).

43
package-lock.json generated
View File

@@ -1,17 +1,18 @@
{ {
"name": "@playwright/mcp", "name": "@playwright/mcp",
"version": "0.0.7", "version": "0.0.8",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@playwright/mcp", "name": "@playwright/mcp",
"version": "0.0.7", "version": "0.0.8",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.6.1", "@modelcontextprotocol/sdk": "^1.6.1",
"commander": "^13.1.0", "commander": "^13.1.0",
"playwright": "1.52.0-alpha-1743011787000", "playwright": "^1.52.0-alpha-1743163434000",
"yaml": "^2.7.1",
"zod-to-json-schema": "^3.24.4" "zod-to-json-schema": "^3.24.4"
}, },
"bin": { "bin": {
@@ -20,7 +21,7 @@
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.2.0", "@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.19.0", "@eslint/js": "^9.19.0",
"@playwright/test": "1.52.0-alpha-1743011787000", "@playwright/test": "^1.52.0-alpha-1743163434000",
"@stylistic/eslint-plugin": "^3.0.1", "@stylistic/eslint-plugin": "^3.0.1",
"@types/node": "^22.13.10", "@types/node": "^22.13.10",
"@typescript-eslint/eslint-plugin": "^8.26.1", "@typescript-eslint/eslint-plugin": "^8.26.1",
@@ -285,13 +286,13 @@
} }
}, },
"node_modules/@playwright/test": { "node_modules/@playwright/test": {
"version": "1.52.0-alpha-1743011787000", "version": "1.52.0-alpha-1743163434000",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.52.0-alpha-1743011787000.tgz", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.52.0-alpha-1743163434000.tgz",
"integrity": "sha512-ikJR8JXof5IBvErrmIsR3ixov4nKlQe/6PSYK/R6eTEe6eoT+eEXlaNY4z6mn9dF02Z1zYGxzAbb8TvSvuwh4Q==", "integrity": "sha512-4uBgNlJ6hgPtB8DrwQsgoKuVoe7j+nPqudna7CLXWCmmT3LYPMD5aOjGoBkszr+R9NejtKashq/bOi/ny9hsIA==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright": "1.52.0-alpha-1743011787000" "playwright": "1.52.0-alpha-1743163434000"
}, },
"bin": { "bin": {
"playwright": "cli.js" "playwright": "cli.js"
@@ -3296,12 +3297,12 @@
} }
}, },
"node_modules/playwright": { "node_modules/playwright": {
"version": "1.52.0-alpha-1743011787000", "version": "1.52.0-alpha-1743163434000",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.52.0-alpha-1743011787000.tgz", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.52.0-alpha-1743163434000.tgz",
"integrity": "sha512-wg9Tu4ZDKJWo7hBKpeuD/XLtLOQ7fCCuBfekgUrPLStA12O3224E1fbp/xGFnmi47SF71Y8F6C2Beyd3gYFWlQ==", "integrity": "sha512-4uYv49ekPjolydfFfTfFQ2z4URF9UZMVUXLy7aXam/tPxEQ5O7+jQC+yzrDMGmhcj5QkMnxjlyk7N2V9a0QLdQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright-core": "1.52.0-alpha-1743011787000" "playwright-core": "1.52.0-alpha-1743163434000"
}, },
"bin": { "bin": {
"playwright": "cli.js" "playwright": "cli.js"
@@ -3314,9 +3315,9 @@
} }
}, },
"node_modules/playwright-core": { "node_modules/playwright-core": {
"version": "1.52.0-alpha-1743011787000", "version": "1.52.0-alpha-1743163434000",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.52.0-alpha-1743011787000.tgz", "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.52.0-alpha-1743163434000.tgz",
"integrity": "sha512-yOpMfKxTBRqdm50b52cojvTCNttWN+Xk6LXF+KU4ufcGwcRjUud1xdHmHHvQNFFanXM1MBYnDKsMkRvjPsuYOw==", "integrity": "sha512-Tn4u3Ywwjkh847/bYWlXIrNxv5DRJRDgtb+VYMXHvNCKkrxL6yfZ1ApIAYD7IAkkKH/KLTXszGWl3a/Z/KDfQA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"playwright-core": "cli.js" "playwright-core": "cli.js"
@@ -4348,6 +4349,18 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/yaml": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.1.tgz",
"integrity": "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/yocto-queue": { "node_modules/yocto-queue": {
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@playwright/mcp", "name": "@playwright/mcp",
"version": "0.0.7", "version": "0.0.8",
"description": "Playwright Tools for MCP", "description": "Playwright Tools for MCP",
"repository": { "repository": {
"type": "git", "type": "git",
@@ -32,18 +32,19 @@
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.6.1", "@modelcontextprotocol/sdk": "^1.6.1",
"commander": "^13.1.0", "commander": "^13.1.0",
"playwright": "1.52.0-alpha-1743011787000", "playwright": "^1.52.0-alpha-1743163434000",
"yaml": "^2.7.1",
"zod-to-json-schema": "^3.24.4" "zod-to-json-schema": "^3.24.4"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.2.0", "@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.19.0", "@eslint/js": "^9.19.0",
"@playwright/test": "1.52.0-alpha-1743011787000", "@playwright/test": "^1.52.0-alpha-1743163434000",
"@stylistic/eslint-plugin": "^3.0.1", "@stylistic/eslint-plugin": "^3.0.1",
"@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",
"@typescript-eslint/utils": "^8.26.1", "@typescript-eslint/utils": "^8.26.1",
"@types/node": "^22.13.10",
"eslint": "^9.19.0", "eslint": "^9.19.0",
"eslint-plugin-import": "^2.31.0", "eslint-plugin-import": "^2.31.0",
"eslint-plugin-notice": "^1.0.0", "eslint-plugin-notice": "^1.0.0",

View File

@@ -14,21 +14,31 @@
* limitations under the License. * limitations under the License.
*/ */
import { fork } from 'child_process';
import path from 'path';
import * as playwright from 'playwright'; import * as playwright from 'playwright';
import yaml from 'yaml';
export type ContextOptions = {
browserName?: 'chromium' | 'firefox' | 'webkit';
userDataDir: string;
launchOptions?: playwright.LaunchOptions;
cdpEndpoint?: string;
remoteEndpoint?: string;
};
export class Context { export class Context {
private _userDataDir: string; private _options: ContextOptions;
private _launchOptions: playwright.LaunchOptions | undefined;
private _browser: playwright.Browser | undefined; private _browser: playwright.Browser | undefined;
private _page: playwright.Page | undefined; private _page: playwright.Page | undefined;
private _console: playwright.ConsoleMessage[] = []; private _console: playwright.ConsoleMessage[] = [];
private _createPagePromise: Promise<playwright.Page> | undefined; private _createPagePromise: Promise<playwright.Page> | undefined;
private _fileChooser: playwright.FileChooser | undefined; private _fileChooser: playwright.FileChooser | undefined;
private _lastSnapshotFrames: playwright.FrameLocator[] = []; private _lastSnapshotFrames: (playwright.Page | playwright.FrameLocator)[] = [];
constructor(userDataDir: string, launchOptions?: playwright.LaunchOptions) { constructor(options: ContextOptions) {
this._userDataDir = userDataDir; this._options = options;
this._launchOptions = launchOptions;
} }
async createPage(): Promise<playwright.Page> { async createPage(): Promise<playwright.Page> {
@@ -64,6 +74,25 @@ export class Context {
this._console.length = 0; this._console.length = 0;
} }
async install(): Promise<string> {
const channel = this._options.launchOptions?.channel ?? this._options.browserName ?? 'chrome';
const cli = path.join(require.resolve('playwright/package.json'), '..', 'cli.js');
const child = fork(cli, ['install', channel], {
stdio: 'pipe',
});
const output: string[] = [];
child.stdout?.on('data', data => output.push(data.toString()));
child.stderr?.on('data', data => output.push(data.toString()));
return new Promise((resolve, reject) => {
child.on('close', code => {
if (code === 0)
resolve(channel);
else
reject(new Error(`Failed to install browser: ${output.join('')}`));
});
});
}
existingPage(): playwright.Page { existingPage(): playwright.Page {
if (!this._page) if (!this._page)
throw new Error('Navigate to a location to create a page'); throw new Error('Navigate to a location to create a page');
@@ -96,55 +125,98 @@ export class Context {
} }
private async _createPage(): Promise<{ browser?: playwright.Browser, page: playwright.Page }> { private async _createPage(): Promise<{ browser?: playwright.Browser, page: playwright.Page }> {
if (process.env.PLAYWRIGHT_WS_ENDPOINT) { if (this._options.remoteEndpoint) {
const url = new URL(process.env.PLAYWRIGHT_WS_ENDPOINT); const url = new URL(this._options.remoteEndpoint);
if (this._launchOptions) if (this._options.browserName)
url.searchParams.set('launch-options', JSON.stringify(this._launchOptions)); url.searchParams.set('browser', this._options.browserName);
const browser = await playwright.chromium.connect(String(url)); if (this._options.launchOptions)
url.searchParams.set('launch-options', JSON.stringify(this._options.launchOptions));
const browser = await playwright[this._options.browserName ?? 'chromium'].connect(String(url));
const page = await browser.newPage(); const page = await browser.newPage();
return { browser, page }; return { browser, page };
} }
const context = await playwright.chromium.launchPersistentContext(this._userDataDir, this._launchOptions); if (this._options.cdpEndpoint) {
const browser = await playwright.chromium.connectOverCDP(this._options.cdpEndpoint);
const browserContext = browser.contexts()[0];
let [page] = browserContext.pages();
if (!page)
page = await browserContext.newPage();
return { browser, page };
}
const context = await this._launchPersistentContext();
const [page] = context.pages(); const [page] = context.pages();
return { page }; return { page };
} }
private async _launchPersistentContext(): Promise<playwright.BrowserContext> {
try {
const browserType = this._options.browserName ? playwright[this._options.browserName] : playwright.chromium;
return await browserType.launchPersistentContext(this._options.userDataDir, this._options.launchOptions);
} 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 allFramesSnapshot() { async allFramesSnapshot() {
const page = this.existingPage(); this._lastSnapshotFrames = [];
const visibleFrames = await page.locator('iframe').filter({ visible: true }).all(); const yaml = await this._allFramesSnapshot(this.existingPage());
this._lastSnapshotFrames = visibleFrames.map(frame => frame.contentFrame()); return yaml.toString().trim();
}
const snapshots = await Promise.all([ private async _allFramesSnapshot(frame: playwright.Page | playwright.FrameLocator): Promise<yaml.Document> {
page.locator('html').ariaSnapshot({ ref: true }), const frameIndex = this._lastSnapshotFrames.push(frame) - 1;
...this._lastSnapshotFrames.map(async (frame, index) => { const snapshotString = await frame.locator('body').ariaSnapshot({ ref: true });
const snapshot = await frame.locator('html').ariaSnapshot({ ref: true }); const snapshot = yaml.parseDocument(snapshotString);
const args = [];
const src = await frame.owner().getAttribute('src');
if (src)
args.push(`src=${src}`);
const name = await frame.owner().getAttribute('name');
if (name)
args.push(`name=${name}`);
return `\n# iframe ${args.join(' ')}\n` + snapshot.replaceAll('[ref=', `[ref=f${index}`);
})
]);
return snapshots.join('\n'); const visit = async (node: any): Promise<unknown> => {
if (yaml.isPair(node)) {
await Promise.all([
visit(node.key).then(k => node.key = k),
visit(node.value).then(v => node.value = v)
]);
} else if (yaml.isSeq(node) || yaml.isMap(node)) {
node.items = await Promise.all(node.items.map(visit));
} else if (yaml.isScalar(node)) {
if (typeof node.value === 'string') {
const value = node.value;
if (frameIndex > 0)
node.value = value.replace('[ref=', `[ref=f${frameIndex}`);
if (value.startsWith('iframe ')) {
const ref = value.match(/\[ref=(.*)\]/)?.[1];
if (ref) {
try {
const childSnapshot = await this._allFramesSnapshot(frame.frameLocator(`aria-ref=${ref}`));
return snapshot.createPair(node.value, childSnapshot);
} catch (error) {
return snapshot.createPair(node.value, '<could not take iframe snapshot>');
}
}
}
}
}
return node;
};
await visit(snapshot.contents);
return snapshot;
} }
refLocator(ref: string): playwright.Locator { refLocator(ref: string): playwright.Locator {
const page = this.existingPage(); let frame = this._lastSnapshotFrames[0];
let frame: playwright.Frame | playwright.FrameLocator = page.mainFrame();
const match = ref.match(/^f(\d+)(.*)/); const match = ref.match(/^f(\d+)(.*)/);
if (match) { if (match) {
const frameIndex = parseInt(match[1], 10); const frameIndex = parseInt(match[1], 10);
if (!this._lastSnapshotFrames[frameIndex])
throw new Error(`Frame does not exist. Provide ref from the most current snapshot.`);
frame = this._lastSnapshotFrames[frameIndex]; frame = this._lastSnapshotFrames[frameIndex];
ref = match[2]; ref = match[2];
} }
if (!frame)
throw new Error(`Frame does not exist. Provide ref from the most current snapshot.`);
return frame.locator(`aria-ref=${ref}`); return frame.locator(`aria-ref=${ref}`);
} }
} }

View File

@@ -30,6 +30,7 @@ const commonTools: Tool[] = [
common.wait, common.wait,
common.pdf, common.pdf,
common.close, common.close,
common.install,
]; ];
const snapshotTools: Tool[] = [ const snapshotTools: Tool[] = [
@@ -64,8 +65,10 @@ const resources: Resource[] = [
]; ];
type Options = { type Options = {
browserName?: 'chromium' | 'firefox' | 'webkit';
userDataDir?: string; userDataDir?: string;
launchOptions?: LaunchOptions; launchOptions?: LaunchOptions;
cdpEndpoint?: string;
vision?: boolean; vision?: boolean;
}; };
@@ -78,7 +81,9 @@ export function createServer(options?: Options): Server {
version: packageJSON.version, version: packageJSON.version,
tools, tools,
resources, resources,
browserName: options?.browserName,
userDataDir: options?.userDataDir ?? '', userDataDir: options?.userDataDir ?? '',
launchOptions: options?.launchOptions, launchOptions: options?.launchOptions,
cdpEndpoint: options?.cdpEndpoint,
}); });
} }

View File

@@ -35,20 +35,56 @@ const packageJSON = require('../package.json');
program program
.version('Version ' + packageJSON.version) .version('Version ' + packageJSON.version)
.name(packageJSON.name) .name(packageJSON.name)
.option('--browser <browser>', 'Browser or chrome channel to use, possible values: chrome, firefox, webkit, msedge.')
.option('--cdp-endpoint <endpoint>', 'CDP endpoint to connect to.')
.option('--executable-path <path>', 'Path to the browser executable.')
.option('--headless', 'Run browser in headless mode, headed by default') .option('--headless', 'Run browser in headless mode, headed by default')
.option('--port <port>', 'Port to listen on for SSE transport.')
.option('--user-data-dir <path>', 'Path to the user data directory') .option('--user-data-dir <path>', 'Path to the user data directory')
.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)')
.option('--port <port>', 'Port to listen on for SSE transport.')
.action(async options => { .action(async options => {
let browserName: 'chromium' | 'firefox' | 'webkit';
let channel: string | undefined;
switch (options.browser) {
case 'chrome':
case 'chrome-beta':
case 'chrome-canary':
case 'chrome-dev':
case 'msedge':
case 'msedge-beta':
case 'msedge-canary':
case 'msedge-dev':
browserName = 'chromium';
channel = options.browser;
break;
case 'chromium':
browserName = 'chromium';
break;
case 'firefox':
browserName = 'firefox';
break;
case 'webkit':
browserName = 'webkit';
break;
default:
browserName = 'chromium';
channel = 'chrome';
}
const launchOptions: LaunchOptions = { const launchOptions: LaunchOptions = {
headless: !!options.headless, headless: !!options.headless,
channel: 'chrome', channel,
executablePath: options.executablePath,
}; };
const userDataDir = options.userDataDir ?? await createUserDataDir();
const userDataDir = options.userDataDir ?? await createUserDataDir(browserName);
const serverList = new ServerList(() => createServer({ const serverList = new ServerList(() => createServer({
browserName,
userDataDir, userDataDir,
launchOptions, launchOptions,
vision: !!options.vision, vision: !!options.vision,
cdpEndpoint: options.cdpEndpoint,
})); }));
setupExitWatchdog(serverList); setupExitWatchdog(serverList);
@@ -70,7 +106,7 @@ function setupExitWatchdog(serverList: ServerList) {
program.parse(process.argv); program.parse(process.argv);
async function createUserDataDir() { async function createUserDataDir(browserName: 'chromium' | 'firefox' | 'webkit') {
let cacheDirectory: string; let cacheDirectory: string;
if (process.platform === 'linux') if (process.platform === 'linux')
cacheDirectory = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache'); cacheDirectory = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
@@ -80,7 +116,7 @@ async function createUserDataDir() {
cacheDirectory = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'); cacheDirectory = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
else else
throw new Error('Unsupported platform: ' + process.platform); throw new Error('Unsupported platform: ' + process.platform);
const result = path.join(cacheDirectory, 'ms-playwright', 'mcp-chrome-profile'); const result = path.join(cacheDirectory, 'ms-playwright', `mcp-${browserName}-profile`);
await fs.promises.mkdir(result, { recursive: true }); await fs.promises.mkdir(result, { recursive: true });
return result; return result;
} }

View File

@@ -21,20 +21,18 @@ import { Context } from './context';
import type { Tool } from './tools/tool'; import type { Tool } from './tools/tool';
import type { Resource } from './resources/resource'; import type { Resource } from './resources/resource';
import type { LaunchOptions } from 'playwright'; import type { ContextOptions } from './context';
type Options = { type Options = ContextOptions & {
name: string; name: string;
version: string; version: string;
tools: Tool[]; tools: Tool[];
resources: Resource[], resources: Resource[],
userDataDir: string;
launchOptions?: LaunchOptions;
}; };
export function createServerWithTools(options: Options): Server { export function createServerWithTools(options: Options): Server {
const { name, version, tools, resources, userDataDir, launchOptions } = options; const { name, version, tools, resources } = options;
const context = new Context(userDataDir, launchOptions); const context = new Context(options);
const server = new Server({ name, version }, { const server = new Server({ name, version }, {
capabilities: { capabilities: {
tools: {}, tools: {},

View File

@@ -20,7 +20,7 @@ import path from 'path';
import { z } from 'zod'; import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema'; import { zodToJsonSchema } from 'zod-to-json-schema';
import { captureAriaSnapshot, runAndWait } from './utils'; import { captureAriaSnapshot, runAndWait, sanitizeForFilePath } from './utils';
import type { ToolFactory, Tool } from './tool'; import type { ToolFactory, Tool } from './tool';
@@ -127,7 +127,7 @@ export const pdf: Tool = {
}, },
handle: async context => { handle: async context => {
const page = context.existingPage(); const page = context.existingPage();
const fileName = path.join(os.tmpdir(), `/page-${new Date().toISOString()}.pdf`); const fileName = path.join(os.tmpdir(), sanitizeForFilePath(`page-${new Date().toISOString()}`)) + '.pdf';
await page.pdf({ path: fileName }); await page.pdf({ path: fileName });
return { return {
content: [{ content: [{
@@ -174,3 +174,20 @@ export const chooseFile: ToolFactory = snapshot => ({
}, snapshot); }, snapshot);
}, },
}); });
export const install: Tool = {
schema: {
name: 'browser_install',
description: 'Install the browser specified in the config. Call this if you get an error about the browser not being installed.',
inputSchema: zodToJsonSchema(z.object({})),
},
handle: async context => {
const channel = await context.install();
return {
content: [{
type: 'text',
text: `Browser ${channel} installed`,
}],
};
},
};

View File

@@ -106,3 +106,7 @@ export async function captureAriaSnapshot(context: Context, status: string = '')
content: [{ type: 'text', text: lines.join('\n') }], content: [{ type: 'text', text: lines.join('\n') }],
}; };
} }
export function sanitizeForFilePath(s: string) {
return s.replace(/[\x00-\x2C\x2E-\x2F\x3A-\x40\x5B-\x60\x7B-\x7F]+/g, '-');
}

View File

@@ -36,6 +36,7 @@ test('test tool list', async ({ client, visionClient }) => {
'browser_wait', 'browser_wait',
'browser_save_as_pdf', 'browser_save_as_pdf',
'browser_close', 'browser_close',
'browser_install',
]); ]);
const { tools: visionTools } = await visionClient.listTools(); const { tools: visionTools } = await visionClient.listTools();
@@ -53,6 +54,7 @@ test('test tool list', async ({ client, visionClient }) => {
'browser_wait', 'browser_wait',
'browser_save_as_pdf', 'browser_save_as_pdf',
'browser_close', 'browser_close',
'browser_install',
]); ]);
}); });
@@ -77,7 +79,7 @@ test('test browser_navigate', async ({ client }) => {
- Page Title: Title - Page Title: Title
- Page Snapshot - Page Snapshot
\`\`\`yaml \`\`\`yaml
- document [ref=s1e2]: Hello, world! - text: Hello, world!
\`\`\` \`\`\`
` `
); );
@@ -95,7 +97,7 @@ test('test browser_click', async ({ client }) => {
name: 'browser_click', name: 'browser_click',
arguments: { arguments: {
element: 'Submit button', element: 'Submit button',
ref: 's1e4', ref: 's1e3',
}, },
})).toHaveTextContent(`"Submit button" clicked })).toHaveTextContent(`"Submit button" clicked
@@ -103,8 +105,7 @@ test('test browser_click', async ({ client }) => {
- Page Title: Title - Page Title: Title
- Page Snapshot - Page Snapshot
\`\`\`yaml \`\`\`yaml
- document [ref=s2e2]: - button "Submit" [ref=s2e3]
- button "Submit" [ref=s2e4]
\`\`\` \`\`\`
`); `);
}); });
@@ -131,7 +132,7 @@ test('test reopen browser', async ({ client }) => {
- Page Title: Title - Page Title: Title
- Page Snapshot - Page Snapshot
\`\`\`yaml \`\`\`yaml
- document [ref=s1e2]: Hello, world! - text: Hello, world!
\`\`\` \`\`\`
`); `);
}); });
@@ -148,7 +149,7 @@ test('single option', async ({ client }) => {
name: 'browser_select_option', name: 'browser_select_option',
arguments: { arguments: {
element: 'Select', element: 'Select',
ref: 's1e4', ref: 's1e3',
values: ['bar'], values: ['bar'],
}, },
})).toHaveTextContent(`Selected option in "Select" })).toHaveTextContent(`Selected option in "Select"
@@ -157,10 +158,9 @@ test('single option', async ({ client }) => {
- Page Title: Title - Page Title: Title
- Page Snapshot - Page Snapshot
\`\`\`yaml \`\`\`yaml
- document [ref=s2e2]: - combobox [ref=s2e3]:
- combobox [ref=s2e4]: - option "Foo" [ref=s2e4]
- option "Foo" [ref=s2e5] - option "Bar" [selected] [ref=s2e5]
- option "Bar" [selected] [ref=s2e6]
\`\`\` \`\`\`
`); `);
}); });
@@ -177,7 +177,7 @@ test('multiple option', async ({ client }) => {
name: 'browser_select_option', name: 'browser_select_option',
arguments: { arguments: {
element: 'Select', element: 'Select',
ref: 's1e4', ref: 's1e3',
values: ['bar', 'baz'], values: ['bar', 'baz'],
}, },
})).toHaveTextContent(`Selected option in "Select" })).toHaveTextContent(`Selected option in "Select"
@@ -186,11 +186,10 @@ test('multiple option', async ({ client }) => {
- Page Title: Title - Page Title: Title
- Page Snapshot - Page Snapshot
\`\`\`yaml \`\`\`yaml
- document [ref=s2e2]: - listbox [ref=s2e3]:
- listbox [ref=s2e4]: - option "Foo" [ref=s2e4]
- option "Foo" [ref=s2e5] - option "Bar" [selected] [ref=s2e5]
- option "Bar" [selected] [ref=s2e6] - option "Baz" [selected] [ref=s2e6]
- option "Baz" [selected] [ref=s2e7]
\`\`\` \`\`\`
`); `);
}); });
@@ -217,21 +216,26 @@ test('stitched aria frames', async ({ client }) => {
expect(await client.callTool({ expect(await client.callTool({
name: 'browser_navigate', name: 'browser_navigate',
arguments: { arguments: {
url: 'data:text/html,<h1>Hello</h1><iframe src="data:text/html,<h1>World</h1>"></iframe><iframe src="data:text/html,<h1>Should be invisible</h1>" style="display: none;"></iframe>', url: `data:text/html,<h1>Hello</h1><iframe src="data:text/html,<button>World</button><main><iframe src='data:text/html,<p>Nested</p>'></iframe></main>"></iframe><iframe src="data:text/html,<h1>Should be invisible</h1>" style="display: none;"></iframe>`,
}, },
})).toHaveTextContent(` })).toContainTextContent(`
- Page URL: data:text/html,<h1>Hello</h1><iframe src="data:text/html,<h1>World</h1>"></iframe><iframe src="data:text/html,<h1>Should be invisible</h1>" style="display: none;"></iframe>
- Page Title:
- Page Snapshot
\`\`\`yaml \`\`\`yaml
- document [ref=s1e2]: - heading "Hello" [level=1] [ref=s1e3]
- heading "Hello" [level=1] [ref=s1e4] - iframe [ref=s1e4]:
- button "World" [ref=f1s1e3]
# iframe src=data:text/html,<h1>World</h1> - main [ref=f1s1e4]:
- document [ref=f0s1e2]: - iframe [ref=f1s1e5]:
- heading "World" [level=1] [ref=f0s1e4] - paragraph [ref=f2s1e3]: Nested
\`\`\` \`\`\`
`); `);
expect(await client.callTool({
name: 'browser_click',
arguments: {
element: 'World',
ref: 'f1s1e3',
},
})).toContainTextContent('"World" clicked');
}); });
test('browser_choose_file', async ({ client }) => { test('browser_choose_file', async ({ client }) => {
@@ -240,13 +244,13 @@ test('browser_choose_file', async ({ client }) => {
arguments: { arguments: {
url: 'data:text/html,<html><title>Title</title><input type="file" /><button>Button</button></html>', url: 'data:text/html,<html><title>Title</title><input type="file" /><button>Button</button></html>',
}, },
})).toContainTextContent('- textbox [ref=s1e4]'); })).toContainTextContent('- textbox [ref=s1e3]');
expect(await client.callTool({ expect(await client.callTool({
name: 'browser_click', name: 'browser_click',
arguments: { arguments: {
element: 'Textbox', element: 'Textbox',
ref: 's1e4', ref: 's1e3',
}, },
})).toContainTextContent('There is a file chooser visible that requires browser_choose_file to be called'); })).toContainTextContent('There is a file chooser visible that requires browser_choose_file to be called');
@@ -262,7 +266,7 @@ test('browser_choose_file', async ({ client }) => {
}); });
expect(response).not.toContainTextContent('There is a file chooser visible that requires browser_choose_file to be called'); expect(response).not.toContainTextContent('There is a file chooser visible that requires browser_choose_file to be called');
expect(response).toContainTextContent('textbox [ref=s3e4]: C:\\fakepath\\test.txt'); expect(response).toContainTextContent('textbox [ref=s3e3]: C:\\fakepath\\test.txt');
} }
{ {
@@ -270,12 +274,12 @@ test('browser_choose_file', async ({ client }) => {
name: 'browser_click', name: 'browser_click',
arguments: { arguments: {
element: 'Textbox', element: 'Textbox',
ref: 's3e4', ref: 's3e3',
}, },
}); });
expect(response).toContainTextContent('There is a file chooser visible that requires browser_choose_file to be called'); expect(response).toContainTextContent('There is a file chooser visible that requires browser_choose_file to be called');
expect(response).toContainTextContent('button "Button" [ref=s4e5]'); expect(response).toContainTextContent('button "Button" [ref=s4e4]');
} }
{ {
@@ -283,7 +287,7 @@ test('browser_choose_file', async ({ client }) => {
name: 'browser_click', name: 'browser_click',
arguments: { arguments: {
element: 'Button', element: 'Button',
ref: 's4e5', ref: 's4e4',
}, },
}); });
@@ -313,3 +317,54 @@ test('sse transport', async () => {
cp.kill(); cp.kill();
} }
}); });
test('cdp server', async ({ cdpEndpoint, startClient }) => {
const client = await startClient({ args: [`--cdp-endpoint=${cdpEndpoint}`] });
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
},
})).toHaveTextContent(`
- Page URL: data:text/html,<html><title>Title</title><body>Hello, world!</body></html>
- Page Title: Title
- Page Snapshot
\`\`\`yaml
- text: Hello, world!
\`\`\`
`
);
});
test('save as pdf', async ({ client }) => {
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
},
})).toHaveTextContent(`
- Page URL: data:text/html,<html><title>Title</title><body>Hello, world!</body></html>
- Page Title: Title
- Page Snapshot
\`\`\`yaml
- text: Hello, world!
\`\`\`
`
);
const response = await client.callTool({
name: 'browser_save_as_pdf',
});
expect(response).toHaveTextContent(/^Saved as.*page-[^:]+.pdf$/);
});
test('executable path', async ({ startClient }) => {
const client = await startClient({ args: [`--executable-path=bogus`] });
const response = await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
},
});
expect(response).toContainTextContent(`executable doesn't exist`);
});

View File

@@ -24,8 +24,9 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
type Fixtures = { type Fixtures = {
client: Client; client: Client;
visionClient: Client; visionClient: Client;
startClient: (options?: { env?: NodeJS.ProcessEnv, vision?: boolean }) => Promise<Client>; startClient: (options?: { args?: string[], vision?: boolean }) => Promise<Client>;
wsEndpoint: string; wsEndpoint: string;
cdpEndpoint: string;
}; };
export const test = baseTest.extend<Fixtures>({ export const test = baseTest.extend<Fixtures>({
@@ -46,6 +47,8 @@ export const test = baseTest.extend<Fixtures>({
const args = ['--headless', '--user-data-dir', userDataDir]; const args = ['--headless', '--user-data-dir', userDataDir];
if (options?.vision) if (options?.vision)
args.push('--vision'); args.push('--vision');
if (options?.args)
args.push(...options.args);
const transport = new StdioClientTransport({ const transport = new StdioClientTransport({
command: 'node', command: 'node',
args: [path.join(__dirname, '../cli.js'), ...args], args: [path.join(__dirname, '../cli.js'), ...args],
@@ -64,20 +67,29 @@ export const test = baseTest.extend<Fixtures>({
await use(browserServer.wsEndpoint()); await use(browserServer.wsEndpoint());
await browserServer.close(); await browserServer.close();
}, },
cdpEndpoint: async ({ }, use, testInfo) => {
const port = 3200 + (+process.env.TEST_PARALLEL_INDEX!);
const browser = await chromium.launchPersistentContext(testInfo.outputPath('user-data-dir'), {
channel: 'chrome',
args: [`--remote-debugging-port=${port}`],
});
await use(`http://localhost:${port}`);
await browser.close();
},
}); });
type Response = Awaited<ReturnType<Client['callTool']>>; type Response = Awaited<ReturnType<Client['callTool']>>;
export const expect = baseExpect.extend({ export const expect = baseExpect.extend({
toHaveTextContent(response: Response, content: string | string[]) { toHaveTextContent(response: Response, content: string | RegExp) {
const isNot = this.isNot; const isNot = this.isNot;
try { try {
content = Array.isArray(content) ? content : [content]; const text = (response.content as any)[0].text;
const texts = (response.content as any).map(c => c.text);
if (isNot) if (isNot)
baseExpect(texts).not.toEqual(content); baseExpect(text).not.toMatch(content);
else else
baseExpect(texts).toEqual(content); baseExpect(text).toMatch(content);
} catch (e) { } catch (e) {
return { return {
pass: isNot, pass: isNot,