@playwright/mcp 0.0.33 → 0.0.35-alpha-2025-08-27
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 +60 -39
- package/config.d.ts +1 -1
- package/lib/browserContextFactory.js +32 -26
- package/lib/browserServerBackend.js +18 -62
- package/lib/config.js +1 -1
- package/lib/context.js +6 -6
- package/lib/extension/cdpRelay.js +33 -21
- package/lib/extension/extensionContextFactory.js +9 -9
- package/lib/extension/protocol.js +18 -0
- package/lib/index.js +2 -1
- package/lib/loopTools/context.js +3 -2
- package/lib/loopTools/main.js +15 -10
- package/lib/mcp/{transport.js → http.js} +51 -37
- package/lib/mcp/mdb.js +198 -0
- package/lib/mcp/proxyBackend.js +104 -0
- package/lib/mcp/server.js +63 -33
- package/lib/mcp/tool.js +32 -0
- package/lib/program.js +49 -20
- package/lib/sessionLog.js +1 -1
- package/lib/tab.js +2 -2
- package/lib/tools/evaluate.js +1 -1
- package/lib/tools/form.js +57 -0
- package/lib/tools/keyboard.js +1 -1
- package/lib/tools/navigate.js +0 -16
- package/lib/tools/pdf.js +1 -1
- package/lib/tools/screenshot.js +1 -1
- package/lib/tools/snapshot.js +1 -1
- package/lib/tools/tabs.js +31 -59
- package/lib/tools/verify.js +137 -0
- package/lib/tools/wait.js +3 -4
- package/lib/tools.js +5 -1
- package/lib/{javascript.js → utils/codegen.js} +1 -1
- package/lib/{fileUtils.js → utils/fileUtils.js} +6 -2
- package/lib/{utils.js → utils/guid.js} +3 -7
- package/lib/{package.js → utils/package.js} +1 -1
- package/lib/vscode/host.js +128 -0
- package/lib/vscode/main.js +62 -0
- package/package.json +6 -5
- package/lib/extension/main.js +0 -26
- package/lib/httpServer.js +0 -39
- /package/lib/{manualPromise.js → mcp/manualPromise.js} +0 -0
- /package/lib/{log.js → utils/log.js} +0 -0
package/README.md
CHANGED
|
@@ -56,6 +56,21 @@ Follow the MCP install [guide](https://modelcontextprotocol.io/quickstart/user),
|
|
|
56
56
|
|
|
57
57
|
</details>
|
|
58
58
|
|
|
59
|
+
<details>
|
|
60
|
+
<summary>Codex</summary>
|
|
61
|
+
|
|
62
|
+
Create or edit the configuration file `~/.codex/config.toml` and add:
|
|
63
|
+
|
|
64
|
+
```toml
|
|
65
|
+
[mcp_servers.playwright]
|
|
66
|
+
command = "npx"
|
|
67
|
+
args = ["@playwright/mcp@latest"]
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
For more information, see the [Codex MCP documentation](https://github.com/openai/codex/blob/main/codex-rs/config.md#mcp_servers).
|
|
71
|
+
|
|
72
|
+
</details>
|
|
73
|
+
|
|
59
74
|
<details>
|
|
60
75
|
<summary>Cursor</summary>
|
|
61
76
|
|
|
@@ -100,6 +115,29 @@ Go to `Advanced settings` -> `Extensions` -> `Add custom extension`. Name to you
|
|
|
100
115
|
Go to `Program` in the right sidebar -> `Install` -> `Edit mcp.json`. Use the standard config above.
|
|
101
116
|
</details>
|
|
102
117
|
|
|
118
|
+
<details>
|
|
119
|
+
<summary>opencode</summary>
|
|
120
|
+
|
|
121
|
+
Follow the MCP Servers [documentation](https://opencode.ai/docs/mcp-servers/). For example in `~/.config/opencode/opencode.json`:
|
|
122
|
+
|
|
123
|
+
```json
|
|
124
|
+
{
|
|
125
|
+
"$schema": "https://opencode.ai/config.json",
|
|
126
|
+
"mcp": {
|
|
127
|
+
"playwright": {
|
|
128
|
+
"type": "local",
|
|
129
|
+
"command": [
|
|
130
|
+
"npx",
|
|
131
|
+
"@playwright/mcp@latest"
|
|
132
|
+
],
|
|
133
|
+
"enabled": true
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
```
|
|
139
|
+
</details>
|
|
140
|
+
|
|
103
141
|
<details>
|
|
104
142
|
<summary>Qodo Gen</summary>
|
|
105
143
|
|
|
@@ -158,6 +196,9 @@ Playwright MCP server supports following arguments. They can be provided in the
|
|
|
158
196
|
--config <path> path to the configuration file.
|
|
159
197
|
--device <device> device to emulate, for example: "iPhone 15"
|
|
160
198
|
--executable-path <path> path to the browser executable.
|
|
199
|
+
--extension Connect to a running browser instance
|
|
200
|
+
(Edge/Chrome only). Requires the "Playwright MCP
|
|
201
|
+
Bridge" browser extension to be installed.
|
|
161
202
|
--headless run browser in headless mode, headed by default
|
|
162
203
|
--host <host> host to bind server to. Default is localhost. Use
|
|
163
204
|
0.0.0.0 to bind to all interfaces.
|
|
@@ -191,7 +232,7 @@ Playwright MCP server supports following arguments. They can be provided in the
|
|
|
191
232
|
|
|
192
233
|
### User profile
|
|
193
234
|
|
|
194
|
-
You can run Playwright MCP with persistent profile like a regular browser (default),
|
|
235
|
+
You can run Playwright MCP with persistent profile like a regular browser (default), in isolated contexts for testing sessions, or connect to your existing browser using the browser extension.
|
|
195
236
|
|
|
196
237
|
**Persistent profile**
|
|
197
238
|
|
|
@@ -231,6 +272,10 @@ state [here](https://playwright.dev/docs/auth).
|
|
|
231
272
|
}
|
|
232
273
|
```
|
|
233
274
|
|
|
275
|
+
**Browser Extension**
|
|
276
|
+
|
|
277
|
+
The Playwright MCP Chrome Extension allows you to connect to existing browser tabs and leverage your logged-in sessions and browser state. See [extension/README.md](extension/README.md) for installation and setup instructions.
|
|
278
|
+
|
|
234
279
|
### Configuration file
|
|
235
280
|
|
|
236
281
|
The Playwright MCP server can be configured using a JSON configuration file. You can specify the configuration file
|
|
@@ -449,6 +494,15 @@ http.createServer(async (req, res) => {
|
|
|
449
494
|
|
|
450
495
|
<!-- NOTE: This has been generated via update-readme.js -->
|
|
451
496
|
|
|
497
|
+
- **browser_fill_form**
|
|
498
|
+
- Title: Fill form
|
|
499
|
+
- Description: Fill multiple form fields
|
|
500
|
+
- Parameters:
|
|
501
|
+
- `fields` (array): Fields to fill in
|
|
502
|
+
- Read-only: **false**
|
|
503
|
+
|
|
504
|
+
<!-- NOTE: This has been generated via update-readme.js -->
|
|
505
|
+
|
|
452
506
|
- **browser_handle_dialog**
|
|
453
507
|
- Title: Handle a dialog
|
|
454
508
|
- Description: Handle a dialog
|
|
@@ -486,14 +540,6 @@ http.createServer(async (req, res) => {
|
|
|
486
540
|
|
|
487
541
|
<!-- NOTE: This has been generated via update-readme.js -->
|
|
488
542
|
|
|
489
|
-
- **browser_navigate_forward**
|
|
490
|
-
- Title: Go forward
|
|
491
|
-
- Description: Go forward to the next page
|
|
492
|
-
- Parameters: None
|
|
493
|
-
- Read-only: **true**
|
|
494
|
-
|
|
495
|
-
<!-- NOTE: This has been generated via update-readme.js -->
|
|
496
|
-
|
|
497
543
|
- **browser_network_requests**
|
|
498
544
|
- Title: List network requests
|
|
499
545
|
- Description: Returns all network requests since loading the page
|
|
@@ -582,39 +628,14 @@ http.createServer(async (req, res) => {
|
|
|
582
628
|
|
|
583
629
|
<!-- NOTE: This has been generated via update-readme.js -->
|
|
584
630
|
|
|
585
|
-
- **
|
|
586
|
-
- Title:
|
|
587
|
-
- Description:
|
|
631
|
+
- **browser_tabs**
|
|
632
|
+
- Title: Manage tabs
|
|
633
|
+
- Description: List, create, close, or select a browser tab.
|
|
588
634
|
- Parameters:
|
|
589
|
-
- `
|
|
635
|
+
- `action` (string): Operation to perform
|
|
636
|
+
- `index` (number, optional): Tab index, used for close/select. If omitted for close, current tab is closed.
|
|
590
637
|
- Read-only: **false**
|
|
591
638
|
|
|
592
|
-
<!-- NOTE: This has been generated via update-readme.js -->
|
|
593
|
-
|
|
594
|
-
- **browser_tab_list**
|
|
595
|
-
- Title: List tabs
|
|
596
|
-
- Description: List browser tabs
|
|
597
|
-
- Parameters: None
|
|
598
|
-
- Read-only: **true**
|
|
599
|
-
|
|
600
|
-
<!-- NOTE: This has been generated via update-readme.js -->
|
|
601
|
-
|
|
602
|
-
- **browser_tab_new**
|
|
603
|
-
- Title: Open a new tab
|
|
604
|
-
- Description: Open a new tab
|
|
605
|
-
- Parameters:
|
|
606
|
-
- `url` (string, optional): The URL to navigate to in the new tab. If not provided, the new tab will be blank.
|
|
607
|
-
- Read-only: **true**
|
|
608
|
-
|
|
609
|
-
<!-- NOTE: This has been generated via update-readme.js -->
|
|
610
|
-
|
|
611
|
-
- **browser_tab_select**
|
|
612
|
-
- Title: Select a tab
|
|
613
|
-
- Description: Select a tab by index
|
|
614
|
-
- Parameters:
|
|
615
|
-
- `index` (number): The index of the tab to select
|
|
616
|
-
- Read-only: **true**
|
|
617
|
-
|
|
618
639
|
</details>
|
|
619
640
|
|
|
620
641
|
<details>
|
package/config.d.ts
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
import type * as playwright from 'playwright';
|
|
18
18
|
|
|
19
|
-
export type ToolCapability = 'core' | 'core-tabs' | 'core-install' | 'vision' | 'pdf';
|
|
19
|
+
export type ToolCapability = 'core' | 'core-tabs' | 'core-install' | 'vision' | 'pdf' | 'verify';
|
|
20
20
|
|
|
21
21
|
export type Config = {
|
|
22
22
|
/**
|
|
@@ -19,8 +19,10 @@ import path from 'path';
|
|
|
19
19
|
import * as playwright from 'playwright';
|
|
20
20
|
// @ts-ignore
|
|
21
21
|
import { registryDirectory } from 'playwright-core/lib/server/registry/index';
|
|
22
|
-
|
|
23
|
-
import {
|
|
22
|
+
// @ts-ignore
|
|
23
|
+
import { startTraceViewerServer } from 'playwright-core/lib/server';
|
|
24
|
+
import { logUnhandledError, testDebug } from './utils/log.js';
|
|
25
|
+
import { createHash } from './utils/guid.js';
|
|
24
26
|
import { outputFile } from './config.js';
|
|
25
27
|
export function contextFactory(config) {
|
|
26
28
|
if (config.browser.remoteEndpoint)
|
|
@@ -32,21 +34,18 @@ export function contextFactory(config) {
|
|
|
32
34
|
return new PersistentContextFactory(config);
|
|
33
35
|
}
|
|
34
36
|
class BaseContextFactory {
|
|
35
|
-
name;
|
|
36
|
-
description;
|
|
37
37
|
config;
|
|
38
|
+
_logName;
|
|
38
39
|
_browserPromise;
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
this.name = name;
|
|
42
|
-
this.description = description;
|
|
40
|
+
constructor(name, config) {
|
|
41
|
+
this._logName = name;
|
|
43
42
|
this.config = config;
|
|
44
43
|
}
|
|
45
|
-
async _obtainBrowser() {
|
|
44
|
+
async _obtainBrowser(clientInfo) {
|
|
46
45
|
if (this._browserPromise)
|
|
47
46
|
return this._browserPromise;
|
|
48
|
-
testDebug(`obtain browser (${this.
|
|
49
|
-
this._browserPromise = this._doObtainBrowser();
|
|
47
|
+
testDebug(`obtain browser (${this._logName})`);
|
|
48
|
+
this._browserPromise = this._doObtainBrowser(clientInfo);
|
|
50
49
|
void this._browserPromise.then(browser => {
|
|
51
50
|
browser.on('disconnected', () => {
|
|
52
51
|
this._browserPromise = undefined;
|
|
@@ -56,14 +55,12 @@ class BaseContextFactory {
|
|
|
56
55
|
});
|
|
57
56
|
return this._browserPromise;
|
|
58
57
|
}
|
|
59
|
-
async _doObtainBrowser() {
|
|
58
|
+
async _doObtainBrowser(clientInfo) {
|
|
60
59
|
throw new Error('Not implemented');
|
|
61
60
|
}
|
|
62
61
|
async createContext(clientInfo) {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
testDebug(`create browser context (${this.name})`);
|
|
66
|
-
const browser = await this._obtainBrowser();
|
|
62
|
+
testDebug(`create browser context (${this._logName})`);
|
|
63
|
+
const browser = await this._obtainBrowser(clientInfo);
|
|
67
64
|
const browserContext = await this._doCreateContext(browser);
|
|
68
65
|
return { browserContext, close: () => this._closeBrowserContext(browserContext, browser) };
|
|
69
66
|
}
|
|
@@ -71,25 +68,25 @@ class BaseContextFactory {
|
|
|
71
68
|
throw new Error('Not implemented');
|
|
72
69
|
}
|
|
73
70
|
async _closeBrowserContext(browserContext, browser) {
|
|
74
|
-
testDebug(`close browser context (${this.
|
|
71
|
+
testDebug(`close browser context (${this._logName})`);
|
|
75
72
|
if (browser.contexts().length === 1)
|
|
76
73
|
this._browserPromise = undefined;
|
|
77
74
|
await browserContext.close().catch(logUnhandledError);
|
|
78
75
|
if (browser.contexts().length === 0) {
|
|
79
|
-
testDebug(`close browser (${this.
|
|
76
|
+
testDebug(`close browser (${this._logName})`);
|
|
80
77
|
await browser.close().catch(logUnhandledError);
|
|
81
78
|
}
|
|
82
79
|
}
|
|
83
80
|
}
|
|
84
81
|
class IsolatedContextFactory extends BaseContextFactory {
|
|
85
82
|
constructor(config) {
|
|
86
|
-
super('isolated',
|
|
83
|
+
super('isolated', config);
|
|
87
84
|
}
|
|
88
|
-
async _doObtainBrowser() {
|
|
85
|
+
async _doObtainBrowser(clientInfo) {
|
|
89
86
|
await injectCdpPort(this.config.browser);
|
|
90
87
|
const browserType = playwright[this.config.browser.browserName];
|
|
91
88
|
return browserType.launch({
|
|
92
|
-
tracesDir: this.
|
|
89
|
+
tracesDir: await startTraceServer(this.config, clientInfo.rootPath),
|
|
93
90
|
...this.config.browser.launchOptions,
|
|
94
91
|
handleSIGINT: false,
|
|
95
92
|
handleSIGTERM: false,
|
|
@@ -105,7 +102,7 @@ class IsolatedContextFactory extends BaseContextFactory {
|
|
|
105
102
|
}
|
|
106
103
|
class CdpContextFactory extends BaseContextFactory {
|
|
107
104
|
constructor(config) {
|
|
108
|
-
super('cdp',
|
|
105
|
+
super('cdp', config);
|
|
109
106
|
}
|
|
110
107
|
async _doObtainBrowser() {
|
|
111
108
|
return playwright.chromium.connectOverCDP(this.config.browser.cdpEndpoint);
|
|
@@ -116,7 +113,7 @@ class CdpContextFactory extends BaseContextFactory {
|
|
|
116
113
|
}
|
|
117
114
|
class RemoteContextFactory extends BaseContextFactory {
|
|
118
115
|
constructor(config) {
|
|
119
|
-
super('remote',
|
|
116
|
+
super('remote', config);
|
|
120
117
|
}
|
|
121
118
|
async _doObtainBrowser() {
|
|
122
119
|
const url = new URL(this.config.browser.remoteEndpoint);
|
|
@@ -141,9 +138,7 @@ class PersistentContextFactory {
|
|
|
141
138
|
await injectCdpPort(this.config.browser);
|
|
142
139
|
testDebug('create browser context (persistent)');
|
|
143
140
|
const userDataDir = this.config.browser.userDataDir ?? await this._createUserDataDir(clientInfo.rootPath);
|
|
144
|
-
|
|
145
|
-
if (this.config.saveTrace)
|
|
146
|
-
tracesDir = await outputFile(this.config, clientInfo.rootPath, `traces-${Date.now()}`);
|
|
141
|
+
const tracesDir = await startTraceServer(this.config, clientInfo.rootPath);
|
|
147
142
|
this._userDataDirs.add(userDataDir);
|
|
148
143
|
testDebug('lock user data dir', userDataDir);
|
|
149
144
|
const browserType = playwright[this.config.browser.browserName];
|
|
@@ -203,3 +198,14 @@ async function findFreePort() {
|
|
|
203
198
|
server.on('error', reject);
|
|
204
199
|
});
|
|
205
200
|
}
|
|
201
|
+
async function startTraceServer(config, rootPath) {
|
|
202
|
+
if (!config.saveTrace)
|
|
203
|
+
return undefined;
|
|
204
|
+
const tracesDir = await outputFile(config, rootPath, `traces-${Date.now()}`);
|
|
205
|
+
const server = await startTraceViewerServer();
|
|
206
|
+
const urlPrefix = server.urlPrefix('human-readable');
|
|
207
|
+
const url = urlPrefix + '/trace/index.html?trace=' + tracesDir + '/trace.json';
|
|
208
|
+
// eslint-disable-next-line no-console
|
|
209
|
+
console.error('\nTrace viewer listening on ' + url);
|
|
210
|
+
return tracesDir;
|
|
211
|
+
}
|
|
@@ -14,35 +14,26 @@
|
|
|
14
14
|
* limitations under the License.
|
|
15
15
|
*/
|
|
16
16
|
import { fileURLToPath } from 'url';
|
|
17
|
-
import { z } from 'zod';
|
|
18
17
|
import { Context } from './context.js';
|
|
19
|
-
import { logUnhandledError } from './log.js';
|
|
18
|
+
import { logUnhandledError } from './utils/log.js';
|
|
20
19
|
import { Response } from './response.js';
|
|
21
20
|
import { SessionLog } from './sessionLog.js';
|
|
22
21
|
import { filteredTools } from './tools.js';
|
|
23
|
-
import {
|
|
24
|
-
import { defineTool } from './tools/tool.js';
|
|
22
|
+
import { toMcpTool } from './mcp/tool.js';
|
|
25
23
|
export class BrowserServerBackend {
|
|
26
|
-
name = 'Playwright';
|
|
27
|
-
version = packageJSON.version;
|
|
28
24
|
_tools;
|
|
29
25
|
_context;
|
|
30
26
|
_sessionLog;
|
|
31
27
|
_config;
|
|
32
28
|
_browserContextFactory;
|
|
33
|
-
constructor(config,
|
|
29
|
+
constructor(config, factory) {
|
|
34
30
|
this._config = config;
|
|
35
|
-
this._browserContextFactory =
|
|
31
|
+
this._browserContextFactory = factory;
|
|
36
32
|
this._tools = filteredTools(config);
|
|
37
|
-
if (factories.length > 1)
|
|
38
|
-
this._tools.push(this._defineContextSwitchTool(factories));
|
|
39
33
|
}
|
|
40
|
-
async initialize(server) {
|
|
41
|
-
const capabilities = server.getClientCapabilities();
|
|
34
|
+
async initialize(server, clientVersion, roots) {
|
|
42
35
|
let rootPath;
|
|
43
|
-
if (
|
|
44
|
-
server.getClientVersion()?.name === 'Visual Studio Code - Insiders')) {
|
|
45
|
-
const { roots } = await server.listRoots();
|
|
36
|
+
if (roots.length > 0) {
|
|
46
37
|
const firstRootUri = roots[0]?.uri;
|
|
47
38
|
const url = firstRootUri ? new URL(firstRootUri) : undefined;
|
|
48
39
|
rootPath = url ? fileURLToPath(url) : undefined;
|
|
@@ -53,17 +44,20 @@ export class BrowserServerBackend {
|
|
|
53
44
|
config: this._config,
|
|
54
45
|
browserContextFactory: this._browserContextFactory,
|
|
55
46
|
sessionLog: this._sessionLog,
|
|
56
|
-
clientInfo: { ...
|
|
47
|
+
clientInfo: { ...clientVersion, rootPath },
|
|
57
48
|
});
|
|
58
49
|
}
|
|
59
|
-
|
|
60
|
-
return this._tools.map(tool => tool.schema);
|
|
50
|
+
async listTools() {
|
|
51
|
+
return this._tools.map(tool => toMcpTool(tool.schema));
|
|
61
52
|
}
|
|
62
|
-
async callTool(
|
|
53
|
+
async callTool(name, rawArguments) {
|
|
54
|
+
const tool = this._tools.find(tool => tool.schema.name === name);
|
|
55
|
+
if (!tool)
|
|
56
|
+
throw new Error(`Tool "${name}" not found`);
|
|
57
|
+
const parsedArguments = tool.schema.inputSchema.parse(rawArguments || {});
|
|
63
58
|
const context = this._context;
|
|
64
|
-
const response = new Response(context,
|
|
65
|
-
|
|
66
|
-
context.setRunningTool(true);
|
|
59
|
+
const response = new Response(context, name, parsedArguments);
|
|
60
|
+
context.setRunningTool(name);
|
|
67
61
|
try {
|
|
68
62
|
await tool.handle(context, parsedArguments, response);
|
|
69
63
|
await response.finish();
|
|
@@ -73,49 +67,11 @@ export class BrowserServerBackend {
|
|
|
73
67
|
response.addError(String(error));
|
|
74
68
|
}
|
|
75
69
|
finally {
|
|
76
|
-
context.setRunningTool(
|
|
70
|
+
context.setRunningTool(undefined);
|
|
77
71
|
}
|
|
78
72
|
return response.serialize();
|
|
79
73
|
}
|
|
80
74
|
serverClosed() {
|
|
81
|
-
void this._context
|
|
82
|
-
}
|
|
83
|
-
_defineContextSwitchTool(factories) {
|
|
84
|
-
const self = this;
|
|
85
|
-
return defineTool({
|
|
86
|
-
capability: 'core',
|
|
87
|
-
schema: {
|
|
88
|
-
name: 'browser_connect',
|
|
89
|
-
title: 'Connect to a browser context',
|
|
90
|
-
description: [
|
|
91
|
-
'Connect to a browser using one of the available methods:',
|
|
92
|
-
...factories.map(factory => `- "${factory.name}": ${factory.description}`),
|
|
93
|
-
].join('\n'),
|
|
94
|
-
inputSchema: z.object({
|
|
95
|
-
method: z.enum(factories.map(factory => factory.name)).default(factories[0].name).describe('The method to use to connect to the browser'),
|
|
96
|
-
}),
|
|
97
|
-
type: 'readOnly',
|
|
98
|
-
},
|
|
99
|
-
async handle(context, params, response) {
|
|
100
|
-
const factory = factories.find(factory => factory.name === params.method);
|
|
101
|
-
if (!factory) {
|
|
102
|
-
response.addError('Unknown connection method: ' + params.method);
|
|
103
|
-
return;
|
|
104
|
-
}
|
|
105
|
-
await self._setContextFactory(factory);
|
|
106
|
-
response.addResult('Successfully changed connection method.');
|
|
107
|
-
}
|
|
108
|
-
});
|
|
109
|
-
}
|
|
110
|
-
async _setContextFactory(newFactory) {
|
|
111
|
-
if (this._context) {
|
|
112
|
-
const options = {
|
|
113
|
-
...this._context.options,
|
|
114
|
-
browserContextFactory: newFactory,
|
|
115
|
-
};
|
|
116
|
-
await this._context.dispose();
|
|
117
|
-
this._context = new Context(options);
|
|
118
|
-
}
|
|
119
|
-
this._browserContextFactory = newFactory;
|
|
75
|
+
void this._context?.dispose().catch(logUnhandledError);
|
|
120
76
|
}
|
|
121
77
|
}
|
package/lib/config.js
CHANGED
|
@@ -17,7 +17,7 @@ import fs from 'fs';
|
|
|
17
17
|
import os from 'os';
|
|
18
18
|
import path from 'path';
|
|
19
19
|
import { devices } from 'playwright';
|
|
20
|
-
import { sanitizeForFilePath } from './utils.js';
|
|
20
|
+
import { sanitizeForFilePath } from './utils/fileUtils.js';
|
|
21
21
|
const defaultConfig = {
|
|
22
22
|
browser: {
|
|
23
23
|
browserName: 'chromium',
|
package/lib/context.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* limitations under the License.
|
|
15
15
|
*/
|
|
16
16
|
import debug from 'debug';
|
|
17
|
-
import { logUnhandledError } from './log.js';
|
|
17
|
+
import { logUnhandledError } from './utils/log.js';
|
|
18
18
|
import { Tab } from './tab.js';
|
|
19
19
|
import { outputFile } from './config.js';
|
|
20
20
|
const testDebug = debug('pw:mcp:test');
|
|
@@ -30,7 +30,7 @@ export class Context {
|
|
|
30
30
|
_clientInfo;
|
|
31
31
|
static _allContexts = new Set();
|
|
32
32
|
_closeBrowserContextPromise;
|
|
33
|
-
|
|
33
|
+
_runningToolName;
|
|
34
34
|
_abortController = new AbortController();
|
|
35
35
|
constructor(options) {
|
|
36
36
|
this.tools = options.tools;
|
|
@@ -110,10 +110,10 @@ export class Context {
|
|
|
110
110
|
this._closeBrowserContextPromise = undefined;
|
|
111
111
|
}
|
|
112
112
|
isRunningTool() {
|
|
113
|
-
return this.
|
|
113
|
+
return this._runningToolName !== undefined;
|
|
114
114
|
}
|
|
115
|
-
setRunningTool(
|
|
116
|
-
this.
|
|
115
|
+
setRunningTool(name) {
|
|
116
|
+
this._runningToolName = name;
|
|
117
117
|
}
|
|
118
118
|
async _closeBrowserContextImpl() {
|
|
119
119
|
if (!this._browserContextPromise)
|
|
@@ -156,7 +156,7 @@ export class Context {
|
|
|
156
156
|
if (this._closeBrowserContextPromise)
|
|
157
157
|
throw new Error('Another browser context is being closed.');
|
|
158
158
|
// TODO: move to the browser context factory to make it based on isolation mode.
|
|
159
|
-
const result = await this._browserContextFactory.createContext(this._clientInfo, this._abortController.signal);
|
|
159
|
+
const result = await this._browserContextFactory.createContext(this._clientInfo, this._abortController.signal, this._runningToolName);
|
|
160
160
|
const { browserContext } = result;
|
|
161
161
|
await this._setupRequestInterception(browserContext);
|
|
162
162
|
if (this.sessionLog)
|
|
@@ -23,9 +23,10 @@
|
|
|
23
23
|
import { spawn } from 'child_process';
|
|
24
24
|
import debug from 'debug';
|
|
25
25
|
import { WebSocket, WebSocketServer } from 'ws';
|
|
26
|
-
import { httpAddressToString } from '../
|
|
27
|
-
import { logUnhandledError } from '../log.js';
|
|
28
|
-
import { ManualPromise } from '../manualPromise.js';
|
|
26
|
+
import { httpAddressToString } from '../mcp/http.js';
|
|
27
|
+
import { logUnhandledError } from '../utils/log.js';
|
|
28
|
+
import { ManualPromise } from '../mcp/manualPromise.js';
|
|
29
|
+
import * as protocol from './protocol.js';
|
|
29
30
|
// @ts-ignore
|
|
30
31
|
const { registry } = await import('playwright-core/lib/server/registry/index');
|
|
31
32
|
const debugLogger = debug('pw:mcp:relay');
|
|
@@ -33,6 +34,7 @@ export class CDPRelayServer {
|
|
|
33
34
|
_wsHost;
|
|
34
35
|
_browserChannel;
|
|
35
36
|
_userDataDir;
|
|
37
|
+
_executablePath;
|
|
36
38
|
_cdpPath;
|
|
37
39
|
_extensionPath;
|
|
38
40
|
_wss;
|
|
@@ -41,10 +43,11 @@ export class CDPRelayServer {
|
|
|
41
43
|
_connectedTabInfo;
|
|
42
44
|
_nextSessionId = 1;
|
|
43
45
|
_extensionConnectionPromise;
|
|
44
|
-
constructor(server, browserChannel, userDataDir) {
|
|
46
|
+
constructor(server, browserChannel, userDataDir, executablePath) {
|
|
45
47
|
this._wsHost = httpAddressToString(server.address()).replace(/^http/, 'ws');
|
|
46
48
|
this._browserChannel = browserChannel;
|
|
47
49
|
this._userDataDir = userDataDir;
|
|
50
|
+
this._executablePath = executablePath;
|
|
48
51
|
const uuid = crypto.randomUUID();
|
|
49
52
|
this._cdpPath = `/cdp/${uuid}`;
|
|
50
53
|
this._extensionPath = `/extension/${uuid}`;
|
|
@@ -58,31 +61,44 @@ export class CDPRelayServer {
|
|
|
58
61
|
extensionEndpoint() {
|
|
59
62
|
return `${this._wsHost}${this._extensionPath}`;
|
|
60
63
|
}
|
|
61
|
-
async ensureExtensionConnectionForMCPContext(clientInfo, abortSignal) {
|
|
64
|
+
async ensureExtensionConnectionForMCPContext(clientInfo, abortSignal, toolName) {
|
|
62
65
|
debugLogger('Ensuring extension connection for MCP context');
|
|
63
66
|
if (this._extensionConnection)
|
|
64
67
|
return;
|
|
65
|
-
this._connectBrowser(clientInfo);
|
|
68
|
+
this._connectBrowser(clientInfo, toolName);
|
|
66
69
|
debugLogger('Waiting for incoming extension connection');
|
|
67
70
|
await Promise.race([
|
|
68
71
|
this._extensionConnectionPromise,
|
|
72
|
+
new Promise((_, reject) => setTimeout(() => {
|
|
73
|
+
reject(new Error(`Extension connection timeout. Make sure the "Playwright MCP Bridge" extension is installed. See https://github.com/microsoft/playwright-mcp/blob/main/extension/README.md for installation instructions.`));
|
|
74
|
+
}, process.env.PWMCP_TEST_CONNECTION_TIMEOUT ? parseInt(process.env.PWMCP_TEST_CONNECTION_TIMEOUT, 10) : 5_000)),
|
|
69
75
|
new Promise((_, reject) => abortSignal.addEventListener('abort', reject))
|
|
70
76
|
]);
|
|
71
77
|
debugLogger('Extension connection established');
|
|
72
78
|
}
|
|
73
|
-
_connectBrowser(clientInfo) {
|
|
79
|
+
_connectBrowser(clientInfo, toolName) {
|
|
74
80
|
const mcpRelayEndpoint = `${this._wsHost}${this._extensionPath}`;
|
|
75
81
|
// Need to specify "key" in the manifest.json to make the id stable when loading from file.
|
|
76
82
|
const url = new URL('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html');
|
|
77
83
|
url.searchParams.set('mcpRelayUrl', mcpRelayEndpoint);
|
|
78
|
-
|
|
84
|
+
const client = {
|
|
85
|
+
name: clientInfo.name,
|
|
86
|
+
version: clientInfo.version,
|
|
87
|
+
};
|
|
88
|
+
url.searchParams.set('client', JSON.stringify(client));
|
|
89
|
+
url.searchParams.set('protocolVersion', process.env.PWMCP_TEST_PROTOCOL_VERSION ?? protocol.VERSION.toString());
|
|
90
|
+
if (toolName)
|
|
91
|
+
url.searchParams.set('newTab', String(toolName === 'browser_navigate'));
|
|
79
92
|
const href = url.toString();
|
|
80
|
-
|
|
81
|
-
if (!
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
93
|
+
let executablePath = this._executablePath;
|
|
94
|
+
if (!executablePath) {
|
|
95
|
+
const executableInfo = registry.findExecutable(this._browserChannel);
|
|
96
|
+
if (!executableInfo)
|
|
97
|
+
throw new Error(`Unsupported channel: "${this._browserChannel}"`);
|
|
98
|
+
executablePath = executableInfo.executablePath();
|
|
99
|
+
if (!executablePath)
|
|
100
|
+
throw new Error(`"${this._browserChannel}" executable not found. Make sure it is installed at a standard location.`);
|
|
101
|
+
}
|
|
86
102
|
const args = [];
|
|
87
103
|
if (this._userDataDir)
|
|
88
104
|
args.push(`--user-data-dir=${this._userDataDir}`);
|
|
@@ -186,10 +202,6 @@ export class CDPRelayServer {
|
|
|
186
202
|
params: params.params
|
|
187
203
|
});
|
|
188
204
|
break;
|
|
189
|
-
case 'detachedFromTab':
|
|
190
|
-
debugLogger('← Debugger detached from tab:', params);
|
|
191
|
-
this._connectedTabInfo = undefined;
|
|
192
|
-
break;
|
|
193
205
|
}
|
|
194
206
|
}
|
|
195
207
|
async _handlePlaywrightMessage(message) {
|
|
@@ -225,7 +237,7 @@ export class CDPRelayServer {
|
|
|
225
237
|
if (sessionId)
|
|
226
238
|
break;
|
|
227
239
|
// Simulate auto-attach behavior with real target info
|
|
228
|
-
const { targetInfo } = await this._extensionConnection.send('attachToTab');
|
|
240
|
+
const { targetInfo } = await this._extensionConnection.send('attachToTab', {});
|
|
229
241
|
this._connectedTabInfo = {
|
|
230
242
|
targetInfo,
|
|
231
243
|
sessionId: `pw-tab-${this._nextSessionId++}`,
|
|
@@ -275,11 +287,11 @@ class ExtensionConnection {
|
|
|
275
287
|
this._ws.on('close', this._onClose.bind(this));
|
|
276
288
|
this._ws.on('error', this._onError.bind(this));
|
|
277
289
|
}
|
|
278
|
-
async send(method, params
|
|
290
|
+
async send(method, params) {
|
|
279
291
|
if (this._ws.readyState !== WebSocket.OPEN)
|
|
280
292
|
throw new Error(`Unexpected WebSocket state: ${this._ws.readyState}`);
|
|
281
293
|
const id = ++this._lastId;
|
|
282
|
-
this._ws.send(JSON.stringify({ id, method, params
|
|
294
|
+
this._ws.send(JSON.stringify({ id, method, params }));
|
|
283
295
|
const error = new Error(`Protocol error: ${method}`);
|
|
284
296
|
return new Promise((resolve, reject) => {
|
|
285
297
|
this._callbacks.set(id, { resolve, reject, error });
|
|
@@ -15,20 +15,20 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import debug from 'debug';
|
|
17
17
|
import * as playwright from 'playwright';
|
|
18
|
-
import { startHttpServer } from '../
|
|
18
|
+
import { startHttpServer } from '../mcp/http.js';
|
|
19
19
|
import { CDPRelayServer } from './cdpRelay.js';
|
|
20
20
|
const debugLogger = debug('pw:mcp:relay');
|
|
21
21
|
export class ExtensionContextFactory {
|
|
22
|
-
name = 'extension';
|
|
23
|
-
description = 'Connect to a browser using the Playwright MCP extension';
|
|
24
22
|
_browserChannel;
|
|
25
23
|
_userDataDir;
|
|
26
|
-
|
|
24
|
+
_executablePath;
|
|
25
|
+
constructor(browserChannel, userDataDir, executablePath) {
|
|
27
26
|
this._browserChannel = browserChannel;
|
|
28
27
|
this._userDataDir = userDataDir;
|
|
28
|
+
this._executablePath = executablePath;
|
|
29
29
|
}
|
|
30
|
-
async createContext(clientInfo, abortSignal) {
|
|
31
|
-
const browser = await this._obtainBrowser(clientInfo, abortSignal);
|
|
30
|
+
async createContext(clientInfo, abortSignal, toolName) {
|
|
31
|
+
const browser = await this._obtainBrowser(clientInfo, abortSignal, toolName);
|
|
32
32
|
return {
|
|
33
33
|
browserContext: browser.contexts()[0],
|
|
34
34
|
close: async () => {
|
|
@@ -37,9 +37,9 @@ export class ExtensionContextFactory {
|
|
|
37
37
|
}
|
|
38
38
|
};
|
|
39
39
|
}
|
|
40
|
-
async _obtainBrowser(clientInfo, abortSignal) {
|
|
40
|
+
async _obtainBrowser(clientInfo, abortSignal, toolName) {
|
|
41
41
|
const relay = await this._startRelay(abortSignal);
|
|
42
|
-
await relay.ensureExtensionConnectionForMCPContext(clientInfo, abortSignal);
|
|
42
|
+
await relay.ensureExtensionConnectionForMCPContext(clientInfo, abortSignal, toolName);
|
|
43
43
|
return await playwright.chromium.connectOverCDP(relay.cdpEndpoint());
|
|
44
44
|
}
|
|
45
45
|
async _startRelay(abortSignal) {
|
|
@@ -48,7 +48,7 @@ export class ExtensionContextFactory {
|
|
|
48
48
|
httpServer.close();
|
|
49
49
|
throw new Error(abortSignal.reason);
|
|
50
50
|
}
|
|
51
|
-
const cdpRelayServer = new CDPRelayServer(httpServer, this._browserChannel, this._userDataDir);
|
|
51
|
+
const cdpRelayServer = new CDPRelayServer(httpServer, this._browserChannel, this._userDataDir, this._executablePath);
|
|
52
52
|
abortSignal.addEventListener('abort', () => cdpRelayServer.stop());
|
|
53
53
|
debugLogger(`CDP relay server started, extension endpoint: ${cdpRelayServer.extensionEndpoint()}.`);
|
|
54
54
|
return cdpRelayServer;
|
|
@@ -0,0 +1,18 @@
|
|
|
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
|
+
// Whenever the commands/events change, the version must be updated. The latest
|
|
17
|
+
// extension version should be compatible with the old MCP clients.
|
|
18
|
+
export const VERSION = 1;
|