@playwright/mcp 0.0.28 → 0.0.29
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 +29 -1
- package/config.d.ts +5 -0
- package/index.d.ts +1 -3
- package/lib/browserContextFactory.js +49 -1
- package/lib/browserServer.js +151 -0
- package/lib/config.js +3 -16
- package/lib/context.js +1 -1
- package/lib/fileUtils.js +32 -0
- package/lib/httpServer.js +201 -0
- package/lib/program.js +1 -0
- package/lib/tab.js +1 -1
- package/package.json +5 -3
package/README.md
CHANGED
|
@@ -52,6 +52,12 @@ After installation, the Playwright MCP server will be available for use with you
|
|
|
52
52
|
<details>
|
|
53
53
|
<summary><b>Install in Cursor</b></summary>
|
|
54
54
|
|
|
55
|
+
#### Click the button to install:
|
|
56
|
+
|
|
57
|
+
[](https://cursor.com/install-mcp?name=playwright&config=eyJjb21tYW5kIjoibnB4IEBwbGF5d3JpZ2h0L21jcEBsYXRlc3QifQ%3D%3D)
|
|
58
|
+
|
|
59
|
+
#### Or install manually:
|
|
60
|
+
|
|
55
61
|
Go to `Cursor Settings` -> `MCP` -> `Add new MCP Server`. Name to your liking, use `command` type with the command `npx @playwright/mcp`. You can also verify config or add command like arguments via clicking `Edit`.
|
|
56
62
|
|
|
57
63
|
```js
|
|
@@ -106,6 +112,27 @@ Follow the MCP install [guide](https://modelcontextprotocol.io/quickstart/user),
|
|
|
106
112
|
```
|
|
107
113
|
</details>
|
|
108
114
|
|
|
115
|
+
<details>
|
|
116
|
+
<summary><b>Install in Qodo Gen</b></summary>
|
|
117
|
+
|
|
118
|
+
Open [Qodo Gen](https://docs.qodo.ai/qodo-documentation/qodo-gen) chat panel in VSCode or IntelliJ → Connect more tools → + Add new MCP → Paste the following configuration:
|
|
119
|
+
|
|
120
|
+
```js
|
|
121
|
+
{
|
|
122
|
+
"mcpServers": {
|
|
123
|
+
"playwright": {
|
|
124
|
+
"command": "npx",
|
|
125
|
+
"args": [
|
|
126
|
+
"@playwright/mcp@latest"
|
|
127
|
+
]
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Click <code>Save</code>.
|
|
134
|
+
</details>
|
|
135
|
+
|
|
109
136
|
### Configuration
|
|
110
137
|
|
|
111
138
|
Playwright MCP server supports following arguments. They can be provided in the JSON configuration above, as a part of the `"args"` list:
|
|
@@ -124,6 +151,7 @@ Playwright MCP server supports following arguments. They can be provided in the
|
|
|
124
151
|
--block-service-workers block service workers
|
|
125
152
|
--browser <browser> browser or chrome channel to use, possible
|
|
126
153
|
values: chrome, firefox, webkit, msedge.
|
|
154
|
+
--browser-agent <endpoint> Use browser agent (experimental).
|
|
127
155
|
--caps <caps> comma-separated list of capabilities to enable,
|
|
128
156
|
possible values: tabs, pdf, history, wait, files,
|
|
129
157
|
install. Default is all.
|
|
@@ -354,7 +382,7 @@ http.createServer(async (req, res) => {
|
|
|
354
382
|
// Creates a headless Playwright MCP server with SSE transport
|
|
355
383
|
const connection = await createConnection({ browser: { launchOptions: { headless: true } } });
|
|
356
384
|
const transport = new SSEServerTransport('/messages', res);
|
|
357
|
-
await connection.connect(transport);
|
|
385
|
+
await connection.sever.connect(transport);
|
|
358
386
|
|
|
359
387
|
// ...
|
|
360
388
|
});
|
package/config.d.ts
CHANGED
package/index.d.ts
CHANGED
|
@@ -16,13 +16,11 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
19
|
-
import type { Config } from './config';
|
|
20
|
-
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
|
19
|
+
import type { Config } from './config.js';
|
|
21
20
|
import type { BrowserContext } from 'playwright';
|
|
22
21
|
|
|
23
22
|
export type Connection = {
|
|
24
23
|
server: Server;
|
|
25
|
-
connect(transport: Transport): Promise<void>;
|
|
26
24
|
close(): Promise<void>;
|
|
27
25
|
};
|
|
28
26
|
|
|
@@ -14,10 +14,12 @@
|
|
|
14
14
|
* limitations under the License.
|
|
15
15
|
*/
|
|
16
16
|
import fs from 'node:fs';
|
|
17
|
-
import
|
|
17
|
+
import net from 'node:net';
|
|
18
18
|
import path from 'node:path';
|
|
19
|
+
import os from 'node:os';
|
|
19
20
|
import debug from 'debug';
|
|
20
21
|
import * as playwright from 'playwright';
|
|
22
|
+
import { userDataDir } from './fileUtils.js';
|
|
21
23
|
const testDebug = debug('pw:mcp:test');
|
|
22
24
|
export function contextFactory(browserConfig) {
|
|
23
25
|
if (browserConfig.remoteEndpoint)
|
|
@@ -26,6 +28,8 @@ export function contextFactory(browserConfig) {
|
|
|
26
28
|
return new CdpContextFactory(browserConfig);
|
|
27
29
|
if (browserConfig.isolated)
|
|
28
30
|
return new IsolatedContextFactory(browserConfig);
|
|
31
|
+
if (browserConfig.browserAgent)
|
|
32
|
+
return new BrowserServerContextFactory(browserConfig);
|
|
29
33
|
return new PersistentContextFactory(browserConfig);
|
|
30
34
|
}
|
|
31
35
|
class BaseContextFactory {
|
|
@@ -78,6 +82,7 @@ class IsolatedContextFactory extends BaseContextFactory {
|
|
|
78
82
|
super('isolated', browserConfig);
|
|
79
83
|
}
|
|
80
84
|
async _doObtainBrowser() {
|
|
85
|
+
await injectCdpPort(this.browserConfig);
|
|
81
86
|
const browserType = playwright[this.browserConfig.browserName];
|
|
82
87
|
return browserType.launch({
|
|
83
88
|
...this.browserConfig.launchOptions,
|
|
@@ -126,6 +131,7 @@ class PersistentContextFactory {
|
|
|
126
131
|
this.browserConfig = browserConfig;
|
|
127
132
|
}
|
|
128
133
|
async createContext() {
|
|
134
|
+
await injectCdpPort(this.browserConfig);
|
|
129
135
|
testDebug('create browser context (persistent)');
|
|
130
136
|
const userDataDir = this.browserConfig.userDataDir ?? await this._createUserDataDir();
|
|
131
137
|
this._userDataDirs.add(userDataDir);
|
|
@@ -177,3 +183,45 @@ class PersistentContextFactory {
|
|
|
177
183
|
return result;
|
|
178
184
|
}
|
|
179
185
|
}
|
|
186
|
+
export class BrowserServerContextFactory extends BaseContextFactory {
|
|
187
|
+
constructor(browserConfig) {
|
|
188
|
+
super('persistent', browserConfig);
|
|
189
|
+
}
|
|
190
|
+
async _doObtainBrowser() {
|
|
191
|
+
const response = await fetch(new URL(`/json/launch`, this.browserConfig.browserAgent), {
|
|
192
|
+
method: 'POST',
|
|
193
|
+
body: JSON.stringify({
|
|
194
|
+
browserType: this.browserConfig.browserName,
|
|
195
|
+
userDataDir: this.browserConfig.userDataDir ?? await this._createUserDataDir(),
|
|
196
|
+
launchOptions: this.browserConfig.launchOptions,
|
|
197
|
+
contextOptions: this.browserConfig.contextOptions,
|
|
198
|
+
}),
|
|
199
|
+
});
|
|
200
|
+
const info = await response.json();
|
|
201
|
+
if (info.error)
|
|
202
|
+
throw new Error(info.error);
|
|
203
|
+
return await playwright.chromium.connectOverCDP(`http://localhost:${info.cdpPort}/`);
|
|
204
|
+
}
|
|
205
|
+
async _doCreateContext(browser) {
|
|
206
|
+
return this.browserConfig.isolated ? await browser.newContext() : browser.contexts()[0];
|
|
207
|
+
}
|
|
208
|
+
async _createUserDataDir() {
|
|
209
|
+
const dir = await userDataDir(this.browserConfig);
|
|
210
|
+
await fs.promises.mkdir(dir, { recursive: true });
|
|
211
|
+
return dir;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
async function injectCdpPort(browserConfig) {
|
|
215
|
+
if (browserConfig.browserName === 'chromium')
|
|
216
|
+
browserConfig.launchOptions.cdpPort = await findFreePort();
|
|
217
|
+
}
|
|
218
|
+
async function findFreePort() {
|
|
219
|
+
return new Promise((resolve, reject) => {
|
|
220
|
+
const server = net.createServer();
|
|
221
|
+
server.listen(0, () => {
|
|
222
|
+
const { port } = server.address();
|
|
223
|
+
server.close(() => resolve(port));
|
|
224
|
+
});
|
|
225
|
+
server.on('error', reject);
|
|
226
|
+
});
|
|
227
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) Microsoft Corporation.
|
|
3
|
+
*
|
|
4
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
* you may not use this file except in compliance with the License.
|
|
6
|
+
* You may obtain a copy of the License at
|
|
7
|
+
*
|
|
8
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
*
|
|
10
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
* See the License for the specific language governing permissions and
|
|
14
|
+
* limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
/* eslint-disable no-console */
|
|
17
|
+
import net from 'net';
|
|
18
|
+
import { program } from 'commander';
|
|
19
|
+
import playwright from 'playwright';
|
|
20
|
+
import { HttpServer } from './httpServer.js';
|
|
21
|
+
import { packageJSON } from './package.js';
|
|
22
|
+
class BrowserServer {
|
|
23
|
+
_server = new HttpServer();
|
|
24
|
+
_entries = [];
|
|
25
|
+
constructor() {
|
|
26
|
+
this._setupExitHandler();
|
|
27
|
+
}
|
|
28
|
+
async start(port) {
|
|
29
|
+
await this._server.start({ port });
|
|
30
|
+
this._server.routePath('/json/list', (req, res) => {
|
|
31
|
+
this._handleJsonList(res);
|
|
32
|
+
});
|
|
33
|
+
this._server.routePath('/json/launch', async (req, res) => {
|
|
34
|
+
void this._handleLaunchBrowser(req, res).catch(e => console.error(e));
|
|
35
|
+
});
|
|
36
|
+
this._setEntries([]);
|
|
37
|
+
}
|
|
38
|
+
_handleJsonList(res) {
|
|
39
|
+
const list = this._entries.map(browser => browser.info);
|
|
40
|
+
res.end(JSON.stringify(list));
|
|
41
|
+
}
|
|
42
|
+
async _handleLaunchBrowser(req, res) {
|
|
43
|
+
const request = await readBody(req);
|
|
44
|
+
let info = this._entries.map(entry => entry.info).find(info => info.userDataDir === request.userDataDir);
|
|
45
|
+
if (!info || info.error)
|
|
46
|
+
info = await this._newBrowser(request);
|
|
47
|
+
res.end(JSON.stringify(info));
|
|
48
|
+
}
|
|
49
|
+
async _newBrowser(request) {
|
|
50
|
+
const cdpPort = await findFreePort();
|
|
51
|
+
request.launchOptions.cdpPort = cdpPort;
|
|
52
|
+
const info = {
|
|
53
|
+
browserType: request.browserType,
|
|
54
|
+
userDataDir: request.userDataDir,
|
|
55
|
+
cdpPort,
|
|
56
|
+
launchOptions: request.launchOptions,
|
|
57
|
+
contextOptions: request.contextOptions,
|
|
58
|
+
};
|
|
59
|
+
const browserType = playwright[request.browserType];
|
|
60
|
+
const { browser, error } = await browserType.launchPersistentContext(request.userDataDir, {
|
|
61
|
+
...request.launchOptions,
|
|
62
|
+
...request.contextOptions,
|
|
63
|
+
handleSIGINT: false,
|
|
64
|
+
handleSIGTERM: false,
|
|
65
|
+
}).then(context => {
|
|
66
|
+
return { browser: context.browser(), error: undefined };
|
|
67
|
+
}).catch(error => {
|
|
68
|
+
return { browser: undefined, error: error.message };
|
|
69
|
+
});
|
|
70
|
+
this._setEntries([...this._entries, {
|
|
71
|
+
browser,
|
|
72
|
+
info: {
|
|
73
|
+
browserType: request.browserType,
|
|
74
|
+
userDataDir: request.userDataDir,
|
|
75
|
+
cdpPort,
|
|
76
|
+
launchOptions: request.launchOptions,
|
|
77
|
+
contextOptions: request.contextOptions,
|
|
78
|
+
error,
|
|
79
|
+
},
|
|
80
|
+
}]);
|
|
81
|
+
browser?.on('disconnected', () => {
|
|
82
|
+
this._setEntries(this._entries.filter(entry => entry.browser !== browser));
|
|
83
|
+
});
|
|
84
|
+
return info;
|
|
85
|
+
}
|
|
86
|
+
_updateReport() {
|
|
87
|
+
// Clear the current line and move cursor to top of screen
|
|
88
|
+
process.stdout.write('\x1b[2J\x1b[H');
|
|
89
|
+
process.stdout.write(`Playwright Browser Server v${packageJSON.version}\n`);
|
|
90
|
+
process.stdout.write(`Listening on ${this._server.urlPrefix('human-readable')}\n\n`);
|
|
91
|
+
if (this._entries.length === 0) {
|
|
92
|
+
process.stdout.write('No browsers currently running\n');
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
process.stdout.write('Running browsers:\n');
|
|
96
|
+
for (const entry of this._entries) {
|
|
97
|
+
const status = entry.browser ? 'running' : 'error';
|
|
98
|
+
const statusColor = entry.browser ? '\x1b[32m' : '\x1b[31m'; // green for running, red for error
|
|
99
|
+
process.stdout.write(`${statusColor}${entry.info.browserType}\x1b[0m (${entry.info.userDataDir}) - ${statusColor}${status}\x1b[0m\n`);
|
|
100
|
+
if (entry.info.error)
|
|
101
|
+
process.stdout.write(` Error: ${entry.info.error}\n`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
_setEntries(entries) {
|
|
105
|
+
this._entries = entries;
|
|
106
|
+
this._updateReport();
|
|
107
|
+
}
|
|
108
|
+
_setupExitHandler() {
|
|
109
|
+
let isExiting = false;
|
|
110
|
+
const handleExit = async () => {
|
|
111
|
+
if (isExiting)
|
|
112
|
+
return;
|
|
113
|
+
isExiting = true;
|
|
114
|
+
setTimeout(() => process.exit(0), 15000);
|
|
115
|
+
for (const entry of this._entries)
|
|
116
|
+
await entry.browser?.close().catch(() => { });
|
|
117
|
+
process.exit(0);
|
|
118
|
+
};
|
|
119
|
+
process.stdin.on('close', handleExit);
|
|
120
|
+
process.on('SIGINT', handleExit);
|
|
121
|
+
process.on('SIGTERM', handleExit);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
program
|
|
125
|
+
.name('browser-agent')
|
|
126
|
+
.option('-p, --port <port>', 'Port to listen on', '9224')
|
|
127
|
+
.action(async (options) => {
|
|
128
|
+
await main(options);
|
|
129
|
+
});
|
|
130
|
+
void program.parseAsync(process.argv);
|
|
131
|
+
async function main(options) {
|
|
132
|
+
const server = new BrowserServer();
|
|
133
|
+
await server.start(+options.port);
|
|
134
|
+
}
|
|
135
|
+
function readBody(req) {
|
|
136
|
+
return new Promise((resolve, reject) => {
|
|
137
|
+
const chunks = [];
|
|
138
|
+
req.on('data', (chunk) => chunks.push(chunk));
|
|
139
|
+
req.on('end', () => resolve(JSON.parse(Buffer.concat(chunks).toString())));
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
async function findFreePort() {
|
|
143
|
+
return new Promise((resolve, reject) => {
|
|
144
|
+
const server = net.createServer();
|
|
145
|
+
server.listen(0, () => {
|
|
146
|
+
const { port } = server.address();
|
|
147
|
+
server.close(() => resolve(port));
|
|
148
|
+
});
|
|
149
|
+
server.on('error', reject);
|
|
150
|
+
});
|
|
151
|
+
}
|
package/lib/config.js
CHANGED
|
@@ -14,7 +14,6 @@
|
|
|
14
14
|
* limitations under the License.
|
|
15
15
|
*/
|
|
16
16
|
import fs from 'fs';
|
|
17
|
-
import net from 'net';
|
|
18
17
|
import os from 'os';
|
|
19
18
|
import path from 'path';
|
|
20
19
|
import { devices } from 'playwright';
|
|
@@ -48,8 +47,6 @@ export async function resolveCLIConfig(cliOptions) {
|
|
|
48
47
|
// Derive artifact output directory from config.outputDir
|
|
49
48
|
if (result.saveTrace)
|
|
50
49
|
result.browser.launchOptions.tracesDir = path.join(result.outputDir, 'traces');
|
|
51
|
-
if (result.browser.browserName === 'chromium')
|
|
52
|
-
result.browser.launchOptions.cdpPort = await findFreePort();
|
|
53
50
|
return result;
|
|
54
51
|
}
|
|
55
52
|
export async function configFromCLIOptions(cliOptions) {
|
|
@@ -114,6 +111,7 @@ export async function configFromCLIOptions(cliOptions) {
|
|
|
114
111
|
contextOptions.serviceWorkers = 'block';
|
|
115
112
|
const result = {
|
|
116
113
|
browser: {
|
|
114
|
+
browserAgent: cliOptions.browserAgent ?? process.env.PW_BROWSER_AGENT,
|
|
117
115
|
browserName,
|
|
118
116
|
isolated: cliOptions.isolated,
|
|
119
117
|
userDataDir: cliOptions.userDataDir,
|
|
@@ -137,16 +135,6 @@ export async function configFromCLIOptions(cliOptions) {
|
|
|
137
135
|
};
|
|
138
136
|
return result;
|
|
139
137
|
}
|
|
140
|
-
async function findFreePort() {
|
|
141
|
-
return new Promise((resolve, reject) => {
|
|
142
|
-
const server = net.createServer();
|
|
143
|
-
server.listen(0, () => {
|
|
144
|
-
const { port } = server.address();
|
|
145
|
-
server.close(() => resolve(port));
|
|
146
|
-
});
|
|
147
|
-
server.on('error', reject);
|
|
148
|
-
});
|
|
149
|
-
}
|
|
150
138
|
async function loadConfig(configFile) {
|
|
151
139
|
if (!configFile)
|
|
152
140
|
return {};
|
|
@@ -167,6 +155,8 @@ function pickDefined(obj) {
|
|
|
167
155
|
}
|
|
168
156
|
function mergeConfig(base, overrides) {
|
|
169
157
|
const browser = {
|
|
158
|
+
...pickDefined(base.browser),
|
|
159
|
+
...pickDefined(overrides.browser),
|
|
170
160
|
browserName: overrides.browser?.browserName ?? base.browser?.browserName ?? 'chromium',
|
|
171
161
|
isolated: overrides.browser?.isolated ?? base.browser?.isolated ?? false,
|
|
172
162
|
launchOptions: {
|
|
@@ -178,9 +168,6 @@ function mergeConfig(base, overrides) {
|
|
|
178
168
|
...pickDefined(base.browser?.contextOptions),
|
|
179
169
|
...pickDefined(overrides.browser?.contextOptions),
|
|
180
170
|
},
|
|
181
|
-
userDataDir: overrides.browser?.userDataDir ?? base.browser?.userDataDir,
|
|
182
|
-
cdpEndpoint: overrides.browser?.cdpEndpoint ?? base.browser?.cdpEndpoint,
|
|
183
|
-
remoteEndpoint: overrides.browser?.remoteEndpoint ?? base.browser?.remoteEndpoint,
|
|
184
171
|
};
|
|
185
172
|
if (browser.browserName !== 'chromium' && browser.launchOptions)
|
|
186
173
|
delete browser.launchOptions.channel;
|
package/lib/context.js
CHANGED
|
@@ -67,7 +67,7 @@ export class Context {
|
|
|
67
67
|
}
|
|
68
68
|
currentTabOrDie() {
|
|
69
69
|
if (!this._currentTab)
|
|
70
|
-
throw new Error('No current snapshot available. Capture a snapshot
|
|
70
|
+
throw new Error('No current snapshot available. Capture a snapshot or navigate to a new location first.');
|
|
71
71
|
return this._currentTab;
|
|
72
72
|
}
|
|
73
73
|
async newTab() {
|
package/lib/fileUtils.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) Microsoft Corporation.
|
|
3
|
+
*
|
|
4
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
* you may not use this file except in compliance with the License.
|
|
6
|
+
* You may obtain a copy of the License at
|
|
7
|
+
*
|
|
8
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
*
|
|
10
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
* See the License for the specific language governing permissions and
|
|
14
|
+
* limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
import os from 'node:os';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
export function cacheDir() {
|
|
19
|
+
let cacheDirectory;
|
|
20
|
+
if (process.platform === 'linux')
|
|
21
|
+
cacheDirectory = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
|
|
22
|
+
else if (process.platform === 'darwin')
|
|
23
|
+
cacheDirectory = path.join(os.homedir(), 'Library', 'Caches');
|
|
24
|
+
else if (process.platform === 'win32')
|
|
25
|
+
cacheDirectory = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
|
|
26
|
+
else
|
|
27
|
+
throw new Error('Unsupported platform: ' + process.platform);
|
|
28
|
+
return path.join(cacheDirectory, 'ms-playwright');
|
|
29
|
+
}
|
|
30
|
+
export async function userDataDir(browserConfig) {
|
|
31
|
+
return path.join(cacheDir(), 'ms-playwright', `mcp-${browserConfig.launchOptions?.channel ?? browserConfig?.browserName}-profile`);
|
|
32
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) Microsoft Corporation.
|
|
3
|
+
*
|
|
4
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
* you may not use this file except in compliance with the License.
|
|
6
|
+
* You may obtain a copy of the License at
|
|
7
|
+
*
|
|
8
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
*
|
|
10
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
* See the License for the specific language governing permissions and
|
|
14
|
+
* limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
import fs from 'fs';
|
|
17
|
+
import path from 'path';
|
|
18
|
+
import http from 'http';
|
|
19
|
+
import mime from 'mime';
|
|
20
|
+
import { ManualPromise } from './manualPromise.js';
|
|
21
|
+
export class HttpServer {
|
|
22
|
+
_server;
|
|
23
|
+
_urlPrefixPrecise = '';
|
|
24
|
+
_urlPrefixHumanReadable = '';
|
|
25
|
+
_port = 0;
|
|
26
|
+
_routes = [];
|
|
27
|
+
constructor() {
|
|
28
|
+
this._server = http.createServer(this._onRequest.bind(this));
|
|
29
|
+
decorateServer(this._server);
|
|
30
|
+
}
|
|
31
|
+
server() {
|
|
32
|
+
return this._server;
|
|
33
|
+
}
|
|
34
|
+
routePrefix(prefix, handler) {
|
|
35
|
+
this._routes.push({ prefix, handler });
|
|
36
|
+
}
|
|
37
|
+
routePath(path, handler) {
|
|
38
|
+
this._routes.push({ exact: path, handler });
|
|
39
|
+
}
|
|
40
|
+
port() {
|
|
41
|
+
return this._port;
|
|
42
|
+
}
|
|
43
|
+
async _tryStart(port, host) {
|
|
44
|
+
const errorPromise = new ManualPromise();
|
|
45
|
+
const errorListener = (error) => errorPromise.reject(error);
|
|
46
|
+
this._server.on('error', errorListener);
|
|
47
|
+
try {
|
|
48
|
+
this._server.listen(port, host);
|
|
49
|
+
await Promise.race([
|
|
50
|
+
new Promise(cb => this._server.once('listening', cb)),
|
|
51
|
+
errorPromise,
|
|
52
|
+
]);
|
|
53
|
+
}
|
|
54
|
+
finally {
|
|
55
|
+
this._server.removeListener('error', errorListener);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
async start(options = {}) {
|
|
59
|
+
const host = options.host || 'localhost';
|
|
60
|
+
if (options.preferredPort) {
|
|
61
|
+
try {
|
|
62
|
+
await this._tryStart(options.preferredPort, host);
|
|
63
|
+
}
|
|
64
|
+
catch (e) {
|
|
65
|
+
if (!e || !e.message || !e.message.includes('EADDRINUSE'))
|
|
66
|
+
throw e;
|
|
67
|
+
await this._tryStart(undefined, host);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
await this._tryStart(options.port, host);
|
|
72
|
+
}
|
|
73
|
+
const address = this._server.address();
|
|
74
|
+
if (typeof address === 'string') {
|
|
75
|
+
this._urlPrefixPrecise = address;
|
|
76
|
+
this._urlPrefixHumanReadable = address;
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
this._port = address.port;
|
|
80
|
+
const resolvedHost = address.family === 'IPv4' ? address.address : `[${address.address}]`;
|
|
81
|
+
this._urlPrefixPrecise = `http://${resolvedHost}:${address.port}`;
|
|
82
|
+
this._urlPrefixHumanReadable = `http://${host}:${address.port}`;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
async stop() {
|
|
86
|
+
await new Promise(cb => this._server.close(cb));
|
|
87
|
+
}
|
|
88
|
+
urlPrefix(purpose) {
|
|
89
|
+
return purpose === 'human-readable' ? this._urlPrefixHumanReadable : this._urlPrefixPrecise;
|
|
90
|
+
}
|
|
91
|
+
serveFile(request, response, absoluteFilePath, headers) {
|
|
92
|
+
try {
|
|
93
|
+
for (const [name, value] of Object.entries(headers || {}))
|
|
94
|
+
response.setHeader(name, value);
|
|
95
|
+
if (request.headers.range)
|
|
96
|
+
this._serveRangeFile(request, response, absoluteFilePath);
|
|
97
|
+
else
|
|
98
|
+
this._serveFile(response, absoluteFilePath);
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
catch (e) {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
_serveFile(response, absoluteFilePath) {
|
|
106
|
+
const content = fs.readFileSync(absoluteFilePath);
|
|
107
|
+
response.statusCode = 200;
|
|
108
|
+
const contentType = mime.getType(path.extname(absoluteFilePath)) || 'application/octet-stream';
|
|
109
|
+
response.setHeader('Content-Type', contentType);
|
|
110
|
+
response.setHeader('Content-Length', content.byteLength);
|
|
111
|
+
response.end(content);
|
|
112
|
+
}
|
|
113
|
+
_serveRangeFile(request, response, absoluteFilePath) {
|
|
114
|
+
const range = request.headers.range;
|
|
115
|
+
if (!range || !range.startsWith('bytes=') || range.includes(', ') || [...range].filter(char => char === '-').length !== 1) {
|
|
116
|
+
response.statusCode = 400;
|
|
117
|
+
return response.end('Bad request');
|
|
118
|
+
}
|
|
119
|
+
// Parse the range header: https://datatracker.ietf.org/doc/html/rfc7233#section-2.1
|
|
120
|
+
const [startStr, endStr] = range.replace(/bytes=/, '').split('-');
|
|
121
|
+
// Both start and end (when passing to fs.createReadStream) and the range header are inclusive and start counting at 0.
|
|
122
|
+
let start;
|
|
123
|
+
let end;
|
|
124
|
+
const size = fs.statSync(absoluteFilePath).size;
|
|
125
|
+
if (startStr !== '' && endStr === '') {
|
|
126
|
+
// No end specified: use the whole file
|
|
127
|
+
start = +startStr;
|
|
128
|
+
end = size - 1;
|
|
129
|
+
}
|
|
130
|
+
else if (startStr === '' && endStr !== '') {
|
|
131
|
+
// No start specified: calculate start manually
|
|
132
|
+
start = size - +endStr;
|
|
133
|
+
end = size - 1;
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
start = +startStr;
|
|
137
|
+
end = +endStr;
|
|
138
|
+
}
|
|
139
|
+
// Handle unavailable range request
|
|
140
|
+
if (Number.isNaN(start) || Number.isNaN(end) || start >= size || end >= size || start > end) {
|
|
141
|
+
// Return the 416 Range Not Satisfiable: https://datatracker.ietf.org/doc/html/rfc7233#section-4.4
|
|
142
|
+
response.writeHead(416, {
|
|
143
|
+
'Content-Range': `bytes */${size}`
|
|
144
|
+
});
|
|
145
|
+
return response.end();
|
|
146
|
+
}
|
|
147
|
+
// Sending Partial Content: https://datatracker.ietf.org/doc/html/rfc7233#section-4.1
|
|
148
|
+
response.writeHead(206, {
|
|
149
|
+
'Content-Range': `bytes ${start}-${end}/${size}`,
|
|
150
|
+
'Accept-Ranges': 'bytes',
|
|
151
|
+
'Content-Length': end - start + 1,
|
|
152
|
+
'Content-Type': mime.getType(path.extname(absoluteFilePath)),
|
|
153
|
+
});
|
|
154
|
+
const readable = fs.createReadStream(absoluteFilePath, { start, end });
|
|
155
|
+
readable.pipe(response);
|
|
156
|
+
}
|
|
157
|
+
_onRequest(request, response) {
|
|
158
|
+
if (request.method === 'OPTIONS') {
|
|
159
|
+
response.writeHead(200);
|
|
160
|
+
response.end();
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
request.on('error', () => response.end());
|
|
164
|
+
try {
|
|
165
|
+
if (!request.url) {
|
|
166
|
+
response.end();
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const url = new URL('http://localhost' + request.url);
|
|
170
|
+
for (const route of this._routes) {
|
|
171
|
+
if (route.exact && url.pathname === route.exact) {
|
|
172
|
+
route.handler(request, response);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (route.prefix && url.pathname.startsWith(route.prefix)) {
|
|
176
|
+
route.handler(request, response);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
response.statusCode = 404;
|
|
181
|
+
response.end();
|
|
182
|
+
}
|
|
183
|
+
catch (e) {
|
|
184
|
+
response.end();
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
function decorateServer(server) {
|
|
189
|
+
const sockets = new Set();
|
|
190
|
+
server.on('connection', socket => {
|
|
191
|
+
sockets.add(socket);
|
|
192
|
+
socket.once('close', () => sockets.delete(socket));
|
|
193
|
+
});
|
|
194
|
+
const close = server.close;
|
|
195
|
+
server.close = (callback) => {
|
|
196
|
+
for (const socket of sockets)
|
|
197
|
+
socket.destroy();
|
|
198
|
+
sockets.clear();
|
|
199
|
+
return close.call(server, callback);
|
|
200
|
+
};
|
|
201
|
+
}
|
package/lib/program.js
CHANGED
|
@@ -27,6 +27,7 @@ program
|
|
|
27
27
|
.option('--blocked-origins <origins>', 'semicolon-separated list of origins to block the browser from requesting. Blocklist is evaluated before allowlist. If used without the allowlist, requests not matching the blocklist are still allowed.', semicolonSeparatedList)
|
|
28
28
|
.option('--block-service-workers', 'block service workers')
|
|
29
29
|
.option('--browser <browser>', 'browser or chrome channel to use, possible values: chrome, firefox, webkit, msedge.')
|
|
30
|
+
.option('--browser-agent <endpoint>', 'Use browser agent (experimental).')
|
|
30
31
|
.option('--caps <caps>', 'comma-separated list of capabilities to enable, possible values: tabs, pdf, history, wait, files, install. Default is all.')
|
|
31
32
|
.option('--cdp-endpoint <endpoint>', 'CDP endpoint to connect to.')
|
|
32
33
|
.option('--config <path>', 'path to the configuration file.')
|
package/lib/tab.js
CHANGED
|
@@ -73,7 +73,7 @@ export class Tab {
|
|
|
73
73
|
// on chromium, the download event is fired *after* page.goto rejects, so we wait a lil bit
|
|
74
74
|
const download = await Promise.race([
|
|
75
75
|
downloadEvent,
|
|
76
|
-
new Promise(resolve => setTimeout(resolve,
|
|
76
|
+
new Promise(resolve => setTimeout(resolve, 1000)),
|
|
77
77
|
]);
|
|
78
78
|
if (!download)
|
|
79
79
|
throw e;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@playwright/mcp",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.29",
|
|
4
4
|
"description": "Playwright Tools for MCP",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
"ctest": "playwright test --project=chrome",
|
|
25
25
|
"ftest": "playwright test --project=firefox",
|
|
26
26
|
"wtest": "playwright test --project=webkit",
|
|
27
|
+
"run-server": "node lib/browserServer.js",
|
|
27
28
|
"clean": "rm -rf lib",
|
|
28
29
|
"npm-publish": "npm run clean && npm run build && npm run test && npm publish"
|
|
29
30
|
},
|
|
@@ -38,13 +39,14 @@
|
|
|
38
39
|
"@modelcontextprotocol/sdk": "^1.11.0",
|
|
39
40
|
"commander": "^13.1.0",
|
|
40
41
|
"debug": "^4.4.1",
|
|
41
|
-
"
|
|
42
|
+
"mime": "^4.0.7",
|
|
43
|
+
"playwright": "1.53.0",
|
|
42
44
|
"zod-to-json-schema": "^3.24.4"
|
|
43
45
|
},
|
|
44
46
|
"devDependencies": {
|
|
45
47
|
"@eslint/eslintrc": "^3.2.0",
|
|
46
48
|
"@eslint/js": "^9.19.0",
|
|
47
|
-
"@playwright/test": "1.53.0
|
|
49
|
+
"@playwright/test": "1.53.0",
|
|
48
50
|
"@stylistic/eslint-plugin": "^3.0.1",
|
|
49
51
|
"@types/debug": "^4.1.12",
|
|
50
52
|
"@types/node": "^22.13.10",
|