@playwright/mcp 0.0.5 → 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:
@@ -291,6 +288,11 @@ Vision Mode provides tools for visual-based interactions using screenshots. Here
291
288
  - Parameters:
292
289
  - `key` (string): Name of the key to press or a character to generate, such as `ArrowLeft` or `a`
293
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
+
294
296
  - **browser_save_as_pdf**
295
297
  - Description: Save page as PDF
296
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,6 +90,7 @@ 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
96
  existingPage() {
@@ -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,6 +64,7 @@ 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,
@@ -76,6 +77,7 @@ const screenshotTools = [
76
77
  common.navigate(false),
77
78
  common.goBack(false),
78
79
  common.goForward(false),
80
+ common.chooseFile(false),
79
81
  screenshot.screenshot,
80
82
  screenshot.moveMouse,
81
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,
@@ -42,8 +46,69 @@ commander_1.program
42
46
  vision: !!options.vision,
43
47
  });
44
48
  setupExitWatchdog(server);
45
- const transport = new stdio_js_1.StdioServerTransport();
46
- 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
+ }
47
112
  });
48
113
  function setupExitWatchdog(server) {
49
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',
@@ -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;
@@ -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)(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,8 +110,8 @@ 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
  },
@@ -135,13 +135,3 @@ exports.screenshot = {
135
135
  };
136
136
  },
137
137
  };
138
- function refLocator(page, ref) {
139
- let frame = page.frames()[0];
140
- const match = ref.match(/^f(\d+)(.*)/);
141
- if (match) {
142
- const frameIndex = parseInt(match[1], 10);
143
- frame = page.frames()[frameIndex];
144
- ref = match[2];
145
- }
146
- return frame.locator(`aria-ref=${ref}`);
147
- }
@@ -16,7 +16,6 @@
16
16
  */
17
17
  Object.defineProperty(exports, "__esModule", { value: true });
18
18
  exports.runAndWait = runAndWait;
19
- exports.captureAllFrameSnapshot = captureAllFrameSnapshot;
20
19
  exports.captureAriaSnapshot = captureAriaSnapshot;
21
20
  async function waitForCompletion(page, callback) {
22
21
  const requests = new Set();
@@ -67,30 +66,25 @@ async function waitForCompletion(page, callback) {
67
66
  }
68
67
  async function runAndWait(context, status, callback, snapshot = false) {
69
68
  const page = context.existingPage();
69
+ const dismissFileChooser = context.hasFileChooser();
70
70
  await waitForCompletion(page, () => callback(page));
71
- return snapshot ? captureAriaSnapshot(page, status) : {
71
+ if (dismissFileChooser)
72
+ context.clearFileChooser();
73
+ const result = snapshot ? await captureAriaSnapshot(context, status) : {
72
74
  content: [{ type: 'text', text: status }],
73
75
  };
76
+ return result;
74
77
  }
75
- async function captureAllFrameSnapshot(page) {
76
- const snapshots = await Promise.all(page.frames().map(frame => frame.locator('html').ariaSnapshot({ ref: true })));
77
- const scopedSnapshots = snapshots.map((snapshot, frameIndex) => {
78
- if (frameIndex === 0)
79
- return snapshot;
80
- return snapshot.replaceAll('[ref=', `[ref=f${frameIndex}`);
81
- });
82
- return scopedSnapshots.join('\n');
83
- }
84
- async function captureAriaSnapshot(page, status = '') {
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(), '```', '');
85
87
  return {
86
- content: [{ type: 'text', text: `${status ? `${status}\n` : ''}
87
- - Page URL: ${page.url()}
88
- - Page Title: ${await page.title()}
89
- - Page Snapshot
90
- \`\`\`yaml
91
- ${await captureAllFrameSnapshot(page)}
92
- \`\`\`
93
- `
94
- }],
88
+ content: [{ type: 'text', text: lines.join('\n') }],
95
89
  };
96
90
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playwright/mcp",
3
- "version": "0.0.5",
3
+ "version": "0.0.6",
4
4
  "description": "Playwright Tools for MCP",
5
5
  "repository": {
6
6
  "type": "git",