@playwright/mcp 0.0.32 → 0.0.33
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 -2
- package/lib/browserContextFactory.js +64 -54
- package/lib/browserServerBackend.js +81 -14
- package/lib/config.js +8 -8
- package/lib/context.js +75 -27
- package/lib/extension/cdpRelay.js +26 -50
- package/lib/extension/extensionContextFactory.js +56 -0
- package/lib/extension/main.js +7 -14
- package/lib/index.js +4 -2
- package/lib/loopTools/context.js +2 -2
- package/lib/mcp/server.js +9 -4
- package/lib/program.js +9 -8
- package/lib/response.js +81 -14
- package/lib/sessionLog.js +85 -34
- package/lib/tab.js +53 -50
- package/lib/tools/common.js +0 -1
- package/lib/tools/files.js +0 -1
- package/lib/tools/keyboard.js +0 -2
- package/lib/tools/navigate.js +0 -3
- package/lib/tools/pdf.js +1 -3
- package/lib/tools/screenshot.js +12 -9
- package/lib/tools/snapshot.js +2 -7
- package/lib/tools/tool.js +5 -4
- package/lib/tools/utils.js +0 -7
- package/lib/utils.js +26 -0
- package/package.json +7 -8
package/README.md
CHANGED
|
@@ -61,7 +61,7 @@ Follow the MCP install [guide](https://modelcontextprotocol.io/quickstart/user),
|
|
|
61
61
|
|
|
62
62
|
#### Click the button to install:
|
|
63
63
|
|
|
64
|
-
[](
|
|
64
|
+
[](cursor://anysphere.cursor-deeplink/mcp/install?name=Playwright&config=eyJjb21tYW5kIjoibnB4IEBwbGF5d3JpZ2h0L21jcEBsYXRlc3QifQ%3D%3D)
|
|
65
65
|
|
|
66
66
|
#### Or install manually:
|
|
67
67
|
|
|
@@ -544,7 +544,7 @@ http.createServer(async (req, res) => {
|
|
|
544
544
|
- Title: Take a screenshot
|
|
545
545
|
- Description: Take a screenshot of the current page. You can't perform actions based on the screenshot, use browser_snapshot for actions.
|
|
546
546
|
- Parameters:
|
|
547
|
-
- `
|
|
547
|
+
- `type` (string, optional): Image format for the screenshot. Default is png.
|
|
548
548
|
- `filename` (string, optional): File name to save the screenshot to. Defaults to `page-{timestamp}.{png|jpeg}` if not specified.
|
|
549
549
|
- `element` (string, optional): Human-readable element description used to obtain permission to screenshot the element. If not provided, the screenshot will be taken of viewport. If element is provided, ref must be provided too.
|
|
550
550
|
- `ref` (string, optional): Exact target element reference from the page snapshot. If not provided, the screenshot will be taken of viewport. If ref is provided, element must be provided too.
|
|
@@ -13,28 +13,34 @@
|
|
|
13
13
|
* See the License for the specific language governing permissions and
|
|
14
14
|
* limitations under the License.
|
|
15
15
|
*/
|
|
16
|
-
import fs from '
|
|
17
|
-
import net from '
|
|
18
|
-
import path from '
|
|
19
|
-
import os from 'node:os';
|
|
16
|
+
import fs from 'fs';
|
|
17
|
+
import net from 'net';
|
|
18
|
+
import path from 'path';
|
|
20
19
|
import * as playwright from 'playwright';
|
|
20
|
+
// @ts-ignore
|
|
21
|
+
import { registryDirectory } from 'playwright-core/lib/server/registry/index';
|
|
21
22
|
import { logUnhandledError, testDebug } from './log.js';
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
if (
|
|
26
|
-
return new
|
|
27
|
-
if (
|
|
28
|
-
return new
|
|
29
|
-
|
|
23
|
+
import { createHash } from './utils.js';
|
|
24
|
+
import { outputFile } from './config.js';
|
|
25
|
+
export function contextFactory(config) {
|
|
26
|
+
if (config.browser.remoteEndpoint)
|
|
27
|
+
return new RemoteContextFactory(config);
|
|
28
|
+
if (config.browser.cdpEndpoint)
|
|
29
|
+
return new CdpContextFactory(config);
|
|
30
|
+
if (config.browser.isolated)
|
|
31
|
+
return new IsolatedContextFactory(config);
|
|
32
|
+
return new PersistentContextFactory(config);
|
|
30
33
|
}
|
|
31
34
|
class BaseContextFactory {
|
|
32
|
-
browserConfig;
|
|
33
|
-
_browserPromise;
|
|
34
35
|
name;
|
|
35
|
-
|
|
36
|
+
description;
|
|
37
|
+
config;
|
|
38
|
+
_browserPromise;
|
|
39
|
+
_tracesDir;
|
|
40
|
+
constructor(name, description, config) {
|
|
36
41
|
this.name = name;
|
|
37
|
-
this.
|
|
42
|
+
this.description = description;
|
|
43
|
+
this.config = config;
|
|
38
44
|
}
|
|
39
45
|
async _obtainBrowser() {
|
|
40
46
|
if (this._browserPromise)
|
|
@@ -53,7 +59,9 @@ class BaseContextFactory {
|
|
|
53
59
|
async _doObtainBrowser() {
|
|
54
60
|
throw new Error('Not implemented');
|
|
55
61
|
}
|
|
56
|
-
async createContext() {
|
|
62
|
+
async createContext(clientInfo) {
|
|
63
|
+
if (this.config.saveTrace)
|
|
64
|
+
this._tracesDir = await outputFile(this.config, clientInfo.rootPath, `traces-${Date.now()}`);
|
|
57
65
|
testDebug(`create browser context (${this.name})`);
|
|
58
66
|
const browser = await this._obtainBrowser();
|
|
59
67
|
const browserContext = await this._doCreateContext(browser);
|
|
@@ -74,14 +82,15 @@ class BaseContextFactory {
|
|
|
74
82
|
}
|
|
75
83
|
}
|
|
76
84
|
class IsolatedContextFactory extends BaseContextFactory {
|
|
77
|
-
constructor(
|
|
78
|
-
super('isolated',
|
|
85
|
+
constructor(config) {
|
|
86
|
+
super('isolated', 'Create a new isolated browser context', config);
|
|
79
87
|
}
|
|
80
88
|
async _doObtainBrowser() {
|
|
81
|
-
await injectCdpPort(this.
|
|
82
|
-
const browserType = playwright[this.
|
|
89
|
+
await injectCdpPort(this.config.browser);
|
|
90
|
+
const browserType = playwright[this.config.browser.browserName];
|
|
83
91
|
return browserType.launch({
|
|
84
|
-
|
|
92
|
+
tracesDir: this._tracesDir,
|
|
93
|
+
...this.config.browser.launchOptions,
|
|
85
94
|
handleSIGINT: false,
|
|
86
95
|
handleSIGTERM: false,
|
|
87
96
|
}).catch(error => {
|
|
@@ -91,53 +100,59 @@ class IsolatedContextFactory extends BaseContextFactory {
|
|
|
91
100
|
});
|
|
92
101
|
}
|
|
93
102
|
async _doCreateContext(browser) {
|
|
94
|
-
return browser.newContext(this.
|
|
103
|
+
return browser.newContext(this.config.browser.contextOptions);
|
|
95
104
|
}
|
|
96
105
|
}
|
|
97
106
|
class CdpContextFactory extends BaseContextFactory {
|
|
98
|
-
constructor(
|
|
99
|
-
super('cdp',
|
|
107
|
+
constructor(config) {
|
|
108
|
+
super('cdp', 'Connect to a browser over CDP', config);
|
|
100
109
|
}
|
|
101
110
|
async _doObtainBrowser() {
|
|
102
|
-
return playwright.chromium.connectOverCDP(this.
|
|
111
|
+
return playwright.chromium.connectOverCDP(this.config.browser.cdpEndpoint);
|
|
103
112
|
}
|
|
104
113
|
async _doCreateContext(browser) {
|
|
105
|
-
return this.
|
|
114
|
+
return this.config.browser.isolated ? await browser.newContext() : browser.contexts()[0];
|
|
106
115
|
}
|
|
107
116
|
}
|
|
108
117
|
class RemoteContextFactory extends BaseContextFactory {
|
|
109
|
-
constructor(
|
|
110
|
-
super('remote',
|
|
118
|
+
constructor(config) {
|
|
119
|
+
super('remote', 'Connect to a browser using a remote endpoint', config);
|
|
111
120
|
}
|
|
112
121
|
async _doObtainBrowser() {
|
|
113
|
-
const url = new URL(this.
|
|
114
|
-
url.searchParams.set('browser', this.
|
|
115
|
-
if (this.
|
|
116
|
-
url.searchParams.set('launch-options', JSON.stringify(this.
|
|
117
|
-
return playwright[this.
|
|
122
|
+
const url = new URL(this.config.browser.remoteEndpoint);
|
|
123
|
+
url.searchParams.set('browser', this.config.browser.browserName);
|
|
124
|
+
if (this.config.browser.launchOptions)
|
|
125
|
+
url.searchParams.set('launch-options', JSON.stringify(this.config.browser.launchOptions));
|
|
126
|
+
return playwright[this.config.browser.browserName].connect(String(url));
|
|
118
127
|
}
|
|
119
128
|
async _doCreateContext(browser) {
|
|
120
129
|
return browser.newContext();
|
|
121
130
|
}
|
|
122
131
|
}
|
|
123
132
|
class PersistentContextFactory {
|
|
124
|
-
|
|
133
|
+
config;
|
|
134
|
+
name = 'persistent';
|
|
135
|
+
description = 'Create a new persistent browser context';
|
|
125
136
|
_userDataDirs = new Set();
|
|
126
|
-
constructor(
|
|
127
|
-
this.
|
|
137
|
+
constructor(config) {
|
|
138
|
+
this.config = config;
|
|
128
139
|
}
|
|
129
|
-
async createContext() {
|
|
130
|
-
await injectCdpPort(this.
|
|
140
|
+
async createContext(clientInfo) {
|
|
141
|
+
await injectCdpPort(this.config.browser);
|
|
131
142
|
testDebug('create browser context (persistent)');
|
|
132
|
-
const userDataDir = this.
|
|
143
|
+
const userDataDir = this.config.browser.userDataDir ?? await this._createUserDataDir(clientInfo.rootPath);
|
|
144
|
+
let tracesDir;
|
|
145
|
+
if (this.config.saveTrace)
|
|
146
|
+
tracesDir = await outputFile(this.config, clientInfo.rootPath, `traces-${Date.now()}`);
|
|
133
147
|
this._userDataDirs.add(userDataDir);
|
|
134
148
|
testDebug('lock user data dir', userDataDir);
|
|
135
|
-
const browserType = playwright[this.
|
|
149
|
+
const browserType = playwright[this.config.browser.browserName];
|
|
136
150
|
for (let i = 0; i < 5; i++) {
|
|
137
151
|
try {
|
|
138
152
|
const browserContext = await browserType.launchPersistentContext(userDataDir, {
|
|
139
|
-
|
|
140
|
-
...this.
|
|
153
|
+
tracesDir,
|
|
154
|
+
...this.config.browser.launchOptions,
|
|
155
|
+
...this.config.browser.contextOptions,
|
|
141
156
|
handleSIGINT: false,
|
|
142
157
|
handleSIGTERM: false,
|
|
143
158
|
});
|
|
@@ -164,17 +179,12 @@ class PersistentContextFactory {
|
|
|
164
179
|
this._userDataDirs.delete(userDataDir);
|
|
165
180
|
testDebug('close browser context complete (persistent)');
|
|
166
181
|
}
|
|
167
|
-
async _createUserDataDir() {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
else if (process.platform === 'win32')
|
|
174
|
-
cacheDirectory = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
|
|
175
|
-
else
|
|
176
|
-
throw new Error('Unsupported platform: ' + process.platform);
|
|
177
|
-
const result = path.join(cacheDirectory, 'ms-playwright', `mcp-${this.browserConfig.launchOptions?.channel ?? this.browserConfig?.browserName}-profile`);
|
|
182
|
+
async _createUserDataDir(rootPath) {
|
|
183
|
+
const dir = process.env.PWMCP_PROFILES_DIR_FOR_TEST ?? registryDirectory;
|
|
184
|
+
const browserToken = this.config.browser.launchOptions?.channel ?? this.config.browser?.browserName;
|
|
185
|
+
// Hesitant putting hundreds of files into the user's workspace, so using it for hashing instead.
|
|
186
|
+
const rootPathToken = rootPath ? `-${createHash(rootPath)}` : '';
|
|
187
|
+
const result = path.join(dir, `mcp-${browserToken}${rootPathToken}`);
|
|
178
188
|
await fs.promises.mkdir(result, { recursive: true });
|
|
179
189
|
return result;
|
|
180
190
|
}
|
|
@@ -13,42 +13,109 @@
|
|
|
13
13
|
* See the License for the specific language governing permissions and
|
|
14
14
|
* limitations under the License.
|
|
15
15
|
*/
|
|
16
|
+
import { fileURLToPath } from 'url';
|
|
17
|
+
import { z } from 'zod';
|
|
16
18
|
import { Context } from './context.js';
|
|
17
19
|
import { logUnhandledError } from './log.js';
|
|
18
20
|
import { Response } from './response.js';
|
|
19
21
|
import { SessionLog } from './sessionLog.js';
|
|
20
22
|
import { filteredTools } from './tools.js';
|
|
21
23
|
import { packageJSON } from './package.js';
|
|
24
|
+
import { defineTool } from './tools/tool.js';
|
|
22
25
|
export class BrowserServerBackend {
|
|
23
26
|
name = 'Playwright';
|
|
24
27
|
version = packageJSON.version;
|
|
25
|
-
onclose;
|
|
26
28
|
_tools;
|
|
27
29
|
_context;
|
|
28
30
|
_sessionLog;
|
|
29
|
-
|
|
31
|
+
_config;
|
|
32
|
+
_browserContextFactory;
|
|
33
|
+
constructor(config, factories) {
|
|
34
|
+
this._config = config;
|
|
35
|
+
this._browserContextFactory = factories[0];
|
|
30
36
|
this._tools = filteredTools(config);
|
|
31
|
-
|
|
37
|
+
if (factories.length > 1)
|
|
38
|
+
this._tools.push(this._defineContextSwitchTool(factories));
|
|
32
39
|
}
|
|
33
|
-
async initialize() {
|
|
34
|
-
|
|
40
|
+
async initialize(server) {
|
|
41
|
+
const capabilities = server.getClientCapabilities();
|
|
42
|
+
let rootPath;
|
|
43
|
+
if (capabilities.roots && (server.getClientVersion()?.name === 'Visual Studio Code' ||
|
|
44
|
+
server.getClientVersion()?.name === 'Visual Studio Code - Insiders')) {
|
|
45
|
+
const { roots } = await server.listRoots();
|
|
46
|
+
const firstRootUri = roots[0]?.uri;
|
|
47
|
+
const url = firstRootUri ? new URL(firstRootUri) : undefined;
|
|
48
|
+
rootPath = url ? fileURLToPath(url) : undefined;
|
|
49
|
+
}
|
|
50
|
+
this._sessionLog = this._config.saveSession ? await SessionLog.create(this._config, rootPath) : undefined;
|
|
51
|
+
this._context = new Context({
|
|
52
|
+
tools: this._tools,
|
|
53
|
+
config: this._config,
|
|
54
|
+
browserContextFactory: this._browserContextFactory,
|
|
55
|
+
sessionLog: this._sessionLog,
|
|
56
|
+
clientInfo: { ...server.getClientVersion(), rootPath },
|
|
57
|
+
});
|
|
35
58
|
}
|
|
36
59
|
tools() {
|
|
37
60
|
return this._tools.map(tool => tool.schema);
|
|
38
61
|
}
|
|
39
62
|
async callTool(schema, parsedArguments) {
|
|
40
|
-
const
|
|
63
|
+
const context = this._context;
|
|
64
|
+
const response = new Response(context, schema.name, parsedArguments);
|
|
41
65
|
const tool = this._tools.find(tool => tool.schema.name === schema.name);
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
await
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
66
|
+
context.setRunningTool(true);
|
|
67
|
+
try {
|
|
68
|
+
await tool.handle(context, parsedArguments, response);
|
|
69
|
+
await response.finish();
|
|
70
|
+
this._sessionLog?.logResponse(response);
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
response.addError(String(error));
|
|
74
|
+
}
|
|
75
|
+
finally {
|
|
76
|
+
context.setRunningTool(false);
|
|
77
|
+
}
|
|
78
|
+
return response.serialize();
|
|
49
79
|
}
|
|
50
80
|
serverClosed() {
|
|
51
|
-
this.onclose?.();
|
|
52
81
|
void this._context.dispose().catch(logUnhandledError);
|
|
53
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;
|
|
120
|
+
}
|
|
54
121
|
}
|
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 './
|
|
20
|
+
import { sanitizeForFilePath } from './utils.js';
|
|
21
21
|
const defaultConfig = {
|
|
22
22
|
browser: {
|
|
23
23
|
browserName: 'chromium',
|
|
@@ -35,7 +35,7 @@ const defaultConfig = {
|
|
|
35
35
|
blockedOrigins: undefined,
|
|
36
36
|
},
|
|
37
37
|
server: {},
|
|
38
|
-
|
|
38
|
+
saveTrace: false,
|
|
39
39
|
};
|
|
40
40
|
export async function resolveConfig(config) {
|
|
41
41
|
return mergeConfig(defaultConfig, config);
|
|
@@ -48,9 +48,6 @@ export async function resolveCLIConfig(cliOptions) {
|
|
|
48
48
|
result = mergeConfig(result, configInFile);
|
|
49
49
|
result = mergeConfig(result, envOverrides);
|
|
50
50
|
result = mergeConfig(result, cliOverrides);
|
|
51
|
-
// Derive artifact output directory from config.outputDir
|
|
52
|
-
if (result.saveTrace)
|
|
53
|
-
result.browser.launchOptions.tracesDir = path.join(result.outputDir, 'traces');
|
|
54
51
|
return result;
|
|
55
52
|
}
|
|
56
53
|
export function configFromCLIOptions(cliOptions) {
|
|
@@ -179,10 +176,13 @@ async function loadConfig(configFile) {
|
|
|
179
176
|
throw new Error(`Failed to load config file: ${configFile}, ${error}`);
|
|
180
177
|
}
|
|
181
178
|
}
|
|
182
|
-
export async function outputFile(config, name) {
|
|
183
|
-
|
|
179
|
+
export async function outputFile(config, rootPath, name) {
|
|
180
|
+
const outputDir = config.outputDir
|
|
181
|
+
?? (rootPath ? path.join(rootPath, '.playwright-mcp') : undefined)
|
|
182
|
+
?? path.join(os.tmpdir(), 'playwright-mcp-output', sanitizeForFilePath(new Date().toISOString()));
|
|
183
|
+
await fs.promises.mkdir(outputDir, { recursive: true });
|
|
184
184
|
const fileName = sanitizeForFilePath(name);
|
|
185
|
-
return path.join(
|
|
185
|
+
return path.join(outputDir, fileName);
|
|
186
186
|
}
|
|
187
187
|
function pickDefined(obj) {
|
|
188
188
|
return Object.fromEntries(Object.entries(obj ?? {}).filter(([_, v]) => v !== undefined));
|
package/lib/context.js
CHANGED
|
@@ -16,21 +16,29 @@
|
|
|
16
16
|
import debug from 'debug';
|
|
17
17
|
import { logUnhandledError } from './log.js';
|
|
18
18
|
import { Tab } from './tab.js';
|
|
19
|
+
import { outputFile } from './config.js';
|
|
19
20
|
const testDebug = debug('pw:mcp:test');
|
|
20
21
|
export class Context {
|
|
21
22
|
tools;
|
|
22
23
|
config;
|
|
24
|
+
sessionLog;
|
|
25
|
+
options;
|
|
23
26
|
_browserContextPromise;
|
|
24
27
|
_browserContextFactory;
|
|
25
28
|
_tabs = [];
|
|
26
29
|
_currentTab;
|
|
27
|
-
|
|
30
|
+
_clientInfo;
|
|
28
31
|
static _allContexts = new Set();
|
|
29
32
|
_closeBrowserContextPromise;
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
this.
|
|
33
|
+
_isRunningTool = false;
|
|
34
|
+
_abortController = new AbortController();
|
|
35
|
+
constructor(options) {
|
|
36
|
+
this.tools = options.tools;
|
|
37
|
+
this.config = options.config;
|
|
38
|
+
this.sessionLog = options.sessionLog;
|
|
39
|
+
this.options = options;
|
|
40
|
+
this._browserContextFactory = options.browserContextFactory;
|
|
41
|
+
this._clientInfo = options.clientInfo;
|
|
34
42
|
testDebug('create context');
|
|
35
43
|
Context._allContexts.add(this);
|
|
36
44
|
}
|
|
@@ -68,27 +76,6 @@ export class Context {
|
|
|
68
76
|
await browserContext.newPage();
|
|
69
77
|
return this._currentTab;
|
|
70
78
|
}
|
|
71
|
-
async listTabsMarkdown(force = false) {
|
|
72
|
-
if (this._tabs.length === 1 && !force)
|
|
73
|
-
return [];
|
|
74
|
-
if (!this._tabs.length) {
|
|
75
|
-
return [
|
|
76
|
-
'### No open tabs',
|
|
77
|
-
'Use the "browser_navigate" tool to navigate to a page first.',
|
|
78
|
-
'',
|
|
79
|
-
];
|
|
80
|
-
}
|
|
81
|
-
const lines = ['### Open tabs'];
|
|
82
|
-
for (let i = 0; i < this._tabs.length; i++) {
|
|
83
|
-
const tab = this._tabs[i];
|
|
84
|
-
const title = await tab.title();
|
|
85
|
-
const url = tab.page.url();
|
|
86
|
-
const current = tab === this._currentTab ? ' (current)' : '';
|
|
87
|
-
lines.push(`- ${i}:${current} [${title}] (${url})`);
|
|
88
|
-
}
|
|
89
|
-
lines.push('');
|
|
90
|
-
return lines;
|
|
91
|
-
}
|
|
92
79
|
async closeTab(index) {
|
|
93
80
|
const tab = index === undefined ? this._currentTab : this._tabs[index];
|
|
94
81
|
if (!tab)
|
|
@@ -97,6 +84,9 @@ export class Context {
|
|
|
97
84
|
await tab.page.close();
|
|
98
85
|
return url;
|
|
99
86
|
}
|
|
87
|
+
async outputFile(name) {
|
|
88
|
+
return outputFile(this.config, this._clientInfo.rootPath, name);
|
|
89
|
+
}
|
|
100
90
|
_onPageCreated(page) {
|
|
101
91
|
const tab = new Tab(this, page, tab => this._onPageClosed(tab));
|
|
102
92
|
this._tabs.push(tab);
|
|
@@ -119,6 +109,12 @@ export class Context {
|
|
|
119
109
|
await this._closeBrowserContextPromise;
|
|
120
110
|
this._closeBrowserContextPromise = undefined;
|
|
121
111
|
}
|
|
112
|
+
isRunningTool() {
|
|
113
|
+
return this._isRunningTool;
|
|
114
|
+
}
|
|
115
|
+
setRunningTool(isRunningTool) {
|
|
116
|
+
this._isRunningTool = isRunningTool;
|
|
117
|
+
}
|
|
122
118
|
async _closeBrowserContextImpl() {
|
|
123
119
|
if (!this._browserContextPromise)
|
|
124
120
|
return;
|
|
@@ -132,6 +128,7 @@ export class Context {
|
|
|
132
128
|
});
|
|
133
129
|
}
|
|
134
130
|
async dispose() {
|
|
131
|
+
this._abortController.abort('MCP context disposed');
|
|
135
132
|
await this.closeBrowserContext();
|
|
136
133
|
Context._allContexts.delete(this);
|
|
137
134
|
}
|
|
@@ -159,9 +156,11 @@ export class Context {
|
|
|
159
156
|
if (this._closeBrowserContextPromise)
|
|
160
157
|
throw new Error('Another browser context is being closed.');
|
|
161
158
|
// TODO: move to the browser context factory to make it based on isolation mode.
|
|
162
|
-
const result = await this._browserContextFactory.createContext(this.
|
|
159
|
+
const result = await this._browserContextFactory.createContext(this._clientInfo, this._abortController.signal);
|
|
163
160
|
const { browserContext } = result;
|
|
164
161
|
await this._setupRequestInterception(browserContext);
|
|
162
|
+
if (this.sessionLog)
|
|
163
|
+
await InputRecorder.create(this, browserContext);
|
|
165
164
|
for (const page of browserContext.pages())
|
|
166
165
|
this._onPageCreated(page);
|
|
167
166
|
browserContext.on('page', page => this._onPageCreated(page));
|
|
@@ -176,3 +175,52 @@ export class Context {
|
|
|
176
175
|
return result;
|
|
177
176
|
}
|
|
178
177
|
}
|
|
178
|
+
export class InputRecorder {
|
|
179
|
+
_context;
|
|
180
|
+
_browserContext;
|
|
181
|
+
constructor(context, browserContext) {
|
|
182
|
+
this._context = context;
|
|
183
|
+
this._browserContext = browserContext;
|
|
184
|
+
}
|
|
185
|
+
static async create(context, browserContext) {
|
|
186
|
+
const recorder = new InputRecorder(context, browserContext);
|
|
187
|
+
await recorder._initialize();
|
|
188
|
+
return recorder;
|
|
189
|
+
}
|
|
190
|
+
async _initialize() {
|
|
191
|
+
const sessionLog = this._context.sessionLog;
|
|
192
|
+
await this._browserContext._enableRecorder({
|
|
193
|
+
mode: 'recording',
|
|
194
|
+
recorderMode: 'api',
|
|
195
|
+
}, {
|
|
196
|
+
actionAdded: (page, data, code) => {
|
|
197
|
+
if (this._context.isRunningTool())
|
|
198
|
+
return;
|
|
199
|
+
const tab = Tab.forPage(page);
|
|
200
|
+
if (tab)
|
|
201
|
+
sessionLog.logUserAction(data.action, tab, code, false);
|
|
202
|
+
},
|
|
203
|
+
actionUpdated: (page, data, code) => {
|
|
204
|
+
if (this._context.isRunningTool())
|
|
205
|
+
return;
|
|
206
|
+
const tab = Tab.forPage(page);
|
|
207
|
+
if (tab)
|
|
208
|
+
sessionLog.logUserAction(data.action, tab, code, true);
|
|
209
|
+
},
|
|
210
|
+
signalAdded: (page, data) => {
|
|
211
|
+
if (this._context.isRunningTool())
|
|
212
|
+
return;
|
|
213
|
+
if (data.signal.name !== 'navigation')
|
|
214
|
+
return;
|
|
215
|
+
const tab = Tab.forPage(page);
|
|
216
|
+
const navigateAction = {
|
|
217
|
+
name: 'navigate',
|
|
218
|
+
url: data.signal.url,
|
|
219
|
+
signals: [],
|
|
220
|
+
};
|
|
221
|
+
if (tab)
|
|
222
|
+
sessionLog.logUserAction(navigateAction, tab, `await page.goto('${data.signal.url}');`, false);
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
}
|
|
@@ -13,19 +13,26 @@
|
|
|
13
13
|
* See the License for the specific language governing permissions and
|
|
14
14
|
* limitations under the License.
|
|
15
15
|
*/
|
|
16
|
+
/**
|
|
17
|
+
* WebSocket server that bridges Playwright MCP and Chrome Extension
|
|
18
|
+
*
|
|
19
|
+
* Endpoints:
|
|
20
|
+
* - /cdp/guid - Full CDP interface for Playwright MCP
|
|
21
|
+
* - /extension/guid - Extension connection for chrome.debugger forwarding
|
|
22
|
+
*/
|
|
16
23
|
import { spawn } from 'child_process';
|
|
17
|
-
import { WebSocket, WebSocketServer } from 'ws';
|
|
18
24
|
import debug from 'debug';
|
|
19
|
-
import
|
|
20
|
-
|
|
21
|
-
const { registry } = await import('playwright-core/lib/server/registry/index');
|
|
22
|
-
import { httpAddressToString, startHttpServer } from '../httpServer.js';
|
|
25
|
+
import { WebSocket, WebSocketServer } from 'ws';
|
|
26
|
+
import { httpAddressToString } from '../httpServer.js';
|
|
23
27
|
import { logUnhandledError } from '../log.js';
|
|
24
28
|
import { ManualPromise } from '../manualPromise.js';
|
|
29
|
+
// @ts-ignore
|
|
30
|
+
const { registry } = await import('playwright-core/lib/server/registry/index');
|
|
25
31
|
const debugLogger = debug('pw:mcp:relay');
|
|
26
32
|
export class CDPRelayServer {
|
|
27
33
|
_wsHost;
|
|
28
34
|
_browserChannel;
|
|
35
|
+
_userDataDir;
|
|
29
36
|
_cdpPath;
|
|
30
37
|
_extensionPath;
|
|
31
38
|
_wss;
|
|
@@ -34,9 +41,10 @@ export class CDPRelayServer {
|
|
|
34
41
|
_connectedTabInfo;
|
|
35
42
|
_nextSessionId = 1;
|
|
36
43
|
_extensionConnectionPromise;
|
|
37
|
-
constructor(server, browserChannel) {
|
|
44
|
+
constructor(server, browserChannel, userDataDir) {
|
|
38
45
|
this._wsHost = httpAddressToString(server.address()).replace(/^http/, 'ws');
|
|
39
46
|
this._browserChannel = browserChannel;
|
|
47
|
+
this._userDataDir = userDataDir;
|
|
40
48
|
const uuid = crypto.randomUUID();
|
|
41
49
|
this._cdpPath = `/cdp/${uuid}`;
|
|
42
50
|
this._extensionPath = `/extension/${uuid}`;
|
|
@@ -50,16 +58,19 @@ export class CDPRelayServer {
|
|
|
50
58
|
extensionEndpoint() {
|
|
51
59
|
return `${this._wsHost}${this._extensionPath}`;
|
|
52
60
|
}
|
|
53
|
-
async ensureExtensionConnectionForMCPContext(clientInfo) {
|
|
61
|
+
async ensureExtensionConnectionForMCPContext(clientInfo, abortSignal) {
|
|
54
62
|
debugLogger('Ensuring extension connection for MCP context');
|
|
55
63
|
if (this._extensionConnection)
|
|
56
64
|
return;
|
|
57
|
-
|
|
65
|
+
this._connectBrowser(clientInfo);
|
|
58
66
|
debugLogger('Waiting for incoming extension connection');
|
|
59
|
-
await
|
|
67
|
+
await Promise.race([
|
|
68
|
+
this._extensionConnectionPromise,
|
|
69
|
+
new Promise((_, reject) => abortSignal.addEventListener('abort', reject))
|
|
70
|
+
]);
|
|
60
71
|
debugLogger('Extension connection established');
|
|
61
72
|
}
|
|
62
|
-
|
|
73
|
+
_connectBrowser(clientInfo) {
|
|
63
74
|
const mcpRelayEndpoint = `${this._wsHost}${this._extensionPath}`;
|
|
64
75
|
// Need to specify "key" in the manifest.json to make the id stable when loading from file.
|
|
65
76
|
const url = new URL('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html');
|
|
@@ -72,7 +83,11 @@ export class CDPRelayServer {
|
|
|
72
83
|
const executablePath = executableInfo.executablePath();
|
|
73
84
|
if (!executablePath)
|
|
74
85
|
throw new Error(`"${this._browserChannel}" executable not found. Make sure it is installed at a standard location.`);
|
|
75
|
-
|
|
86
|
+
const args = [];
|
|
87
|
+
if (this._userDataDir)
|
|
88
|
+
args.push(`--user-data-dir=${this._userDataDir}`);
|
|
89
|
+
args.push(href);
|
|
90
|
+
spawn(executablePath, args, {
|
|
76
91
|
windowsHide: true,
|
|
77
92
|
detached: true,
|
|
78
93
|
shell: false,
|
|
@@ -248,45 +263,6 @@ export class CDPRelayServer {
|
|
|
248
263
|
this._playwrightConnection?.send(JSON.stringify(message));
|
|
249
264
|
}
|
|
250
265
|
}
|
|
251
|
-
class ExtensionContextFactory {
|
|
252
|
-
_relay;
|
|
253
|
-
_browserPromise;
|
|
254
|
-
constructor(relay) {
|
|
255
|
-
this._relay = relay;
|
|
256
|
-
}
|
|
257
|
-
async createContext(clientInfo) {
|
|
258
|
-
// First call will establish the connection to the extension.
|
|
259
|
-
if (!this._browserPromise)
|
|
260
|
-
this._browserPromise = this._obtainBrowser(clientInfo);
|
|
261
|
-
const browser = await this._browserPromise;
|
|
262
|
-
return {
|
|
263
|
-
browserContext: browser.contexts()[0],
|
|
264
|
-
close: async () => {
|
|
265
|
-
debugLogger('close() called for browser context, ignoring');
|
|
266
|
-
}
|
|
267
|
-
};
|
|
268
|
-
}
|
|
269
|
-
clientDisconnected() {
|
|
270
|
-
this._relay.closeConnections('MCP client disconnected');
|
|
271
|
-
this._browserPromise = undefined;
|
|
272
|
-
}
|
|
273
|
-
async _obtainBrowser(clientInfo) {
|
|
274
|
-
await this._relay.ensureExtensionConnectionForMCPContext(clientInfo);
|
|
275
|
-
const browser = await playwright.chromium.connectOverCDP(this._relay.cdpEndpoint());
|
|
276
|
-
browser.on('disconnected', () => {
|
|
277
|
-
this._browserPromise = undefined;
|
|
278
|
-
debugLogger('Browser disconnected');
|
|
279
|
-
});
|
|
280
|
-
return browser;
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
export async function startCDPRelayServer(browserChannel, abortController) {
|
|
284
|
-
const httpServer = await startHttpServer({});
|
|
285
|
-
const cdpRelayServer = new CDPRelayServer(httpServer, browserChannel);
|
|
286
|
-
abortController.signal.addEventListener('abort', () => cdpRelayServer.stop());
|
|
287
|
-
debugLogger(`CDP relay server started, extension endpoint: ${cdpRelayServer.extensionEndpoint()}.`);
|
|
288
|
-
return new ExtensionContextFactory(cdpRelayServer);
|
|
289
|
-
}
|
|
290
266
|
class ExtensionConnection {
|
|
291
267
|
_ws;
|
|
292
268
|
_callbacks = new Map();
|