@wordbricks/playwright-mcp 0.1.20 → 0.1.23
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/cli-wrapper.js +15 -14
- package/cli.js +1 -1
- package/config.d.ts +11 -6
- package/index.d.ts +7 -5
- package/index.js +1 -1
- package/package.json +34 -57
- package/LICENSE +0 -202
- package/lib/browserContextFactory.js +0 -326
- package/lib/browserServerBackend.js +0 -84
- package/lib/config.js +0 -286
- package/lib/context.js +0 -309
- package/lib/extension/cdpRelay.js +0 -346
- package/lib/extension/extensionContextFactory.js +0 -56
- package/lib/frameworkPatterns.js +0 -35
- package/lib/hooks/antiBotDetectionHook.js +0 -171
- package/lib/hooks/core.js +0 -144
- package/lib/hooks/eventConsumer.js +0 -52
- package/lib/hooks/events.js +0 -42
- package/lib/hooks/formatToolCallEvent.js +0 -16
- package/lib/hooks/frameworkStateHook.js +0 -182
- package/lib/hooks/grouping.js +0 -72
- package/lib/hooks/jsonLdDetectionHook.js +0 -175
- package/lib/hooks/networkFilters.js +0 -82
- package/lib/hooks/networkSetup.js +0 -59
- package/lib/hooks/networkTrackingHook.js +0 -67
- package/lib/hooks/pageHeightHook.js +0 -75
- package/lib/hooks/registry.js +0 -42
- package/lib/hooks/requireTabHook.js +0 -26
- package/lib/hooks/schema.js +0 -89
- package/lib/hooks/waitHook.js +0 -33
- package/lib/index.js +0 -39
- package/lib/mcp/inProcessTransport.js +0 -72
- package/lib/mcp/proxyBackend.js +0 -115
- package/lib/mcp/server.js +0 -86
- package/lib/mcp/tool.js +0 -38
- package/lib/mcp/transport.js +0 -181
- package/lib/playwrightTransformer.js +0 -497
- package/lib/program.js +0 -110
- package/lib/response.js +0 -186
- package/lib/sessionLog.js +0 -121
- package/lib/tab.js +0 -249
- package/lib/tools/common.js +0 -55
- package/lib/tools/console.js +0 -33
- package/lib/tools/dialogs.js +0 -47
- package/lib/tools/evaluate.js +0 -53
- package/lib/tools/extractFrameworkState.js +0 -214
- package/lib/tools/files.js +0 -45
- package/lib/tools/form.js +0 -57
- package/lib/tools/getSnapshot.js +0 -37
- package/lib/tools/getVisibleHtml.js +0 -52
- package/lib/tools/install.js +0 -51
- package/lib/tools/keyboard.js +0 -78
- package/lib/tools/mouse.js +0 -99
- package/lib/tools/navigate.js +0 -70
- package/lib/tools/network.js +0 -123
- package/lib/tools/networkDetail.js +0 -229
- package/lib/tools/networkSearch/bodySearch.js +0 -147
- package/lib/tools/networkSearch/grouping.js +0 -28
- package/lib/tools/networkSearch/helpers.js +0 -32
- package/lib/tools/networkSearch/searchHtml.js +0 -67
- package/lib/tools/networkSearch/types.js +0 -1
- package/lib/tools/networkSearch/urlSearch.js +0 -82
- package/lib/tools/networkSearch.js +0 -268
- package/lib/tools/pdf.js +0 -40
- package/lib/tools/repl.js +0 -402
- package/lib/tools/screenshot.js +0 -79
- package/lib/tools/scroll.js +0 -126
- package/lib/tools/snapshot.js +0 -144
- package/lib/tools/tabs.js +0 -59
- package/lib/tools/tool.js +0 -33
- package/lib/tools/utils.js +0 -74
- package/lib/tools/wait.js +0 -55
- package/lib/tools.js +0 -67
- package/lib/utils/adBlockFilter.js +0 -87
- package/lib/utils/codegen.js +0 -51
- package/lib/utils/extensionPath.js +0 -10
- package/lib/utils/fileUtils.js +0 -36
- package/lib/utils/graphql.js +0 -258
- package/lib/utils/guid.js +0 -22
- package/lib/utils/httpServer.js +0 -39
- package/lib/utils/log.js +0 -21
- package/lib/utils/manualPromise.js +0 -111
- package/lib/utils/networkFormat.js +0 -12
- package/lib/utils/package.js +0 -20
- package/lib/utils/result.js +0 -2
- package/lib/utils/sanitizeHtml.js +0 -98
- package/lib/utils/truncate.js +0 -103
- package/lib/utils/withTimeout.js +0 -7
- package/src/index.ts +0 -50
package/lib/context.js
DELETED
|
@@ -1,309 +0,0 @@
|
|
|
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 debug from 'debug';
|
|
17
|
-
import { logUnhandledError } from './utils/log.js';
|
|
18
|
-
import { Tab } from './tab.js';
|
|
19
|
-
import { applyHooksToTools, hookRegistryMap } from './hooks/core.js';
|
|
20
|
-
import { buildHookRegistry } from './hooks/registry.js';
|
|
21
|
-
import { setEventStore, createEventStore } from './hooks/events.js';
|
|
22
|
-
import { setupNetworkTracking } from './hooks/networkSetup.js';
|
|
23
|
-
import { outputFile } from './config.js';
|
|
24
|
-
import * as codegen from './utils/codegen.js';
|
|
25
|
-
import { shouldBlockRequest } from './utils/adBlockFilter.js';
|
|
26
|
-
const testDebug = debug('pw:mcp:test');
|
|
27
|
-
const protocolPattern = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//;
|
|
28
|
-
const defaultPortForProtocol = (protocol) => {
|
|
29
|
-
if (protocol === 'http:')
|
|
30
|
-
return '80';
|
|
31
|
-
if (protocol === 'https:')
|
|
32
|
-
return '443';
|
|
33
|
-
return '';
|
|
34
|
-
};
|
|
35
|
-
const matchesOriginHost = (requestUrl, candidate) => {
|
|
36
|
-
const normalized = candidate.trim();
|
|
37
|
-
if (!normalized)
|
|
38
|
-
return false;
|
|
39
|
-
const hasProtocol = protocolPattern.test(normalized);
|
|
40
|
-
const parsed = (() => {
|
|
41
|
-
try {
|
|
42
|
-
return new URL(hasProtocol ? normalized : `http://${normalized}`);
|
|
43
|
-
}
|
|
44
|
-
catch {
|
|
45
|
-
return undefined;
|
|
46
|
-
}
|
|
47
|
-
})();
|
|
48
|
-
if (!parsed)
|
|
49
|
-
return false;
|
|
50
|
-
const candidateHost = parsed.hostname.toLowerCase().replace(/\.+$/, '');
|
|
51
|
-
if (!candidateHost)
|
|
52
|
-
return false;
|
|
53
|
-
const requestHost = requestUrl.hostname.toLowerCase();
|
|
54
|
-
if (requestHost !== candidateHost && !requestHost.endsWith(`.${candidateHost}`))
|
|
55
|
-
return false;
|
|
56
|
-
if (hasProtocol && parsed.protocol !== requestUrl.protocol)
|
|
57
|
-
return false;
|
|
58
|
-
if (parsed.port === '')
|
|
59
|
-
return true;
|
|
60
|
-
const candidatePort = parsed.port || defaultPortForProtocol(parsed.protocol);
|
|
61
|
-
if (!candidatePort)
|
|
62
|
-
return false;
|
|
63
|
-
const requestPort = requestUrl.port || defaultPortForProtocol(requestUrl.protocol);
|
|
64
|
-
return requestPort === candidatePort;
|
|
65
|
-
};
|
|
66
|
-
export class Context {
|
|
67
|
-
tools;
|
|
68
|
-
config;
|
|
69
|
-
sessionLog;
|
|
70
|
-
options;
|
|
71
|
-
_browserContextPromise;
|
|
72
|
-
_browserContextFactory;
|
|
73
|
-
_tabs = [];
|
|
74
|
-
_currentTab;
|
|
75
|
-
_clientInfo;
|
|
76
|
-
static _allContexts = new Set();
|
|
77
|
-
_closeBrowserContextPromise;
|
|
78
|
-
_isRunningTool = false;
|
|
79
|
-
_abortController = new AbortController();
|
|
80
|
-
constructor(options) {
|
|
81
|
-
// Set up hook registry and event store first
|
|
82
|
-
hookRegistryMap.set(this, buildHookRegistry());
|
|
83
|
-
setEventStore(this, createEventStore());
|
|
84
|
-
// Apply hooks to tools
|
|
85
|
-
this.tools = applyHooksToTools(options.tools, this);
|
|
86
|
-
this.config = options.config;
|
|
87
|
-
this.sessionLog = options.sessionLog;
|
|
88
|
-
this.options = options;
|
|
89
|
-
this._browserContextFactory = options.browserContextFactory;
|
|
90
|
-
this._clientInfo = options.clientInfo;
|
|
91
|
-
testDebug('create context');
|
|
92
|
-
Context._allContexts.add(this);
|
|
93
|
-
}
|
|
94
|
-
static async disposeAll() {
|
|
95
|
-
await Promise.all([...Context._allContexts].map(context => context.dispose()));
|
|
96
|
-
}
|
|
97
|
-
tabs() {
|
|
98
|
-
return this._tabs;
|
|
99
|
-
}
|
|
100
|
-
currentTab() {
|
|
101
|
-
return this._currentTab;
|
|
102
|
-
}
|
|
103
|
-
currentTabOrDie() {
|
|
104
|
-
if (!this._currentTab)
|
|
105
|
-
throw new Error('No open pages available. Use the "browser_navigate" tool to navigate to a page first.');
|
|
106
|
-
return this._currentTab;
|
|
107
|
-
}
|
|
108
|
-
async newTab() {
|
|
109
|
-
const { browserContext } = await this._ensureBrowserContext();
|
|
110
|
-
const page = await browserContext.newPage();
|
|
111
|
-
this._currentTab = this._tabs.find(t => t.page === page);
|
|
112
|
-
return this._currentTab;
|
|
113
|
-
}
|
|
114
|
-
async selectTab(index) {
|
|
115
|
-
const tab = this._tabs[index];
|
|
116
|
-
if (!tab)
|
|
117
|
-
throw new Error(`Tab ${index} not found`);
|
|
118
|
-
await tab.page.bringToFront();
|
|
119
|
-
this._currentTab = tab;
|
|
120
|
-
return tab;
|
|
121
|
-
}
|
|
122
|
-
async ensureTab() {
|
|
123
|
-
const { browserContext } = await this._ensureBrowserContext();
|
|
124
|
-
if (!this._currentTab)
|
|
125
|
-
await browserContext.newPage();
|
|
126
|
-
return this._currentTab;
|
|
127
|
-
}
|
|
128
|
-
async closeTab(index) {
|
|
129
|
-
const tab = index === undefined ? this._currentTab : this._tabs[index];
|
|
130
|
-
if (!tab)
|
|
131
|
-
throw new Error(`Tab ${index} not found`);
|
|
132
|
-
const url = tab.page.url();
|
|
133
|
-
await tab.page.close();
|
|
134
|
-
return url;
|
|
135
|
-
}
|
|
136
|
-
async outputFile(name) {
|
|
137
|
-
return outputFile(this.config, this._clientInfo.rootPath, name);
|
|
138
|
-
}
|
|
139
|
-
_onPageCreated(page) {
|
|
140
|
-
const tab = new Tab(this, page, tab => this._onPageClosed(tab));
|
|
141
|
-
this._tabs.push(tab);
|
|
142
|
-
if (!this._currentTab)
|
|
143
|
-
this._currentTab = tab;
|
|
144
|
-
// Set up network tracking via hooks system
|
|
145
|
-
setupNetworkTracking(this, page);
|
|
146
|
-
}
|
|
147
|
-
_onPageClosed(tab) {
|
|
148
|
-
const index = this._tabs.indexOf(tab);
|
|
149
|
-
if (index === -1)
|
|
150
|
-
return;
|
|
151
|
-
this._tabs.splice(index, 1);
|
|
152
|
-
if (this._currentTab === tab)
|
|
153
|
-
this._currentTab = this._tabs[Math.min(index, this._tabs.length - 1)];
|
|
154
|
-
if (!this._tabs.length)
|
|
155
|
-
void this.closeBrowserContext();
|
|
156
|
-
}
|
|
157
|
-
async closeBrowserContext() {
|
|
158
|
-
if (!this._closeBrowserContextPromise)
|
|
159
|
-
this._closeBrowserContextPromise = this._closeBrowserContextImpl().catch(logUnhandledError);
|
|
160
|
-
await this._closeBrowserContextPromise;
|
|
161
|
-
this._closeBrowserContextPromise = undefined;
|
|
162
|
-
}
|
|
163
|
-
isRunningTool() {
|
|
164
|
-
return this._isRunningTool;
|
|
165
|
-
}
|
|
166
|
-
setRunningTool(isRunningTool) {
|
|
167
|
-
this._isRunningTool = isRunningTool;
|
|
168
|
-
}
|
|
169
|
-
async _closeBrowserContextImpl() {
|
|
170
|
-
if (!this._browserContextPromise)
|
|
171
|
-
return;
|
|
172
|
-
testDebug('close context');
|
|
173
|
-
const promise = this._browserContextPromise;
|
|
174
|
-
this._browserContextPromise = undefined;
|
|
175
|
-
await promise.then(async ({ browserContext, close }) => {
|
|
176
|
-
if (this.config.saveTrace)
|
|
177
|
-
await browserContext.tracing.stop();
|
|
178
|
-
await close();
|
|
179
|
-
});
|
|
180
|
-
}
|
|
181
|
-
async dispose() {
|
|
182
|
-
this._abortController.abort('MCP context disposed');
|
|
183
|
-
await this.closeBrowserContext();
|
|
184
|
-
Context._allContexts.delete(this);
|
|
185
|
-
}
|
|
186
|
-
async _setupRequestInterception(context) {
|
|
187
|
-
await context.route('**', route => {
|
|
188
|
-
const request = route.request();
|
|
189
|
-
const url = request.url();
|
|
190
|
-
const resourceType = request.resourceType();
|
|
191
|
-
let urlObj;
|
|
192
|
-
try {
|
|
193
|
-
urlObj = new URL(url);
|
|
194
|
-
}
|
|
195
|
-
catch {
|
|
196
|
-
void route.continue();
|
|
197
|
-
return;
|
|
198
|
-
}
|
|
199
|
-
const domain = urlObj.hostname;
|
|
200
|
-
if (shouldBlockRequest(url, domain, resourceType)) {
|
|
201
|
-
void route.abort('blockedbyclient');
|
|
202
|
-
return;
|
|
203
|
-
}
|
|
204
|
-
if (this.config.network?.allowedOrigins?.length) {
|
|
205
|
-
const isAllowed = this.config.network.allowedOrigins.some(allowed => matchesOriginHost(urlObj, allowed));
|
|
206
|
-
if (!isAllowed) {
|
|
207
|
-
void route.abort('blockedbyclient');
|
|
208
|
-
return;
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
if (this.config.network?.blockedOrigins?.length) {
|
|
212
|
-
const isBlocked = this.config.network.blockedOrigins.some(blocked => matchesOriginHost(urlObj, blocked));
|
|
213
|
-
if (isBlocked) {
|
|
214
|
-
void route.abort('blockedbyclient');
|
|
215
|
-
return;
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
void route.continue();
|
|
219
|
-
});
|
|
220
|
-
}
|
|
221
|
-
_ensureBrowserContext() {
|
|
222
|
-
if (!this._browserContextPromise) {
|
|
223
|
-
this._browserContextPromise = this._setupBrowserContext();
|
|
224
|
-
this._browserContextPromise.catch(() => {
|
|
225
|
-
this._browserContextPromise = undefined;
|
|
226
|
-
});
|
|
227
|
-
}
|
|
228
|
-
return this._browserContextPromise;
|
|
229
|
-
}
|
|
230
|
-
async _setupBrowserContext() {
|
|
231
|
-
if (this._closeBrowserContextPromise)
|
|
232
|
-
throw new Error('Another browser context is being closed.');
|
|
233
|
-
// TODO: move to the browser context factory to make it based on isolation mode.
|
|
234
|
-
const result = await this._browserContextFactory.createContext(this._clientInfo, this._abortController.signal);
|
|
235
|
-
const { browserContext } = result;
|
|
236
|
-
await this._setupRequestInterception(browserContext);
|
|
237
|
-
if (this.sessionLog)
|
|
238
|
-
await InputRecorder.create(this, browserContext);
|
|
239
|
-
for (const page of browserContext.pages())
|
|
240
|
-
this._onPageCreated(page);
|
|
241
|
-
browserContext.on('page', page => this._onPageCreated(page));
|
|
242
|
-
if (this.config.saveTrace) {
|
|
243
|
-
await browserContext.tracing.start({
|
|
244
|
-
name: 'trace',
|
|
245
|
-
screenshots: false,
|
|
246
|
-
snapshots: true,
|
|
247
|
-
sources: false,
|
|
248
|
-
});
|
|
249
|
-
}
|
|
250
|
-
return result;
|
|
251
|
-
}
|
|
252
|
-
lookupSecret(secretName) {
|
|
253
|
-
// if (!this.config.secrets?.[secretName])
|
|
254
|
-
return { value: secretName, code: codegen.quote(secretName) };
|
|
255
|
-
// return {
|
|
256
|
-
// value: this.config.secrets[secretName]!,
|
|
257
|
-
// code: `process.env['${secretName}']`,
|
|
258
|
-
// };
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
export class InputRecorder {
|
|
262
|
-
_context;
|
|
263
|
-
_browserContext;
|
|
264
|
-
constructor(context, browserContext) {
|
|
265
|
-
this._context = context;
|
|
266
|
-
this._browserContext = browserContext;
|
|
267
|
-
}
|
|
268
|
-
static async create(context, browserContext) {
|
|
269
|
-
const recorder = new InputRecorder(context, browserContext);
|
|
270
|
-
await recorder._initialize();
|
|
271
|
-
return recorder;
|
|
272
|
-
}
|
|
273
|
-
async _initialize() {
|
|
274
|
-
const sessionLog = this._context.sessionLog;
|
|
275
|
-
await this._browserContext._enableRecorder({
|
|
276
|
-
mode: 'recording',
|
|
277
|
-
recorderMode: 'api',
|
|
278
|
-
}, {
|
|
279
|
-
actionAdded: (page, data, code) => {
|
|
280
|
-
if (this._context.isRunningTool())
|
|
281
|
-
return;
|
|
282
|
-
const tab = Tab.forPage(page);
|
|
283
|
-
if (tab)
|
|
284
|
-
sessionLog.logUserAction(data.action, tab, code, false);
|
|
285
|
-
},
|
|
286
|
-
actionUpdated: (page, data, code) => {
|
|
287
|
-
if (this._context.isRunningTool())
|
|
288
|
-
return;
|
|
289
|
-
const tab = Tab.forPage(page);
|
|
290
|
-
if (tab)
|
|
291
|
-
sessionLog.logUserAction(data.action, tab, code, true);
|
|
292
|
-
},
|
|
293
|
-
signalAdded: (page, data) => {
|
|
294
|
-
if (this._context.isRunningTool())
|
|
295
|
-
return;
|
|
296
|
-
if (data.signal.name !== 'navigation')
|
|
297
|
-
return;
|
|
298
|
-
const tab = Tab.forPage(page);
|
|
299
|
-
const navigateAction = {
|
|
300
|
-
name: 'navigate',
|
|
301
|
-
url: data.signal.url,
|
|
302
|
-
signals: [],
|
|
303
|
-
};
|
|
304
|
-
if (tab)
|
|
305
|
-
sessionLog.logUserAction(navigateAction, tab, `await page.goto('${data.signal.url}');`, false);
|
|
306
|
-
},
|
|
307
|
-
});
|
|
308
|
-
}
|
|
309
|
-
}
|
|
@@ -1,346 +0,0 @@
|
|
|
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
|
-
/**
|
|
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
|
-
*/
|
|
23
|
-
import { spawn } from 'child_process';
|
|
24
|
-
import debug from 'debug';
|
|
25
|
-
import { WebSocket, WebSocketServer } from 'ws';
|
|
26
|
-
import { httpAddressToString } from '../utils/httpServer.js';
|
|
27
|
-
import { logUnhandledError } from '../utils/log.js';
|
|
28
|
-
import { ManualPromise } from '../utils/manualPromise.js';
|
|
29
|
-
// @ts-ignore
|
|
30
|
-
const { registry } = await import('playwright-core/lib/server/registry/index');
|
|
31
|
-
const debugLogger = debug('pw:mcp:relay');
|
|
32
|
-
export class CDPRelayServer {
|
|
33
|
-
_wsHost;
|
|
34
|
-
_browserChannel;
|
|
35
|
-
_userDataDir;
|
|
36
|
-
_cdpPath;
|
|
37
|
-
_extensionPath;
|
|
38
|
-
_wss;
|
|
39
|
-
_playwrightConnection = null;
|
|
40
|
-
_extensionConnection = null;
|
|
41
|
-
_connectedTabInfo;
|
|
42
|
-
_nextSessionId = 1;
|
|
43
|
-
_extensionConnectionPromise;
|
|
44
|
-
constructor(server, browserChannel, userDataDir) {
|
|
45
|
-
this._wsHost = httpAddressToString(server.address()).replace(/^http/, 'ws');
|
|
46
|
-
this._browserChannel = browserChannel;
|
|
47
|
-
this._userDataDir = userDataDir;
|
|
48
|
-
const uuid = crypto.randomUUID();
|
|
49
|
-
this._cdpPath = `/cdp/${uuid}`;
|
|
50
|
-
this._extensionPath = `/extension/${uuid}`;
|
|
51
|
-
this._resetExtensionConnection();
|
|
52
|
-
this._wss = new WebSocketServer({ server });
|
|
53
|
-
this._wss.on('connection', this._onConnection.bind(this));
|
|
54
|
-
}
|
|
55
|
-
cdpEndpoint() {
|
|
56
|
-
return `${this._wsHost}${this._cdpPath}`;
|
|
57
|
-
}
|
|
58
|
-
extensionEndpoint() {
|
|
59
|
-
return `${this._wsHost}${this._extensionPath}`;
|
|
60
|
-
}
|
|
61
|
-
async ensureExtensionConnectionForMCPContext(clientInfo, abortSignal) {
|
|
62
|
-
debugLogger('Ensuring extension connection for MCP context');
|
|
63
|
-
if (this._extensionConnection)
|
|
64
|
-
return;
|
|
65
|
-
this._connectBrowser(clientInfo);
|
|
66
|
-
debugLogger('Waiting for incoming extension connection');
|
|
67
|
-
await Promise.race([
|
|
68
|
-
this._extensionConnectionPromise,
|
|
69
|
-
new Promise((_, reject) => abortSignal.addEventListener('abort', reject))
|
|
70
|
-
]);
|
|
71
|
-
debugLogger('Extension connection established');
|
|
72
|
-
}
|
|
73
|
-
_connectBrowser(clientInfo) {
|
|
74
|
-
const mcpRelayEndpoint = `${this._wsHost}${this._extensionPath}`;
|
|
75
|
-
// Need to specify "key" in the manifest.json to make the id stable when loading from file.
|
|
76
|
-
const url = new URL('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html');
|
|
77
|
-
url.searchParams.set('mcpRelayUrl', mcpRelayEndpoint);
|
|
78
|
-
url.searchParams.set('client', JSON.stringify(clientInfo));
|
|
79
|
-
const href = url.toString();
|
|
80
|
-
const executableInfo = registry.findExecutable(this._browserChannel);
|
|
81
|
-
if (!executableInfo)
|
|
82
|
-
throw new Error(`Unsupported channel: "${this._browserChannel}"`);
|
|
83
|
-
const executablePath = executableInfo.executablePath();
|
|
84
|
-
if (!executablePath)
|
|
85
|
-
throw new Error(`"${this._browserChannel}" executable not found. Make sure it is installed at a standard location.`);
|
|
86
|
-
const args = [];
|
|
87
|
-
if (this._userDataDir)
|
|
88
|
-
args.push(`--user-data-dir=${this._userDataDir}`);
|
|
89
|
-
args.push(href);
|
|
90
|
-
spawn(executablePath, args, {
|
|
91
|
-
windowsHide: true,
|
|
92
|
-
detached: true,
|
|
93
|
-
shell: false,
|
|
94
|
-
stdio: 'ignore',
|
|
95
|
-
});
|
|
96
|
-
}
|
|
97
|
-
stop() {
|
|
98
|
-
this.closeConnections('Server stopped');
|
|
99
|
-
this._wss.close();
|
|
100
|
-
}
|
|
101
|
-
closeConnections(reason) {
|
|
102
|
-
this._closePlaywrightConnection(reason);
|
|
103
|
-
this._closeExtensionConnection(reason);
|
|
104
|
-
}
|
|
105
|
-
_onConnection(ws, request) {
|
|
106
|
-
const url = new URL(`http://localhost${request.url}`);
|
|
107
|
-
debugLogger(`New connection to ${url.pathname}`);
|
|
108
|
-
if (url.pathname === this._cdpPath) {
|
|
109
|
-
this._handlePlaywrightConnection(ws);
|
|
110
|
-
}
|
|
111
|
-
else if (url.pathname === this._extensionPath) {
|
|
112
|
-
this._handleExtensionConnection(ws);
|
|
113
|
-
}
|
|
114
|
-
else {
|
|
115
|
-
debugLogger(`Invalid path: ${url.pathname}`);
|
|
116
|
-
ws.close(4004, 'Invalid path');
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
_handlePlaywrightConnection(ws) {
|
|
120
|
-
if (this._playwrightConnection) {
|
|
121
|
-
debugLogger('Rejecting second Playwright connection');
|
|
122
|
-
ws.close(1000, 'Another CDP client already connected');
|
|
123
|
-
return;
|
|
124
|
-
}
|
|
125
|
-
this._playwrightConnection = ws;
|
|
126
|
-
ws.on('message', async (data) => {
|
|
127
|
-
try {
|
|
128
|
-
const message = JSON.parse(data.toString());
|
|
129
|
-
await this._handlePlaywrightMessage(message);
|
|
130
|
-
}
|
|
131
|
-
catch (error) {
|
|
132
|
-
debugLogger(`Error while handling Playwright message\n${data.toString()}\n`, error);
|
|
133
|
-
}
|
|
134
|
-
});
|
|
135
|
-
ws.on('close', () => {
|
|
136
|
-
if (this._playwrightConnection !== ws)
|
|
137
|
-
return;
|
|
138
|
-
this._playwrightConnection = null;
|
|
139
|
-
this._closeExtensionConnection('Playwright client disconnected');
|
|
140
|
-
debugLogger('Playwright WebSocket closed');
|
|
141
|
-
});
|
|
142
|
-
ws.on('error', error => {
|
|
143
|
-
debugLogger('Playwright WebSocket error:', error);
|
|
144
|
-
});
|
|
145
|
-
debugLogger('Playwright MCP connected');
|
|
146
|
-
}
|
|
147
|
-
_closeExtensionConnection(reason) {
|
|
148
|
-
this._extensionConnection?.close(reason);
|
|
149
|
-
this._extensionConnectionPromise.reject(new Error(reason));
|
|
150
|
-
this._resetExtensionConnection();
|
|
151
|
-
}
|
|
152
|
-
_resetExtensionConnection() {
|
|
153
|
-
this._connectedTabInfo = undefined;
|
|
154
|
-
this._extensionConnection = null;
|
|
155
|
-
this._extensionConnectionPromise = new ManualPromise();
|
|
156
|
-
void this._extensionConnectionPromise.catch(logUnhandledError);
|
|
157
|
-
}
|
|
158
|
-
_closePlaywrightConnection(reason) {
|
|
159
|
-
if (this._playwrightConnection?.readyState === WebSocket.OPEN)
|
|
160
|
-
this._playwrightConnection.close(1000, reason);
|
|
161
|
-
this._playwrightConnection = null;
|
|
162
|
-
}
|
|
163
|
-
_handleExtensionConnection(ws) {
|
|
164
|
-
if (this._extensionConnection) {
|
|
165
|
-
ws.close(1000, 'Another extension connection already established');
|
|
166
|
-
return;
|
|
167
|
-
}
|
|
168
|
-
this._extensionConnection = new ExtensionConnection(ws);
|
|
169
|
-
this._extensionConnection.onclose = (c, reason) => {
|
|
170
|
-
debugLogger('Extension WebSocket closed:', reason, c === this._extensionConnection);
|
|
171
|
-
if (this._extensionConnection !== c)
|
|
172
|
-
return;
|
|
173
|
-
this._resetExtensionConnection();
|
|
174
|
-
this._closePlaywrightConnection(`Extension disconnected: ${reason}`);
|
|
175
|
-
};
|
|
176
|
-
this._extensionConnection.onmessage = this._handleExtensionMessage.bind(this);
|
|
177
|
-
this._extensionConnectionPromise.resolve();
|
|
178
|
-
}
|
|
179
|
-
_handleExtensionMessage(method, params) {
|
|
180
|
-
switch (method) {
|
|
181
|
-
case 'forwardCDPEvent':
|
|
182
|
-
const sessionId = params.sessionId || this._connectedTabInfo?.sessionId;
|
|
183
|
-
this._sendToPlaywright({
|
|
184
|
-
sessionId,
|
|
185
|
-
method: params.method,
|
|
186
|
-
params: params.params
|
|
187
|
-
});
|
|
188
|
-
break;
|
|
189
|
-
case 'detachedFromTab':
|
|
190
|
-
debugLogger('← Debugger detached from tab:', params);
|
|
191
|
-
this._connectedTabInfo = undefined;
|
|
192
|
-
break;
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
async _handlePlaywrightMessage(message) {
|
|
196
|
-
debugLogger('← Playwright:', `${message.method} (id=${message.id})`);
|
|
197
|
-
const { id, sessionId, method, params } = message;
|
|
198
|
-
try {
|
|
199
|
-
const result = await this._handleCDPCommand(method, params, sessionId);
|
|
200
|
-
this._sendToPlaywright({ id, sessionId, result });
|
|
201
|
-
}
|
|
202
|
-
catch (e) {
|
|
203
|
-
debugLogger('Error in the extension:', e);
|
|
204
|
-
this._sendToPlaywright({
|
|
205
|
-
id,
|
|
206
|
-
sessionId,
|
|
207
|
-
error: { message: e.message }
|
|
208
|
-
});
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
async _handleCDPCommand(method, params, sessionId) {
|
|
212
|
-
switch (method) {
|
|
213
|
-
case 'Browser.getVersion': {
|
|
214
|
-
return {
|
|
215
|
-
protocolVersion: '1.3',
|
|
216
|
-
product: 'Chrome/Extension-Bridge',
|
|
217
|
-
userAgent: 'CDP-Bridge-Server/1.0.0',
|
|
218
|
-
};
|
|
219
|
-
}
|
|
220
|
-
case 'Browser.setDownloadBehavior': {
|
|
221
|
-
return {};
|
|
222
|
-
}
|
|
223
|
-
case 'Target.setAutoAttach': {
|
|
224
|
-
// Forward child session handling.
|
|
225
|
-
if (sessionId)
|
|
226
|
-
break;
|
|
227
|
-
// Simulate auto-attach behavior with real target info
|
|
228
|
-
const { targetInfo } = await this._extensionConnection.send('attachToTab');
|
|
229
|
-
this._connectedTabInfo = {
|
|
230
|
-
targetInfo,
|
|
231
|
-
sessionId: `pw-tab-${this._nextSessionId++}`,
|
|
232
|
-
};
|
|
233
|
-
debugLogger('Simulating auto-attach');
|
|
234
|
-
this._sendToPlaywright({
|
|
235
|
-
method: 'Target.attachedToTarget',
|
|
236
|
-
params: {
|
|
237
|
-
sessionId: this._connectedTabInfo.sessionId,
|
|
238
|
-
targetInfo: {
|
|
239
|
-
...this._connectedTabInfo.targetInfo,
|
|
240
|
-
attached: true,
|
|
241
|
-
},
|
|
242
|
-
waitingForDebugger: false
|
|
243
|
-
}
|
|
244
|
-
});
|
|
245
|
-
return {};
|
|
246
|
-
}
|
|
247
|
-
case 'Target.getTargetInfo': {
|
|
248
|
-
return this._connectedTabInfo?.targetInfo;
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
return await this._forwardToExtension(method, params, sessionId);
|
|
252
|
-
}
|
|
253
|
-
async _forwardToExtension(method, params, sessionId) {
|
|
254
|
-
if (!this._extensionConnection)
|
|
255
|
-
throw new Error('Extension not connected');
|
|
256
|
-
// Top level sessionId is only passed between the relay and the client.
|
|
257
|
-
if (this._connectedTabInfo?.sessionId === sessionId)
|
|
258
|
-
sessionId = undefined;
|
|
259
|
-
return await this._extensionConnection.send('forwardCDPCommand', { sessionId, method, params });
|
|
260
|
-
}
|
|
261
|
-
_sendToPlaywright(message) {
|
|
262
|
-
debugLogger('→ Playwright:', `${message.method ?? `response(id=${message.id})`}`);
|
|
263
|
-
this._playwrightConnection?.send(JSON.stringify(message));
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
class ExtensionConnection {
|
|
267
|
-
_ws;
|
|
268
|
-
_callbacks = new Map();
|
|
269
|
-
_lastId = 0;
|
|
270
|
-
onmessage;
|
|
271
|
-
onclose;
|
|
272
|
-
constructor(ws) {
|
|
273
|
-
this._ws = ws;
|
|
274
|
-
this._ws.on('message', this._onMessage.bind(this));
|
|
275
|
-
this._ws.on('close', this._onClose.bind(this));
|
|
276
|
-
this._ws.on('error', this._onError.bind(this));
|
|
277
|
-
}
|
|
278
|
-
async send(method, params, sessionId) {
|
|
279
|
-
if (this._ws.readyState !== WebSocket.OPEN)
|
|
280
|
-
throw new Error(`Unexpected WebSocket state: ${this._ws.readyState}`);
|
|
281
|
-
const id = ++this._lastId;
|
|
282
|
-
this._ws.send(JSON.stringify({ id, method, params, sessionId }));
|
|
283
|
-
const error = new Error(`Protocol error: ${method}`);
|
|
284
|
-
return new Promise((resolve, reject) => {
|
|
285
|
-
this._callbacks.set(id, { resolve, reject, error });
|
|
286
|
-
});
|
|
287
|
-
}
|
|
288
|
-
close(message) {
|
|
289
|
-
debugLogger('closing extension connection:', message);
|
|
290
|
-
if (this._ws.readyState === WebSocket.OPEN)
|
|
291
|
-
this._ws.close(1000, message);
|
|
292
|
-
}
|
|
293
|
-
_onMessage(event) {
|
|
294
|
-
const eventData = event.toString();
|
|
295
|
-
let parsedJson;
|
|
296
|
-
try {
|
|
297
|
-
parsedJson = JSON.parse(eventData);
|
|
298
|
-
}
|
|
299
|
-
catch (e) {
|
|
300
|
-
debugLogger(`<closing ws> Closing websocket due to malformed JSON. eventData=${eventData} e=${e?.message}`);
|
|
301
|
-
this._ws.close();
|
|
302
|
-
return;
|
|
303
|
-
}
|
|
304
|
-
try {
|
|
305
|
-
this._handleParsedMessage(parsedJson);
|
|
306
|
-
}
|
|
307
|
-
catch (e) {
|
|
308
|
-
debugLogger(`<closing ws> Closing websocket due to failed onmessage callback. eventData=${eventData} e=${e?.message}`);
|
|
309
|
-
this._ws.close();
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
_handleParsedMessage(object) {
|
|
313
|
-
if (object.id && this._callbacks.has(object.id)) {
|
|
314
|
-
const callback = this._callbacks.get(object.id);
|
|
315
|
-
this._callbacks.delete(object.id);
|
|
316
|
-
if (object.error) {
|
|
317
|
-
const error = callback.error;
|
|
318
|
-
error.message = object.error;
|
|
319
|
-
callback.reject(error);
|
|
320
|
-
}
|
|
321
|
-
else {
|
|
322
|
-
callback.resolve(object.result);
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
else if (object.id) {
|
|
326
|
-
debugLogger('← Extension: unexpected response', object);
|
|
327
|
-
}
|
|
328
|
-
else {
|
|
329
|
-
this.onmessage?.(object.method, object.params);
|
|
330
|
-
}
|
|
331
|
-
}
|
|
332
|
-
_onClose(event) {
|
|
333
|
-
debugLogger(`<ws closed> code=${event.code} reason=${event.reason}`);
|
|
334
|
-
this._dispose();
|
|
335
|
-
this.onclose?.(this, event.reason);
|
|
336
|
-
}
|
|
337
|
-
_onError(event) {
|
|
338
|
-
debugLogger(`<ws error> message=${event.message} type=${event.type} target=${event.target}`);
|
|
339
|
-
this._dispose();
|
|
340
|
-
}
|
|
341
|
-
_dispose() {
|
|
342
|
-
for (const callback of this._callbacks.values())
|
|
343
|
-
callback.reject(new Error('WebSocket closed'));
|
|
344
|
-
this._callbacks.clear();
|
|
345
|
-
}
|
|
346
|
-
}
|