@playwright/mcp 0.0.25 → 0.0.26
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 +2 -0
- package/config.d.ts +5 -0
- package/lib/config.js +20 -9
- package/lib/context.js +26 -16
- package/lib/index.js +3 -1
- package/lib/pageSnapshot.js +6 -2
- package/lib/program.js +12 -2
- package/lib/tab.js +9 -2
- package/lib/tools/utils.js +12 -11
- package/lib/transport.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -146,6 +146,8 @@ Playwright MCP server supports following arguments. They can be provided in the
|
|
|
146
146
|
example ".com,chromium.org,.domain.com"
|
|
147
147
|
--proxy-server <proxy> specify proxy server, for example
|
|
148
148
|
"http://myproxy:3128" or "socks5://myproxy:8080"
|
|
149
|
+
--save-trace Whether to save the Playwright Trace of the
|
|
150
|
+
session into the output directory.
|
|
149
151
|
--storage-state <path> path to the storage state file for isolated
|
|
150
152
|
sessions.
|
|
151
153
|
--user-agent <ua string> specify user agent string
|
package/config.d.ts
CHANGED
package/lib/config.js
CHANGED
|
@@ -35,11 +35,19 @@ const defaultConfig = {
|
|
|
35
35
|
allowedOrigins: undefined,
|
|
36
36
|
blockedOrigins: undefined,
|
|
37
37
|
},
|
|
38
|
+
outputDir: path.join(os.tmpdir(), 'playwright-mcp-output', sanitizeForFilePath(new Date().toISOString())),
|
|
38
39
|
};
|
|
39
|
-
export async function resolveConfig(
|
|
40
|
-
|
|
40
|
+
export async function resolveConfig(config) {
|
|
41
|
+
return mergeConfig(defaultConfig, config);
|
|
42
|
+
}
|
|
43
|
+
export async function resolveCLIConfig(cliOptions) {
|
|
44
|
+
const configInFile = await loadConfig(cliOptions.config);
|
|
41
45
|
const cliOverrides = await configFromCLIOptions(cliOptions);
|
|
42
|
-
|
|
46
|
+
const result = mergeConfig(mergeConfig(defaultConfig, configInFile), cliOverrides);
|
|
47
|
+
// Derive artifact output directory from config.outputDir
|
|
48
|
+
if (result.saveTrace)
|
|
49
|
+
result.browser.launchOptions.tracesDir = path.join(result.outputDir, 'traces');
|
|
50
|
+
return result;
|
|
43
51
|
}
|
|
44
52
|
export async function configFromCLIOptions(cliOptions) {
|
|
45
53
|
let browserName;
|
|
@@ -127,6 +135,7 @@ export async function configFromCLIOptions(cliOptions) {
|
|
|
127
135
|
allowedOrigins: cliOptions.allowedOrigins,
|
|
128
136
|
blockedOrigins: cliOptions.blockedOrigins,
|
|
129
137
|
},
|
|
138
|
+
saveTrace: cliOptions.saveTrace,
|
|
130
139
|
outputDir: cliOptions.outputDir,
|
|
131
140
|
};
|
|
132
141
|
if (!cliOptions.imageResponses) {
|
|
@@ -156,18 +165,17 @@ async function loadConfig(configFile) {
|
|
|
156
165
|
}
|
|
157
166
|
}
|
|
158
167
|
export async function outputFile(config, name) {
|
|
159
|
-
|
|
160
|
-
await fs.promises.mkdir(result, { recursive: true });
|
|
168
|
+
await fs.promises.mkdir(config.outputDir, { recursive: true });
|
|
161
169
|
const fileName = sanitizeForFilePath(name);
|
|
162
|
-
return path.join(
|
|
170
|
+
return path.join(config.outputDir, fileName);
|
|
163
171
|
}
|
|
164
172
|
function pickDefined(obj) {
|
|
165
173
|
return Object.fromEntries(Object.entries(obj ?? {}).filter(([_, v]) => v !== undefined));
|
|
166
174
|
}
|
|
167
175
|
function mergeConfig(base, overrides) {
|
|
168
176
|
const browser = {
|
|
169
|
-
|
|
170
|
-
|
|
177
|
+
browserName: overrides.browser?.browserName ?? base.browser?.browserName ?? 'chromium',
|
|
178
|
+
isolated: overrides.browser?.isolated ?? base.browser?.isolated ?? false,
|
|
171
179
|
launchOptions: {
|
|
172
180
|
...pickDefined(base.browser?.launchOptions),
|
|
173
181
|
...pickDefined(overrides.browser?.launchOptions),
|
|
@@ -177,6 +185,9 @@ function mergeConfig(base, overrides) {
|
|
|
177
185
|
...pickDefined(base.browser?.contextOptions),
|
|
178
186
|
...pickDefined(overrides.browser?.contextOptions),
|
|
179
187
|
},
|
|
188
|
+
userDataDir: overrides.browser?.userDataDir ?? base.browser?.userDataDir,
|
|
189
|
+
cdpEndpoint: overrides.browser?.cdpEndpoint ?? base.browser?.cdpEndpoint,
|
|
190
|
+
remoteEndpoint: overrides.browser?.remoteEndpoint ?? base.browser?.remoteEndpoint,
|
|
180
191
|
};
|
|
181
192
|
if (browser.browserName !== 'chromium' && browser.launchOptions)
|
|
182
193
|
delete browser.launchOptions.channel;
|
|
@@ -187,6 +198,6 @@ function mergeConfig(base, overrides) {
|
|
|
187
198
|
network: {
|
|
188
199
|
...pickDefined(base.network),
|
|
189
200
|
...pickDefined(overrides.network),
|
|
190
|
-
}
|
|
201
|
+
}
|
|
191
202
|
};
|
|
192
203
|
}
|
package/lib/context.js
CHANGED
|
@@ -18,7 +18,7 @@ import url from 'node:url';
|
|
|
18
18
|
import os from 'node:os';
|
|
19
19
|
import path from 'node:path';
|
|
20
20
|
import * as playwright from 'playwright';
|
|
21
|
-
import { waitForCompletion } from './tools/utils.js';
|
|
21
|
+
import { callOnPageNoTrace, waitForCompletion } from './tools/utils.js';
|
|
22
22
|
import { ManualPromise } from './manualPromise.js';
|
|
23
23
|
import { Tab } from './tab.js';
|
|
24
24
|
import { outputFile } from './config.js';
|
|
@@ -84,7 +84,7 @@ export class Context {
|
|
|
84
84
|
const lines = ['### Open tabs'];
|
|
85
85
|
for (let i = 0; i < this._tabs.length; i++) {
|
|
86
86
|
const tab = this._tabs[i];
|
|
87
|
-
const title = await tab.
|
|
87
|
+
const title = await tab.title();
|
|
88
88
|
const url = tab.page.url();
|
|
89
89
|
const current = tab === this._currentTab ? ' (current)' : '';
|
|
90
90
|
lines.push(`- ${i + 1}:${current} [${title}] (${url})`);
|
|
@@ -116,7 +116,7 @@ export class Context {
|
|
|
116
116
|
let actionResult;
|
|
117
117
|
try {
|
|
118
118
|
if (waitForNetwork)
|
|
119
|
-
actionResult = await waitForCompletion(this, tab
|
|
119
|
+
actionResult = await waitForCompletion(this, tab, async () => racingAction?.()) ?? undefined;
|
|
120
120
|
else
|
|
121
121
|
actionResult = await racingAction?.() ?? undefined;
|
|
122
122
|
}
|
|
@@ -153,7 +153,7 @@ ${code.join('\n')}
|
|
|
153
153
|
result.push(await this.listTabsMarkdown(), '');
|
|
154
154
|
if (this.tabs().length > 1)
|
|
155
155
|
result.push('### Current tab');
|
|
156
|
-
result.push(`- Page URL: ${tab.page.url()}`, `- Page Title: ${await tab.
|
|
156
|
+
result.push(`- Page URL: ${tab.page.url()}`, `- Page Title: ${await tab.title()}`);
|
|
157
157
|
if (captureSnapshot && tab.hasSnapshot())
|
|
158
158
|
result.push(tab.snapshotOrDie().text());
|
|
159
159
|
const content = actionResult?.content ?? [];
|
|
@@ -168,10 +168,13 @@ ${code.join('\n')}
|
|
|
168
168
|
};
|
|
169
169
|
}
|
|
170
170
|
async waitForTimeout(time) {
|
|
171
|
-
if (this._currentTab
|
|
172
|
-
await this._currentTab.page.evaluate(() => new Promise(f => setTimeout(f, 1000)));
|
|
173
|
-
else
|
|
171
|
+
if (!this._currentTab || this._javaScriptBlocked()) {
|
|
174
172
|
await new Promise(f => setTimeout(f, time));
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
await callOnPageNoTrace(this._currentTab.page, page => {
|
|
176
|
+
return page.evaluate(() => new Promise(f => setTimeout(f, 1000)));
|
|
177
|
+
});
|
|
175
178
|
}
|
|
176
179
|
async _raceAgainstModalDialogs(action) {
|
|
177
180
|
this._pendingAction = {
|
|
@@ -233,6 +236,8 @@ ${code.join('\n')}
|
|
|
233
236
|
const promise = this._browserContextPromise;
|
|
234
237
|
this._browserContextPromise = undefined;
|
|
235
238
|
await promise.then(async ({ browserContext, browser }) => {
|
|
239
|
+
if (this.config.saveTrace)
|
|
240
|
+
await browserContext.tracing.stop();
|
|
236
241
|
await browserContext.close().then(async () => {
|
|
237
242
|
await browser?.close();
|
|
238
243
|
}).catch(() => { });
|
|
@@ -264,6 +269,14 @@ ${code.join('\n')}
|
|
|
264
269
|
for (const page of browserContext.pages())
|
|
265
270
|
this._onPageCreated(page);
|
|
266
271
|
browserContext.on('page', page => this._onPageCreated(page));
|
|
272
|
+
if (this.config.saveTrace) {
|
|
273
|
+
await browserContext.tracing.start({
|
|
274
|
+
name: 'trace',
|
|
275
|
+
screenshots: false,
|
|
276
|
+
snapshots: true,
|
|
277
|
+
sources: false,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
267
280
|
return { browser, browserContext };
|
|
268
281
|
}
|
|
269
282
|
async _createBrowserContext() {
|
|
@@ -291,8 +304,8 @@ async function createIsolatedContext(browserConfig) {
|
|
|
291
304
|
try {
|
|
292
305
|
const browserName = browserConfig?.browserName ?? 'chromium';
|
|
293
306
|
const browserType = playwright[browserName];
|
|
294
|
-
const browser = await browserType.launch(browserConfig
|
|
295
|
-
const browserContext = await browser.newContext(browserConfig
|
|
307
|
+
const browser = await browserType.launch(browserConfig.launchOptions);
|
|
308
|
+
const browserContext = await browser.newContext(browserConfig.contextOptions);
|
|
296
309
|
return { browser, browserContext };
|
|
297
310
|
}
|
|
298
311
|
catch (error) {
|
|
@@ -303,10 +316,10 @@ async function createIsolatedContext(browserConfig) {
|
|
|
303
316
|
}
|
|
304
317
|
async function launchPersistentContext(browserConfig) {
|
|
305
318
|
try {
|
|
306
|
-
const browserName = browserConfig
|
|
307
|
-
const userDataDir = browserConfig
|
|
319
|
+
const browserName = browserConfig.browserName ?? 'chromium';
|
|
320
|
+
const userDataDir = browserConfig.userDataDir ?? await createUserDataDir({ ...browserConfig, browserName });
|
|
308
321
|
const browserType = playwright[browserName];
|
|
309
|
-
const browserContext = await browserType.launchPersistentContext(userDataDir, { ...browserConfig
|
|
322
|
+
const browserContext = await browserType.launchPersistentContext(userDataDir, { ...browserConfig.launchOptions, ...browserConfig.contextOptions });
|
|
310
323
|
return { browserContext };
|
|
311
324
|
}
|
|
312
325
|
catch (error) {
|
|
@@ -325,12 +338,9 @@ async function createUserDataDir(browserConfig) {
|
|
|
325
338
|
cacheDirectory = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
|
|
326
339
|
else
|
|
327
340
|
throw new Error('Unsupported platform: ' + process.platform);
|
|
328
|
-
const result = path.join(cacheDirectory, 'ms-playwright', `mcp-${browserConfig
|
|
341
|
+
const result = path.join(cacheDirectory, 'ms-playwright', `mcp-${browserConfig.launchOptions?.channel ?? browserConfig?.browserName}-profile`);
|
|
329
342
|
await fs.promises.mkdir(result, { recursive: true });
|
|
330
343
|
return result;
|
|
331
344
|
}
|
|
332
|
-
export async function generateLocator(locator) {
|
|
333
|
-
return locator._generateLocatorString();
|
|
334
|
-
}
|
|
335
345
|
const __filename = url.fileURLToPath(import.meta.url);
|
|
336
346
|
export const packageJSON = JSON.parse(fs.readFileSync(path.join(path.dirname(__filename), '..', 'package.json'), 'utf8'));
|
package/lib/index.js
CHANGED
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
* limitations under the License.
|
|
15
15
|
*/
|
|
16
16
|
import { createConnection as createConnectionImpl } from './connection.js';
|
|
17
|
-
|
|
17
|
+
import { resolveConfig } from './config.js';
|
|
18
|
+
export async function createConnection(userConfig = {}) {
|
|
19
|
+
const config = await resolveConfig(userConfig);
|
|
18
20
|
return createConnectionImpl(config);
|
|
19
21
|
}
|
package/lib/pageSnapshot.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* See the License for the specific language governing permissions and
|
|
14
14
|
* limitations under the License.
|
|
15
15
|
*/
|
|
16
|
+
import { callOnPageNoTrace } from './tools/utils.js';
|
|
16
17
|
export class PageSnapshot {
|
|
17
18
|
_page;
|
|
18
19
|
_text;
|
|
@@ -28,11 +29,14 @@ export class PageSnapshot {
|
|
|
28
29
|
return this._text;
|
|
29
30
|
}
|
|
30
31
|
async _build() {
|
|
31
|
-
|
|
32
|
+
// FIXME: Rountrip evaluate to ensure _snapshotForAI works.
|
|
33
|
+
// This probably broke once we moved off locator snapshots
|
|
34
|
+
await this._page.evaluate(() => 1);
|
|
35
|
+
const snapshot = await callOnPageNoTrace(this._page, page => page._snapshotForAI());
|
|
32
36
|
this._text = [
|
|
33
37
|
`- Page Snapshot`,
|
|
34
38
|
'```yaml',
|
|
35
|
-
|
|
39
|
+
snapshot,
|
|
36
40
|
'```',
|
|
37
41
|
].join('\n');
|
|
38
42
|
}
|
package/lib/program.js
CHANGED
|
@@ -15,7 +15,9 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { program } from 'commander';
|
|
17
17
|
import { startHttpTransport, startStdioTransport } from './transport.js';
|
|
18
|
-
import {
|
|
18
|
+
import { resolveCLIConfig } from './config.js';
|
|
19
|
+
// @ts-ignore
|
|
20
|
+
import { startTraceViewerServer } from 'playwright-core/lib/server';
|
|
19
21
|
import { packageJSON } from './context.js';
|
|
20
22
|
program
|
|
21
23
|
.version('Version ' + packageJSON.version)
|
|
@@ -39,19 +41,27 @@ program
|
|
|
39
41
|
.option('--port <port>', 'port to listen on for SSE transport.')
|
|
40
42
|
.option('--proxy-bypass <bypass>', 'comma-separated domains to bypass proxy, for example ".com,chromium.org,.domain.com"')
|
|
41
43
|
.option('--proxy-server <proxy>', 'specify proxy server, for example "http://myproxy:3128" or "socks5://myproxy:8080"')
|
|
44
|
+
.option('--save-trace', 'Whether to save the Playwright Trace of the session into the output directory.')
|
|
42
45
|
.option('--storage-state <path>', 'path to the storage state file for isolated sessions.')
|
|
43
46
|
.option('--user-agent <ua string>', 'specify user agent string')
|
|
44
47
|
.option('--user-data-dir <path>', 'path to the user data directory. If not specified, a temporary directory will be created.')
|
|
45
48
|
.option('--viewport-size <size>', 'specify browser viewport size in pixels, for example "1280, 720"')
|
|
46
49
|
.option('--vision', 'Run server that uses screenshots (Aria snapshots are used by default)')
|
|
47
50
|
.action(async (options) => {
|
|
48
|
-
const config = await
|
|
51
|
+
const config = await resolveCLIConfig(options);
|
|
49
52
|
const connectionList = [];
|
|
50
53
|
setupExitWatchdog(connectionList);
|
|
51
54
|
if (options.port)
|
|
52
55
|
startHttpTransport(config, +options.port, options.host, connectionList);
|
|
53
56
|
else
|
|
54
57
|
await startStdioTransport(config, connectionList);
|
|
58
|
+
if (config.saveTrace) {
|
|
59
|
+
const server = await startTraceViewerServer();
|
|
60
|
+
const urlPrefix = server.urlPrefix('human-readable');
|
|
61
|
+
const url = urlPrefix + '/trace/index.html?trace=' + config.browser.launchOptions.tracesDir + '/trace.json';
|
|
62
|
+
// eslint-disable-next-line no-console
|
|
63
|
+
console.error('\nTrace viewer listening on ' + url);
|
|
64
|
+
}
|
|
55
65
|
});
|
|
56
66
|
function setupExitWatchdog(connectionList) {
|
|
57
67
|
const handleExit = async () => {
|
package/lib/tab.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* limitations under the License.
|
|
15
15
|
*/
|
|
16
16
|
import { PageSnapshot } from './pageSnapshot.js';
|
|
17
|
+
import { callOnPageNoTrace } from './tools/utils.js';
|
|
17
18
|
export class Tab {
|
|
18
19
|
context;
|
|
19
20
|
page;
|
|
@@ -51,9 +52,15 @@ export class Tab {
|
|
|
51
52
|
this._clearCollectedArtifacts();
|
|
52
53
|
this._onPageClose(this);
|
|
53
54
|
}
|
|
55
|
+
async title() {
|
|
56
|
+
return await callOnPageNoTrace(this.page, page => page.title());
|
|
57
|
+
}
|
|
58
|
+
async waitForLoadState(state, options) {
|
|
59
|
+
await callOnPageNoTrace(this.page, page => page.waitForLoadState(state, options).catch(() => { }));
|
|
60
|
+
}
|
|
54
61
|
async navigate(url) {
|
|
55
62
|
this._clearCollectedArtifacts();
|
|
56
|
-
const downloadEvent = this.page.waitForEvent('download').catch(() => { });
|
|
63
|
+
const downloadEvent = callOnPageNoTrace(this.page, page => page.waitForEvent('download').catch(() => { }));
|
|
57
64
|
try {
|
|
58
65
|
await this.page.goto(url, { waitUntil: 'domcontentloaded' });
|
|
59
66
|
}
|
|
@@ -72,7 +79,7 @@ export class Tab {
|
|
|
72
79
|
throw e;
|
|
73
80
|
}
|
|
74
81
|
// Cap load event to 5 seconds, the page is operational at this point.
|
|
75
|
-
await this.
|
|
82
|
+
await this.waitForLoadState('load', { timeout: 5000 });
|
|
76
83
|
}
|
|
77
84
|
hasSnapshot() {
|
|
78
85
|
return !!this._snapshot;
|
package/lib/tools/utils.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* See the License for the specific language governing permissions and
|
|
14
14
|
* limitations under the License.
|
|
15
15
|
*/
|
|
16
|
-
export async function waitForCompletion(context,
|
|
16
|
+
export async function waitForCompletion(context, tab, callback) {
|
|
17
17
|
const requests = new Set();
|
|
18
18
|
let frameNavigated = false;
|
|
19
19
|
let waitCallback = () => { };
|
|
@@ -30,22 +30,20 @@ export async function waitForCompletion(context, page, callback) {
|
|
|
30
30
|
frameNavigated = true;
|
|
31
31
|
dispose();
|
|
32
32
|
clearTimeout(timeout);
|
|
33
|
-
void
|
|
34
|
-
waitCallback();
|
|
35
|
-
});
|
|
33
|
+
void tab.waitForLoadState('load').then(waitCallback);
|
|
36
34
|
};
|
|
37
35
|
const onTimeout = () => {
|
|
38
36
|
dispose();
|
|
39
37
|
waitCallback();
|
|
40
38
|
};
|
|
41
|
-
page.on('request', requestListener);
|
|
42
|
-
page.on('requestfinished', requestFinishedListener);
|
|
43
|
-
page.on('framenavigated', frameNavigateListener);
|
|
39
|
+
tab.page.on('request', requestListener);
|
|
40
|
+
tab.page.on('requestfinished', requestFinishedListener);
|
|
41
|
+
tab.page.on('framenavigated', frameNavigateListener);
|
|
44
42
|
const timeout = setTimeout(onTimeout, 10000);
|
|
45
43
|
const dispose = () => {
|
|
46
|
-
page.off('request', requestListener);
|
|
47
|
-
page.off('requestfinished', requestFinishedListener);
|
|
48
|
-
page.off('framenavigated', frameNavigateListener);
|
|
44
|
+
tab.page.off('request', requestListener);
|
|
45
|
+
tab.page.off('requestfinished', requestFinishedListener);
|
|
46
|
+
tab.page.off('framenavigated', frameNavigateListener);
|
|
49
47
|
clearTimeout(timeout);
|
|
50
48
|
};
|
|
51
49
|
try {
|
|
@@ -68,5 +66,8 @@ export function sanitizeForFilePath(s) {
|
|
|
68
66
|
return sanitize(s.substring(0, separator)) + '.' + sanitize(s.substring(separator + 1));
|
|
69
67
|
}
|
|
70
68
|
export async function generateLocator(locator) {
|
|
71
|
-
return locator._generateLocatorString();
|
|
69
|
+
return locator._frame._wrapApiCall(() => locator._generateLocatorString(), true);
|
|
70
|
+
}
|
|
71
|
+
export async function callOnPageNoTrace(page, callback) {
|
|
72
|
+
return await page._wrapApiCall(() => callback(page), true);
|
|
72
73
|
}
|
package/lib/transport.js
CHANGED
|
@@ -127,6 +127,6 @@ export function startHttpTransport(config, port, hostname, connectionList) {
|
|
|
127
127
|
'If your client supports streamable HTTP, you can use the /mcp endpoint instead.',
|
|
128
128
|
].join('\n');
|
|
129
129
|
// eslint-disable-next-line no-console
|
|
130
|
-
console.
|
|
130
|
+
console.error(message);
|
|
131
131
|
});
|
|
132
132
|
}
|