@playwright/mcp 0.0.4 → 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -93,27 +93,19 @@ This mode is useful for background or batch operations.
93
93
  ### Running headed browser on Linux w/o DISPLAY
94
94
 
95
95
  When running headed browser on system w/o display or from worker processes of the IDEs,
96
- you can run Playwright in a client-server manner. You'll run the Playwright server
97
- from environment with the DISPLAY
96
+ run the MCP server from environment with the DISPLAY and pass the `--port` flag to enable SSE transport.
98
97
 
99
- ```sh
100
- npx playwright run-server
98
+ ```bash
99
+ npx @playwright/mcp@latest --port 8931
101
100
  ```
102
101
 
103
- And then in MCP config, add following to the `env`:
102
+ And then in MCP client config, set the `url` to the SSE endpoint:
104
103
 
105
104
  ```js
106
105
  {
107
106
  "mcpServers": {
108
107
  "playwright": {
109
- "command": "npx",
110
- "args": [
111
- "@playwright/mcp@latest"
112
- ],
113
- "env": {
114
- // Use the endpoint from the output of the server above.
115
- "PLAYWRIGHT_WS_ENDPOINT": "ws://localhost:<port>/"
116
- }
108
+ "url": "http://localhost:8931/sse"
117
109
  }
118
110
  }
119
111
  }
@@ -211,6 +203,11 @@ The Playwright MCP provides a set of tools for browser automation. Here are all
211
203
  - `ref` (string): Exact target element reference from the page snapshot
212
204
  - `values` (array): Array of values to select in the dropdown.
213
205
 
206
+ - **browser_choose_file**
207
+ - Description: Choose one or multiple files to upload
208
+ - Parameters:
209
+ - `paths` (array): The absolute paths to the files to upload. Can be a single file or multiple files.
210
+
214
211
  - **browser_press_key**
215
212
  - Description: Press a key on the keyboard
216
213
  - Parameters:
@@ -224,6 +221,11 @@ The Playwright MCP provides a set of tools for browser automation. Here are all
224
221
  - Description: Save page as PDF
225
222
  - Parameters: None
226
223
 
224
+ - **browser_take_screenshot**
225
+ - Description: Capture screenshot of the page
226
+ - Parameters:
227
+ - `raw` (string): Optionally returns lossless PNG screenshot. JPEG by default.
228
+
227
229
  - **browser_wait**
228
230
  - Description: Wait for a specified time in seconds
229
231
  - Parameters:
@@ -286,6 +288,11 @@ Vision Mode provides tools for visual-based interactions using screenshots. Here
286
288
  - Parameters:
287
289
  - `key` (string): Name of the key to press or a character to generate, such as `ArrowLeft` or `a`
288
290
 
291
+ - **browser_choose_file**
292
+ - Description: Choose one or multiple files to upload
293
+ - Parameters:
294
+ - `paths` (array): The absolute paths to the files to upload. Can be a single file or multiple files.
295
+
289
296
  - **browser_save_as_pdf**
290
297
  - Description: Save page as PDF
291
298
  - Parameters: None
