Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71e51ea42a | ||
|
|
0c5a104e0f | ||
|
|
606b898a71 | ||
|
|
e729494bd9 | ||
|
|
77080e8ca4 | ||
|
|
31ac1ed191 | ||
|
|
b8ff009b0a | ||
|
|
42167878fb |
3
.github/workflows/ci.yml
vendored
3
.github/workflows/ci.yml
vendored
@@ -22,6 +22,9 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Playwright install
|
||||
run: npx playwright install --with-deps
|
||||
|
||||
- name: Run linting
|
||||
run: npm run lint
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ const urlForWebsites = `vscode:mcp/install?${encodeURIComponent(config)}`;
|
||||
const urlForGithub = `https://insiders.vscode.dev/redirect?url=${encodeURIComponent(urlForWebsites)}`;
|
||||
-->
|
||||
|
||||
[<img alt="Install in VS Code Insiders" src="https://img.shields.io/badge/VS_Code_Insiders-VS_Code_Insiders?style=flat-square&label=Install%20Server&color=24bfa5">](https://insiders.vscode.dev/redirect?url=vscode-insiders%3Amcp%2Finstall%3F%257B%2522name%2522%253A%2522playwright%2522%252C%2522command%2522%253A%2522npx%2522%252C%2522args%2522%253A%255B%2522-y%2522%252C%2522%2540playwright%252Fmcp%2540latest%2522%255D%257D)
|
||||
[<img src="https://img.shields.io/badge/VS_Code-VS_Code?style=flat-square&label=Install%20Server&color=0098FF" alt="Install in VS Code">](https://insiders.vscode.dev/redirect?url=vscode%3Amcp%2Finstall%3F%257B%2522name%2522%253A%2522playwright%2522%252C%2522command%2522%253A%2522npx%2522%252C%2522args%2522%253A%255B%2522-y%2522%252C%2522%2540playwright%252Fmcp%2540latest%2522%255D%257D) [<img alt="Install in VS Code Insiders" src="https://img.shields.io/badge/VS_Code_Insiders-VS_Code_Insiders?style=flat-square&label=Install%20Server&color=24bfa5">](https://insiders.vscode.dev/redirect?url=vscode-insiders%3Amcp%2Finstall%3F%257B%2522name%2522%253A%2522playwright%2522%252C%2522command%2522%253A%2522npx%2522%252C%2522args%2522%253A%255B%2522-y%2522%252C%2522%2540playwright%252Fmcp%2540latest%2522%255D%257D)
|
||||
|
||||
Alternatively, you can install the Playwright MCP server using the VS Code CLI:
|
||||
|
||||
@@ -323,6 +323,3 @@ server.connect(transport);
|
||||
- **browser_install**
|
||||
- Description: Install the browser specified in the config. Call this if you get an error about the browser not being installed.
|
||||
- Parameters: None
|
||||
|
||||
### Vision Mode
|
||||
|
||||
|
||||
14
package-lock.json
generated
14
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@playwright/mcp",
|
||||
"version": "0.0.10",
|
||||
"version": "0.0.11",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@playwright/mcp",
|
||||
"version": "0.0.10",
|
||||
"version": "0.0.11",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.6.1",
|
||||
@@ -377,6 +377,8 @@
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.13.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.10.tgz",
|
||||
"integrity": "sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1019,6 +1021,8 @@
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "13.1.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz",
|
||||
"integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -4188,6 +4192,8 @@
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.20.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
|
||||
"integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -4376,6 +4382,8 @@
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "3.24.2",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.24.2.tgz",
|
||||
"integrity": "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
@@ -4383,6 +4391,8 @@
|
||||
},
|
||||
"node_modules/zod-to-json-schema": {
|
||||
"version": "3.24.4",
|
||||
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.4.tgz",
|
||||
"integrity": "sha512-0uNlcvgabyrni9Ag8Vghj21drk7+7tp7VTwwR7KxxXXc/3pbXz2PHlDgj3cICahgF1kHm4dExBFj7BXrZJXzig==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"zod": "^3.24.1"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@playwright/mcp",
|
||||
"version": "0.0.10",
|
||||
"version": "0.0.11",
|
||||
"description": "Playwright Tools for MCP",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -54,7 +54,7 @@ export class Context {
|
||||
|
||||
currentTab(): Tab {
|
||||
if (!this._currentTab)
|
||||
throw new Error('Navigate to a location to create a tab');
|
||||
throw new Error('No current snapshot available. Capture a snapshot of navigate to a new location first.');
|
||||
return this._currentTab;
|
||||
}
|
||||
|
||||
@@ -236,8 +236,8 @@ class Tab {
|
||||
});
|
||||
}
|
||||
|
||||
async runAndWaitWithSnapshot(callback: (tab: Tab) => Promise<void>, options?: RunOptions): Promise<ToolResult> {
|
||||
return await this.run(callback, {
|
||||
async runAndWaitWithSnapshot(callback: (snapshot: PageSnapshot) => Promise<void>, options?: RunOptions): Promise<ToolResult> {
|
||||
return await this.run(tab => callback(tab.lastSnapshot()), {
|
||||
captureSnapshot: true,
|
||||
waitForCompletion: true,
|
||||
...options,
|
||||
|
||||
@@ -32,7 +32,7 @@ import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import type { LaunchOptions } from 'playwright';
|
||||
|
||||
const snapshotTools: Tool[] = [
|
||||
...common,
|
||||
...common(true),
|
||||
...files(true),
|
||||
...install,
|
||||
...keyboard(true),
|
||||
@@ -43,7 +43,7 @@ const snapshotTools: Tool[] = [
|
||||
];
|
||||
|
||||
const screenshotTools: Tool[] = [
|
||||
...common,
|
||||
...common(false),
|
||||
...files(false),
|
||||
...install,
|
||||
...keyboard(false),
|
||||
|
||||
@@ -74,7 +74,7 @@ program
|
||||
}
|
||||
|
||||
const launchOptions: LaunchOptions = {
|
||||
headless: !!options.headless,
|
||||
headless: options.headless ?? !process.env.DISPLAY,
|
||||
channel,
|
||||
executablePath: options.executablePath,
|
||||
};
|
||||
@@ -100,11 +100,15 @@ program
|
||||
});
|
||||
|
||||
function setupExitWatchdog(serverList: ServerList) {
|
||||
process.stdin.on('close', async () => {
|
||||
const handleExit = async () => {
|
||||
setTimeout(() => process.exit(0), 15000);
|
||||
await serverList.closeAll();
|
||||
process.exit(0);
|
||||
});
|
||||
};
|
||||
|
||||
process.stdin.on('close', handleExit);
|
||||
process.on('SIGINT', handleExit);
|
||||
process.on('SIGTERM', handleExit);
|
||||
}
|
||||
|
||||
program.parse(process.argv);
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
import { z } from 'zod';
|
||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||
|
||||
import type { Tool } from './tool';
|
||||
import type { Tool, ToolFactory } from './tool';
|
||||
|
||||
const waitSchema = z.object({
|
||||
time: z.number().describe('The time to wait in seconds'),
|
||||
@@ -62,7 +62,34 @@ const close: Tool = {
|
||||
},
|
||||
};
|
||||
|
||||
export default [
|
||||
const resizeSchema = z.object({
|
||||
width: z.number().describe('Width of the browser window'),
|
||||
height: z.number().describe('Height of the browser window'),
|
||||
});
|
||||
|
||||
const resize: ToolFactory = captureSnapshot => ({
|
||||
capability: 'core',
|
||||
schema: {
|
||||
name: 'browser_resize',
|
||||
description: 'Resize the browser window',
|
||||
inputSchema: zodToJsonSchema(resizeSchema),
|
||||
},
|
||||
handle: async (context, params) => {
|
||||
const validatedParams = resizeSchema.parse(params);
|
||||
|
||||
const tab = context.currentTab();
|
||||
return await tab.run(
|
||||
tab => tab.page.setViewportSize({ width: validatedParams.width, height: validatedParams.height }),
|
||||
{
|
||||
status: `Resized browser window`,
|
||||
captureSnapshot,
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export default (captureSnapshot: boolean) => [
|
||||
close,
|
||||
wait,
|
||||
resize(captureSnapshot)
|
||||
];
|
||||
|
||||
@@ -28,7 +28,7 @@ const screenshot: Tool = {
|
||||
},
|
||||
|
||||
handle: async context => {
|
||||
const tab = context.currentTab();
|
||||
const tab = await context.ensureTab();
|
||||
const screenshot = await tab.page.screenshot({ type: 'jpeg', quality: 50, scale: 'css' });
|
||||
return {
|
||||
content: [{ type: 'image', data: screenshot.toString('base64'), mimeType: 'image/jpeg' }],
|
||||
|
||||
@@ -29,7 +29,8 @@ const snapshot: Tool = {
|
||||
},
|
||||
|
||||
handle: async context => {
|
||||
return await context.currentTab().run(async () => {}, { captureSnapshot: true });
|
||||
const tab = await context.ensureTab();
|
||||
return await tab.run(async () => {}, { captureSnapshot: true });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -48,8 +49,8 @@ const click: Tool = {
|
||||
|
||||
handle: async (context, params) => {
|
||||
const validatedParams = elementSchema.parse(params);
|
||||
return await context.currentTab().runAndWaitWithSnapshot(async tab => {
|
||||
const locator = tab.lastSnapshot().refLocator(validatedParams.ref);
|
||||
return await context.currentTab().runAndWaitWithSnapshot(async snapshot => {
|
||||
const locator = snapshot.refLocator(validatedParams.ref);
|
||||
await locator.click();
|
||||
}, {
|
||||
status: `Clicked "${validatedParams.element}"`,
|
||||
@@ -74,9 +75,9 @@ const drag: Tool = {
|
||||
|
||||
handle: async (context, params) => {
|
||||
const validatedParams = dragSchema.parse(params);
|
||||
return await context.currentTab().runAndWaitWithSnapshot(async tab => {
|
||||
const startLocator = tab.lastSnapshot().refLocator(validatedParams.startRef);
|
||||
const endLocator = tab.lastSnapshot().refLocator(validatedParams.endRef);
|
||||
return await context.currentTab().runAndWaitWithSnapshot(async snapshot => {
|
||||
const startLocator = snapshot.refLocator(validatedParams.startRef);
|
||||
const endLocator = snapshot.refLocator(validatedParams.endRef);
|
||||
await startLocator.dragTo(endLocator);
|
||||
}, {
|
||||
status: `Dragged "${validatedParams.startElement}" to "${validatedParams.endElement}"`,
|
||||
@@ -94,8 +95,8 @@ const hover: Tool = {
|
||||
|
||||
handle: async (context, params) => {
|
||||
const validatedParams = elementSchema.parse(params);
|
||||
return await context.currentTab().runAndWaitWithSnapshot(async tab => {
|
||||
const locator = tab.lastSnapshot().refLocator(validatedParams.ref);
|
||||
return await context.currentTab().runAndWaitWithSnapshot(async snapshot => {
|
||||
const locator = snapshot.refLocator(validatedParams.ref);
|
||||
await locator.hover();
|
||||
}, {
|
||||
status: `Hovered over "${validatedParams.element}"`,
|
||||
@@ -119,8 +120,8 @@ const type: Tool = {
|
||||
|
||||
handle: async (context, params) => {
|
||||
const validatedParams = typeSchema.parse(params);
|
||||
return await context.currentTab().runAndWaitWithSnapshot(async tab => {
|
||||
const locator = tab.lastSnapshot().refLocator(validatedParams.ref);
|
||||
return await context.currentTab().runAndWaitWithSnapshot(async snapshot => {
|
||||
const locator = snapshot.refLocator(validatedParams.ref);
|
||||
if (validatedParams.slowly)
|
||||
await locator.pressSequentially(validatedParams.text);
|
||||
else
|
||||
@@ -147,8 +148,8 @@ const selectOption: Tool = {
|
||||
|
||||
handle: async (context, params) => {
|
||||
const validatedParams = selectOptionSchema.parse(params);
|
||||
return await context.currentTab().runAndWaitWithSnapshot(async tab => {
|
||||
const locator = tab.lastSnapshot().refLocator(validatedParams.ref);
|
||||
return await context.currentTab().runAndWaitWithSnapshot(async snapshot => {
|
||||
const locator = snapshot.refLocator(validatedParams.ref);
|
||||
await locator.selectOption(validatedParams.values);
|
||||
}, {
|
||||
status: `Selected option in "${validatedParams.element}"`,
|
||||
|
||||
@@ -89,7 +89,7 @@ const closeTab: ToolFactory = captureSnapshot => ({
|
||||
handle: async (context, params) => {
|
||||
const validatedParams = closeTabSchema.parse(params);
|
||||
await context.closeTab(validatedParams.index);
|
||||
const currentTab = await context.currentTab();
|
||||
const currentTab = context.currentTab();
|
||||
if (currentTab)
|
||||
return await currentTab.run(async () => {}, { captureSnapshot });
|
||||
return {
|
||||
|
||||
@@ -233,3 +233,22 @@ test('browser_type (slowly)', async ({ client }) => {
|
||||
].join('\n'),
|
||||
}]);
|
||||
});
|
||||
|
||||
test('browser_resize', async ({ client }) => {
|
||||
await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: {
|
||||
url: 'data:text/html,<html><title>Resize Test</title><body><div id="size">Waiting for resize...</div><script>new ResizeObserver(() => { document.getElementById("size").textContent = `Window size: ${window.innerWidth}x${window.innerHeight}`; }).observe(document.body);</script></body></html>',
|
||||
},
|
||||
});
|
||||
|
||||
const response = await client.callTool({
|
||||
name: 'browser_resize',
|
||||
arguments: {
|
||||
width: 390,
|
||||
height: 780,
|
||||
},
|
||||
});
|
||||
expect(response).toContainTextContent('Resized browser window');
|
||||
expect(response).toContainTextContent('Window size: 390x780');
|
||||
});
|
||||
|
||||
@@ -32,6 +32,7 @@ test('test snapshot tool list', async ({ client }) => {
|
||||
'browser_navigate',
|
||||
'browser_pdf_save',
|
||||
'browser_press_key',
|
||||
'browser_resize',
|
||||
'browser_snapshot',
|
||||
'browser_tab_close',
|
||||
'browser_tab_list',
|
||||
@@ -53,6 +54,7 @@ test('test vision tool list', async ({ visionClient }) => {
|
||||
'browser_navigate',
|
||||
'browser_pdf_save',
|
||||
'browser_press_key',
|
||||
'browser_resize',
|
||||
'browser_screen_capture',
|
||||
'browser_screen_click',
|
||||
'browser_screen_drag',
|
||||
|
||||
@@ -35,3 +35,27 @@ Navigated to data:text/html,<html><title>Title</title><body>Hello, world!</body>
|
||||
`
|
||||
);
|
||||
});
|
||||
|
||||
test('cdp server reuse tab', async ({ cdpEndpoint, startClient }) => {
|
||||
const client = await startClient({ args: [`--cdp-endpoint=${cdpEndpoint}`] });
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_click',
|
||||
arguments: {
|
||||
element: 'Hello, world!',
|
||||
ref: 'f0',
|
||||
},
|
||||
})).toHaveTextContent(`Error: No current snapshot available. Capture a snapshot of navigate to a new location first.`);
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_snapshot',
|
||||
arguments: {},
|
||||
})).toHaveTextContent(`
|
||||
- Page URL: data:text/html,hello world
|
||||
- Page Title:
|
||||
- Page Snapshot
|
||||
\`\`\`yaml
|
||||
- text: hello world
|
||||
\`\`\`
|
||||
`);
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@ import { chromium } from 'playwright';
|
||||
import { test as baseTest, expect as baseExpect } from '@playwright/test';
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { spawn } from 'child_process';
|
||||
|
||||
type Fixtures = {
|
||||
client: Client;
|
||||
@@ -68,12 +69,25 @@ export const test = baseTest.extend<Fixtures>({
|
||||
|
||||
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}`],
|
||||
const executablePath = chromium.executablePath();
|
||||
const browserProcess = spawn(executablePath, [
|
||||
`--user-data-dir=${testInfo.outputPath('user-data-dir')}`,
|
||||
`--remote-debugging-port=${port}`,
|
||||
`--no-first-run`,
|
||||
`--no-sandbox`,
|
||||
`--headless`,
|
||||
`data:text/html,hello world`,
|
||||
], {
|
||||
stdio: 'pipe',
|
||||
});
|
||||
await new Promise<void>(resolve => {
|
||||
browserProcess.stderr.on('data', data => {
|
||||
if (data.toString().includes('DevTools listening on '))
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
await use(`http://localhost:${port}`);
|
||||
await browser.close();
|
||||
browserProcess.kill();
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user