package/lib/context.js CHANGED
@@ -57,6 +57,8 @@ class Context {
57
57
  _page;
58
58
  _console = [];
59
59
  _createPagePromise;
60
+ _fileChooser;
61
+ _lastSnapshotFrames = [];
60
62
  constructor(userDataDir, launchOptions) {
61
63
  this._userDataDir = userDataDir;
62
64
  this._launchOptions = launchOptions;
@@ -72,6 +74,9 @@ class Context {
72
74
  this._console.length = 0;
73
75
  });
74
76
  page.on('close', () => this._onPageClose());
77
+ page.on('filechooser', chooser => this._fileChooser = chooser);
78
+ page.setDefaultNavigationTimeout(60000);
79
+ page.setDefaultTimeout(5000);
75
80
  this._page = page;
76
81
  this._browser = browser;
77
82
  return page;
@@ -85,9 +90,10 @@ class Context {
85
90
  this._createPagePromise = undefined;
86
91
  this._browser = undefined;
87
92
  this._page = undefined;
93
+ this._fileChooser = undefined;
88
94
  this._console.length = 0;
89
95
  }
90
- async existingPage() {
96
+ existingPage() {
91
97
  if (!this._page)
92
98
  throw new Error('Navigate to a location to create a page');
93
99
  return this._page;
@@ -100,6 +106,18 @@ class Context {
100
106
  return;
101
107
  await this._page.close();
102
108
  }
109
+ async submitFileChooser(paths) {
110
+ if (!this._fileChooser)
111
+ throw new Error('No file chooser visible');
112
+ await this._fileChooser.setFiles(paths);
113
+ this._fileChooser = undefined;
114
+ }
115
+ hasFileChooser() {
116
+ return !!this._fileChooser;
117
+ }
118
+ clearFileChooser() {
119
+ this._fileChooser = undefined;
120
+ }
103
121
  async _createPage() {
104
122
  if (process.env.PLAYWRIGHT_WS_ENDPOINT) {
105
123
  const url = new URL(process.env.PLAYWRIGHT_WS_ENDPOINT);
@@ -113,5 +131,38 @@ class Context {
113
131
  const [page] = context.pages();
114
132
  return { page };
115
133
  }
134
+ async allFramesSnapshot() {
135
+ const page = this.existingPage();
136
+ const visibleFrames = await page.locator('iframe').filter({ visible: true }).all();
137
+ this._lastSnapshotFrames = visibleFrames.map(frame => frame.contentFrame());
138
+ const snapshots = await Promise.all([
139
+ page.locator('html').ariaSnapshot({ ref: true }),
140
+ ...this._lastSnapshotFrames.map(async (frame, index) => {
141
+ const snapshot = await frame.locator('html').ariaSnapshot({ ref: true });
142
+ const args = [];
143
+ const src = await frame.owner().getAttribute('src');
144
+ if (src)
145
+ args.push(`src=${src}`);
146
+ const name = await frame.owner().getAttribute('name');
147
+ if (name)
148
+ args.push(`name=${name}`);
149
+ return `\n# iframe ${args.join(' ')}\n` + snapshot.replaceAll('[ref=', `[ref=f${index}`);
150
+ })
151
+ ]);
152
+ return snapshots.join('\n');
153
+ }
154
+ refLocator(ref) {
155
+ const page = this.existingPage();
156
+ let frame = page.mainFrame();
157
+ const match = ref.match(/^f(\d+)(.*)/);
158
+ if (match) {
159
+ const frameIndex = parseInt(match[1], 10);
160
+ if (!this._lastSnapshotFrames[frameIndex])
161
+ throw new Error(`Frame does not exist. Provide ref from the most current snapshot.`);
162
+ frame = this._lastSnapshotFrames[frameIndex];
163
+ ref = match[2];
164
+ }
165
+ return frame.locator(`aria-ref=${ref}`);
166
+ }
116
167
  }
117
168
  exports.Context = Context;
package/lib/index.js CHANGED
@@ -64,17 +64,20 @@ const snapshotTools = [
64
64
  common.navigate(true),
65
65
  common.goBack(true),
66
66
  common.goForward(true),
67
+ common.chooseFile(true),
67
68
  snapshot.snapshot,
68
69
  snapshot.click,
69
70
  snapshot.hover,
70
71
  snapshot.type,
71
72
  snapshot.selectOption,
73
+ snapshot.screenshot,
72
74
  ...commonTools,
73
75
  ];
74
76
  const screenshotTools = [
75
77
  common.navigate(false),
76
78
  common.goBack(false),
77
79
  common.goForward(false),
80
+ common.chooseFile(false),
78
81
  screenshot.screenshot,
79
82
  screenshot.moveMouse,
80
83
  screenshot.click,
package/lib/program.js CHANGED
@@ -18,12 +18,15 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
18
18
  return (mod && mod.__esModule) ? mod : { "default": mod };
19
19
  };
20
20
  Object.defineProperty(exports, "__esModule", { value: true });
21
+ const http_1 = __importDefault(require("http"));
21
22
  const fs_1 = __importDefault(require("fs"));
22
23
  const os_1 = __importDefault(require("os"));
23
24
  const path_1 = __importDefault(require("path"));
24
25
  const commander_1 = require("commander");
25
26
  const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js");
27
+ const sse_js_1 = require("@modelcontextprotocol/sdk/server/sse.js");
26
28
  const index_1 = require("./index");
29
+ const assert_1 = __importDefault(require("assert"));
27
30
  const packageJSON = require('../package.json');
28
31
  commander_1.program
29
32
  .version('Version ' + packageJSON.version)
@@ -31,6 +34,7 @@ commander_1.program
31
34
  .option('--headless', 'Run browser in headless mode, headed by default')
32
35
  .option('--user-data-dir <path>', 'Path to the user data directory')
33
36
  .option('--vision', 'Run server that uses screenshots (Aria snapshots are used by default)')
37
+ .option('--port <port>', 'Port to listen on for SSE transport.')
34
38
  .action(async (options) => {
35
39
  const launchOptions = {
36
40
  headless: !!options.headless,
@@ -39,10 +43,72 @@ commander_1.program
39
43
  const server = (0, index_1.createServer)({
40
44
  userDataDir: options.userDataDir ?? await userDataDir(),
41
45
  launchOptions,
46
+ vision: !!options.vision,
42
47
  });
43
48
  setupExitWatchdog(server);
44
- const transport = new stdio_js_1.StdioServerTransport();
45
- await server.connect(transport);
49
+ if (options.port) {
50
+ const sessions = new Map();
51
+ const httpServer = http_1.default.createServer(async (req, res) => {
52
+ if (req.method === 'POST') {
53
+ const host = req.headers.host ?? 'http://unknown';
54
+ const sessionId = new URL(host + req.url).searchParams.get('sessionId');
55
+ if (!sessionId) {
56
+ res.statusCode = 400;
57
+ res.end('Missing sessionId');
58
+ return;
59
+ }
60
+ const transport = sessions.get(sessionId);
61
+ if (!transport) {
62
+ res.statusCode = 404;
63
+ res.end('Session not found');
64
+ return;
65
+ }
66
+ await transport.handlePostMessage(req, res);
67
+ return;
68
+ }
69
+ else if (req.method === 'GET') {
70
+ const transport = new sse_js_1.SSEServerTransport('/sse', res);
71
+ sessions.set(transport.sessionId, transport);
72
+ res.on('close', () => {
73
+ sessions.delete(transport.sessionId);
74
+ });
75
+ await server.connect(transport);
76
+ return;
77
+ }
78
+ else {
79
+ res.statusCode = 405;
80
+ res.end('Method not allowed');
81
+ }
82
+ });
83
+ httpServer.listen(+options.port, () => {
84
+ const address = httpServer.address();
85
+ (0, assert_1.default)(address, 'Could not bind server socket');
86
+ let urlPrefixHumanReadable;
87
+ if (typeof address === 'string') {
88
+ urlPrefixHumanReadable = address;
89
+ }
90
+ else {
91
+ const port = address.port;
92
+ let resolvedHost = address.family === 'IPv4' ? address.address : `[${address.address}]`;
93
+ if (resolvedHost === '0.0.0.0' || resolvedHost === '[::]')
94
+ resolvedHost = 'localhost';
95
+ urlPrefixHumanReadable = `http://${resolvedHost}:${port}`;
96
+ }
97
+ console.log(`Listening on ${urlPrefixHumanReadable}`);
98
+ console.log('Put this in your client config:');
99
+ console.log(JSON.stringify({
100
+ 'mcpServers': {
101
+ 'playwright': {
102
+ 'url': `${urlPrefixHumanReadable}/sse`
103
+ }
104
+ }
105
+ }, undefined, 2));
106
+ });
107
+ }
108
+ else {
109
+ const transport = new stdio_js_1.StdioServerTransport();
110
+ await server.connect(transport);
111
+ }
46
112
  });
47
113
  function setupExitWatchdog(server) {
48
114
  process.stdin.on('close', async () => {
package/lib/server.js CHANGED
@@ -60,8 +60,9 @@ function createServerWithTools(options) {
60
60
  const contents = await resource.read(context, request.params.uri);
61
61
  return { contents };
62
62
  });
63
+ const oldClose = server.close.bind(server);
63
64
  server.close = async () => {
64
- await server.close();
65
+ await oldClose();
65
66
  await context.close();
66
67
  };
67
68
  return server;
@@ -18,7 +18,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
18
18
  return (mod && mod.__esModule) ? mod : { "default": mod };
19
19
  };
20
20
  Object.defineProperty(exports, "__esModule", { value: true });
21
- exports.close = exports.pdf = exports.pressKey = exports.wait = exports.goForward = exports.goBack = exports.navigate = void 0;
21
+ exports.chooseFile = exports.close = exports.pdf = exports.pressKey = exports.wait = exports.goForward = exports.goBack = exports.navigate = void 0;
22
22
  const os_1 = __importDefault(require("os"));
23
23
  const path_1 = __importDefault(require("path"));
24
24
  const zod_1 = require("zod");
@@ -40,7 +40,7 @@ const navigate = snapshot => ({
40
40
  // Cap load event to 5 seconds, the page is operational at this point.
41
41
  await page.waitForLoadState('load', { timeout: 5000 }).catch(() => { });
42
42
  if (snapshot)
43
- return (0, utils_1.captureAriaSnapshot)(page);
43
+ return (0, utils_1.captureAriaSnapshot)(context);
44
44
  return {
45
45
  content: [{
46
46
  type: 'text',
@@ -118,7 +118,7 @@ exports.pdf = {
118
118
  inputSchema: (0, zod_to_json_schema_1.zodToJsonSchema)(pdfSchema),
119
119
  },
120
120
  handle: async (context) => {
121
- const page = await context.existingPage();
121
+ const page = context.existingPage();
122
122
  const fileName = path_1.default.join(os_1.default.tmpdir(), `/page-${new Date().toISOString()}.pdf`);
123
123
  await page.pdf({ path: fileName });
124
124
  return {
@@ -146,3 +146,20 @@ exports.close = {
146
146
  };
147
147
  },
148
148
  };
149
+ const chooseFileSchema = zod_1.z.object({
150
+ paths: zod_1.z.array(zod_1.z.string()).describe('The absolute paths to the files to upload. Can be a single file or multiple files.'),
151
+ });
152
+ const chooseFile = snapshot => ({
153
+ schema: {
154
+ name: 'browser_choose_file',
155
+ description: 'Choose one or multiple files to upload',
156
+ inputSchema: (0, zod_to_json_schema_1.zodToJsonSchema)(chooseFileSchema),
157
+ },
158
+ handle: async (context, params) => {
159
+ const validatedParams = chooseFileSchema.parse(params);
160
+ return await (0, utils_1.runAndWait)(context, `Chose files ${validatedParams.paths.join(', ')}`, async () => {
161
+ await context.submitFileChooser(validatedParams.paths);
162
+ }, snapshot);
163
+ },
164
+ });
165
+ exports.chooseFile = chooseFile;
@@ -26,7 +26,7 @@ exports.screenshot = {
26
26
  inputSchema: (0, zod_to_json_schema_1.zodToJsonSchema)(zod_1.z.object({})),
27
27
  },
28
28
  handle: async (context) => {
29
- const page = await context.existingPage();
29
+ const page = context.existingPage();
30
30
  const screenshot = await page.screenshot({ type: 'jpeg', quality: 50, scale: 'css' });
31
31
  return {
32
32
  content: [{ type: 'image', data: screenshot.toString('base64'), mimeType: 'image/jpeg' }],
@@ -48,7 +48,7 @@ exports.moveMouse = {
48
48
  },
49
49
  handle: async (context, params) => {
50
50
  const validatedParams = moveMouseSchema.parse(params);
51
- const page = await context.existingPage();
51
+ const page = context.existingPage();
52
52
  await page.mouse.move(validatedParams.x, validatedParams.y);
53
53
  return {
54
54
  content: [{ type: 'text', text: `Moved mouse to (${validatedParams.x}, ${validatedParams.y})` }],
@@ -18,7 +18,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
18
18
  return (mod && mod.__esModule) ? mod : { "default": mod };
19
19
  };
20
20
  Object.defineProperty(exports, "__esModule", { value: true });
21
- exports.selectOption = exports.type = exports.hover = exports.drag = exports.click = exports.snapshot = void 0;
21
+ exports.screenshot = exports.selectOption = exports.type = exports.hover = exports.drag = exports.click = exports.snapshot = void 0;
22
22
  const zod_1 = require("zod");
23
23
  const zod_to_json_schema_1 = __importDefault(require("zod-to-json-schema"));
24
24
  const utils_1 = require("./utils");
@@ -29,7 +29,7 @@ exports.snapshot = {
29
29
  inputSchema: (0, zod_to_json_schema_1.default)(zod_1.z.object({})),
30
30
  },
31
31
  handle: async (context) => {
32
- return await (0, utils_1.captureAriaSnapshot)(await context.existingPage());
32
+ return await (0, utils_1.captureAriaSnapshot)(context);
33
33
  },
34
34
  };
35
35
  const elementSchema = zod_1.z.object({
@@ -44,7 +44,7 @@ exports.click = {
44
44
  },
45
45
  handle: async (context, params) => {
46
46
  const validatedParams = elementSchema.parse(params);
47
- return (0, utils_1.runAndWait)(context, `"${validatedParams.element}" clicked`, page => refLocator(page, validatedParams.ref).click(), true);
47
+ return (0, utils_1.runAndWait)(context, `"${validatedParams.element}" clicked`, () => context.refLocator(validatedParams.ref).click(), true);
48
48
  },
49
49
  };
50
50
  const dragSchema = zod_1.z.object({
@@ -61,9 +61,9 @@ exports.drag = {
61
61
  },
62
62
  handle: async (context, params) => {
63
63
  const validatedParams = dragSchema.parse(params);
64
- return (0, utils_1.runAndWait)(context, `Dragged "${validatedParams.startElement}" to "${validatedParams.endElement}"`, async (page) => {
65
- const startLocator = refLocator(page, validatedParams.startRef);
66
- const endLocator = refLocator(page, validatedParams.endRef);
64
+ return (0, utils_1.runAndWait)(context, `Dragged "${validatedParams.startElement}" to "${validatedParams.endElement}"`, async () => {
65
+ const startLocator = context.refLocator(validatedParams.startRef);
66
+ const endLocator = context.refLocator(validatedParams.endRef);
67
67
  await startLocator.dragTo(endLocator);
68
68
  }, true);
69
69
  },
@@ -76,7 +76,7 @@ exports.hover = {
76
76
  },
77
77
  handle: async (context, params) => {
78
78
  const validatedParams = elementSchema.parse(params);
79
- return (0, utils_1.runAndWait)(context, `Hovered over "${validatedParams.element}"`, page => refLocator(page, validatedParams.ref).hover(), true);
79
+ return (0, utils_1.runAndWait)(context, `Hovered over "${validatedParams.element}"`, () => context.refLocator(validatedParams.ref).hover(), true);
80
80
  },
81
81
  };
82
82
  const typeSchema = elementSchema.extend({
@@ -91,8 +91,8 @@ exports.type = {
91
91
  },
92
92
  handle: async (context, params) => {
93
93
  const validatedParams = typeSchema.parse(params);
94
- return await (0, utils_1.runAndWait)(context, `Typed "${validatedParams.text}" into "${validatedParams.element}"`, async (page) => {
95
- const locator = refLocator(page, validatedParams.ref);
94
+ return await (0, utils_1.runAndWait)(context, `Typed "${validatedParams.text}" into "${validatedParams.element}"`, async () => {
95
+ const locator = context.refLocator(validatedParams.ref);
96
96
  await locator.fill(validatedParams.text);
97
97
  if (validatedParams.submit)
98
98
  await locator.press('Enter');
@@ -110,12 +110,28 @@ exports.selectOption = {
110
110
  },
111
111
  handle: async (context, params) => {
112
112
  const validatedParams = selectOptionSchema.parse(params);
113
- return await (0, utils_1.runAndWait)(context, `Selected option in "${validatedParams.element}"`, async (page) => {
114
- const locator = refLocator(page, validatedParams.ref);
113
+ return await (0, utils_1.runAndWait)(context, `Selected option in "${validatedParams.element}"`, async () => {
114
+ const locator = context.refLocator(validatedParams.ref);
115
115
  await locator.selectOption(validatedParams.values);
116
116
  }, true);
117
117
  },
118
118
  };
119
- function refLocator(page, ref) {
120
- return page.locator(`aria-ref=${ref}`);
121
- }
119
+ const screenshotSchema = zod_1.z.object({
120
+ raw: zod_1.z.boolean().optional().describe('Whether to return without compression (in PNG format). Default is false, which returns a JPEG image.'),
121
+ });
122
+ exports.screenshot = {
123
+ schema: {
124
+ name: 'browser_take_screenshot',
125
+ description: `Take a screenshot of the current page. You can't perform actions based on the screenshot, use browser_snapshot for actions.`,
126
+ inputSchema: (0, zod_to_json_schema_1.default)(screenshotSchema),
127
+ },
128
+ handle: async (context, params) => {
129
+ const validatedParams = screenshotSchema.parse(params);
130
+ const page = context.existingPage();
131
+ const options = validatedParams.raw ? { type: 'png', scale: 'css' } : { type: 'jpeg', quality: 50, scale: 'css' };
132
+ const screenshot = await page.screenshot(options);
133
+ return {
134
+ content: [{ type: 'image', data: screenshot.toString('base64'), mimeType: validatedParams.raw ? 'image/png' : 'image/jpeg' }],
135
+ };
136
+ },
137
+ };
@@ -65,23 +65,26 @@ async function waitForCompletion(page, callback) {
65
65
  }
66
66
  }
67
67
  async function runAndWait(context, status, callback, snapshot = false) {
68
- const page = await context.existingPage();
68
+ const page = context.existingPage();
69
+ const dismissFileChooser = context.hasFileChooser();
69
70
  await waitForCompletion(page, () => callback(page));
70
- return snapshot ? captureAriaSnapshot(page, status) : {
71
+ if (dismissFileChooser)
72
+ context.clearFileChooser();
73
+ const result = snapshot ? await captureAriaSnapshot(context, status) : {
71
74
  content: [{ type: 'text', text: status }],
72
75
  };
76
+ return result;
73
77
  }
74
- async function captureAriaSnapshot(page, status = '') {
75
- const snapshot = await page.locator('html').ariaSnapshot({ ref: true });
78
+ async function captureAriaSnapshot(context, status = '') {
79
+ const page = context.existingPage();
80
+ const lines = [];
81
+ if (status)
82
+ lines.push(`${status}`);
83
+ lines.push('', `- Page URL: ${page.url()}`, `- Page Title: ${await page.title()}`);
84
+ if (context.hasFileChooser())
85
+ lines.push(`- There is a file chooser visible that requires browser_choose_file to be called`);
86
+ lines.push(`- Page Snapshot`, '```yaml', await context.allFramesSnapshot(), '```', '');
76
87
  return {
77
- content: [{ type: 'text', text: `${status ? `${status}\n` : ''}
78
- - Page URL: ${page.url()}
79
- - Page Title: ${await page.title()}
80
- - Page Snapshot
81
- \`\`\`yaml
82
- ${snapshot}
83
- \`\`\`
84
- `
85
- }],
88
+ content: [{ type: 'text', text: lines.join('\n') }],
86
89
  };
87
90
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playwright/mcp",
3
- "version": "0.0.4",
3
+ "version": "0.0.6",
4
4
  "description": "Playwright Tools for MCP",
5
5
  "repository": {
6
6
  "type": "git",
@@ -18,7 +18,9 @@
18
18
  "build": "tsc",
19
19
  "lint": "eslint .",
20
20
  "watch": "tsc --watch",
21
- "test": "playwright test"
21
+ "test": "playwright test",
22
+ "clean": "rm -rf lib",
23
+ "publish": "npm run clean && npm run build && npm run test && npm publish"
22
24
  },
23
25
  "exports": {
24
26
  "./package.json": "./package.json",