@wordbricks/playwright-mcp 0.1.3
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/LICENSE +202 -0
- package/README.md +624 -0
- package/cli-wrapper.js +47 -0
- package/cli.js +18 -0
- package/config.d.ts +119 -0
- package/index.d.ts +23 -0
- package/index.js +19 -0
- package/lib/browserContextFactory.js +289 -0
- package/lib/browserServerBackend.js +82 -0
- package/lib/config.js +246 -0
- package/lib/context.js +236 -0
- package/lib/extension/cdpRelay.js +346 -0
- package/lib/extension/extensionContextFactory.js +56 -0
- package/lib/frameworkPatterns.js +35 -0
- package/lib/hooks/core.js +144 -0
- package/lib/hooks/eventConsumer.js +39 -0
- package/lib/hooks/events.js +42 -0
- package/lib/hooks/formatToolCallEvent.js +16 -0
- package/lib/hooks/frameworkStateHook.js +182 -0
- package/lib/hooks/grouping.js +72 -0
- package/lib/hooks/jsonLdDetectionHook.js +175 -0
- package/lib/hooks/networkFilters.js +74 -0
- package/lib/hooks/networkSetup.js +56 -0
- package/lib/hooks/networkTrackingHook.js +55 -0
- package/lib/hooks/pageHeightHook.js +75 -0
- package/lib/hooks/registry.js +39 -0
- package/lib/hooks/requireTabHook.js +26 -0
- package/lib/hooks/schema.js +75 -0
- package/lib/hooks/waitHook.js +33 -0
- package/lib/index.js +39 -0
- package/lib/loop/loop.js +69 -0
- package/lib/loop/loopClaude.js +152 -0
- package/lib/loop/loopOpenAI.js +141 -0
- package/lib/loop/main.js +60 -0
- package/lib/loopTools/context.js +66 -0
- package/lib/loopTools/main.js +51 -0
- package/lib/loopTools/perform.js +32 -0
- package/lib/loopTools/snapshot.js +29 -0
- package/lib/loopTools/tool.js +18 -0
- package/lib/mcp/inProcessTransport.js +72 -0
- package/lib/mcp/proxyBackend.js +115 -0
- package/lib/mcp/server.js +86 -0
- package/lib/mcp/tool.js +29 -0
- package/lib/mcp/transport.js +181 -0
- package/lib/playwrightTransformer.js +497 -0
- package/lib/program.js +111 -0
- package/lib/response.js +186 -0
- package/lib/sessionLog.js +121 -0
- package/lib/tab.js +249 -0
- package/lib/tools/common.js +55 -0
- package/lib/tools/console.js +33 -0
- package/lib/tools/dialogs.js +47 -0
- package/lib/tools/evaluate.js +53 -0
- package/lib/tools/extractFrameworkState.js +214 -0
- package/lib/tools/files.js +44 -0
- package/lib/tools/getSnapshot.js +37 -0
- package/lib/tools/getVisibleHtml.js +52 -0
- package/lib/tools/install.js +53 -0
- package/lib/tools/keyboard.js +78 -0
- package/lib/tools/mouse.js +99 -0
- package/lib/tools/navigate.js +70 -0
- package/lib/tools/network.js +123 -0
- package/lib/tools/networkDetail.js +231 -0
- package/lib/tools/networkSearch/bodySearch.js +141 -0
- package/lib/tools/networkSearch/grouping.js +28 -0
- package/lib/tools/networkSearch/helpers.js +32 -0
- package/lib/tools/networkSearch/searchHtml.js +65 -0
- package/lib/tools/networkSearch/types.js +1 -0
- package/lib/tools/networkSearch/urlSearch.js +82 -0
- package/lib/tools/networkSearch.js +168 -0
- package/lib/tools/pdf.js +40 -0
- package/lib/tools/repl.js +402 -0
- package/lib/tools/screenshot.js +79 -0
- package/lib/tools/scroll.js +126 -0
- package/lib/tools/snapshot.js +139 -0
- package/lib/tools/tabs.js +87 -0
- package/lib/tools/tool.js +33 -0
- package/lib/tools/utils.js +74 -0
- package/lib/tools/wait.js +55 -0
- package/lib/tools.js +67 -0
- package/lib/utils/codegen.js +49 -0
- package/lib/utils/extensionPath.js +6 -0
- package/lib/utils/fileUtils.js +36 -0
- package/lib/utils/graphql.js +258 -0
- package/lib/utils/guid.js +22 -0
- package/lib/utils/httpServer.js +39 -0
- package/lib/utils/log.js +21 -0
- package/lib/utils/manualPromise.js +111 -0
- package/lib/utils/networkFormat.js +12 -0
- package/lib/utils/package.js +20 -0
- package/lib/utils/result.js +2 -0
- package/lib/utils/sanitizeHtml.js +98 -0
- package/lib/utils/truncate.js +103 -0
- package/lib/utils/withTimeout.js +7 -0
- package/package.json +100 -0
|
@@ -0,0 +1,346 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
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 * as playwright from 'playwright';
|
|
18
|
+
import { startHttpServer } from '../utils/httpServer.js';
|
|
19
|
+
import { CDPRelayServer } from './cdpRelay.js';
|
|
20
|
+
const debugLogger = debug('pw:mcp:relay');
|
|
21
|
+
export class ExtensionContextFactory {
|
|
22
|
+
name = 'extension';
|
|
23
|
+
description = 'Connect to a browser using the Playwright MCP extension';
|
|
24
|
+
_browserChannel;
|
|
25
|
+
_userDataDir;
|
|
26
|
+
constructor(browserChannel, userDataDir) {
|
|
27
|
+
this._browserChannel = browserChannel;
|
|
28
|
+
this._userDataDir = userDataDir;
|
|
29
|
+
}
|
|
30
|
+
async createContext(clientInfo, abortSignal) {
|
|
31
|
+
const browser = await this._obtainBrowser(clientInfo, abortSignal);
|
|
32
|
+
return {
|
|
33
|
+
browserContext: browser.contexts()[0],
|
|
34
|
+
close: async () => {
|
|
35
|
+
debugLogger('close() called for browser context');
|
|
36
|
+
await browser.close();
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
async _obtainBrowser(clientInfo, abortSignal) {
|
|
41
|
+
const relay = await this._startRelay(abortSignal);
|
|
42
|
+
await relay.ensureExtensionConnectionForMCPContext(clientInfo, abortSignal);
|
|
43
|
+
return await playwright.chromium.connectOverCDP(relay.cdpEndpoint());
|
|
44
|
+
}
|
|
45
|
+
async _startRelay(abortSignal) {
|
|
46
|
+
const httpServer = await startHttpServer({});
|
|
47
|
+
if (abortSignal.aborted) {
|
|
48
|
+
httpServer.close();
|
|
49
|
+
throw new Error(abortSignal.reason);
|
|
50
|
+
}
|
|
51
|
+
const cdpRelayServer = new CDPRelayServer(httpServer, this._browserChannel, this._userDataDir);
|
|
52
|
+
abortSignal.addEventListener('abort', () => cdpRelayServer.stop());
|
|
53
|
+
debugLogger(`CDP relay server started, extension endpoint: ${cdpRelayServer.extensionEndpoint()}.`);
|
|
54
|
+
return cdpRelayServer;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared framework state patterns used by framework state detection hook
|
|
3
|
+
*/
|
|
4
|
+
export const FRAMEWORK_STATE_PATTERNS = [
|
|
5
|
+
// React/Next.js
|
|
6
|
+
'__NEXT_DATA__',
|
|
7
|
+
'__reactServerState',
|
|
8
|
+
// Remix
|
|
9
|
+
'__remixContext',
|
|
10
|
+
'__remixManifest',
|
|
11
|
+
'__remixRouteModules',
|
|
12
|
+
// Apollo GraphQL
|
|
13
|
+
'__APOLLO_STATE__',
|
|
14
|
+
'__APOLLO_CLIENT__',
|
|
15
|
+
// Redux/State Management
|
|
16
|
+
'__PRELOADED_STATE__',
|
|
17
|
+
'__INITIAL_STATE__',
|
|
18
|
+
'__REDUX_STATE__',
|
|
19
|
+
// Vue/Nuxt
|
|
20
|
+
'__NUXT__',
|
|
21
|
+
// Gatsby
|
|
22
|
+
'___gatsby',
|
|
23
|
+
'___loader',
|
|
24
|
+
// Generic SSR
|
|
25
|
+
'__SSR_DATA__',
|
|
26
|
+
'__APP_STATE__',
|
|
27
|
+
'__SERVER_STATE__',
|
|
28
|
+
// Others
|
|
29
|
+
'__QWIK_STATE__',
|
|
30
|
+
'__SVELTE__',
|
|
31
|
+
'__ANGULAR__',
|
|
32
|
+
'__SOLID__',
|
|
33
|
+
'__ASTRO_DATA__',
|
|
34
|
+
];
|
|
35
|
+
export const MAX_DISPLAY_ITEMS = 5;
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { reduce } from '@fxts/core';
|
|
2
|
+
import { getEventStore, trackEvent } from './events.js';
|
|
3
|
+
import { consumeEvents } from './eventConsumer.js';
|
|
4
|
+
import { toolNameSchema } from './schema.js';
|
|
5
|
+
export { Ok, Err } from '../utils/result.js';
|
|
6
|
+
export const runHook = async (hook, ctx) => {
|
|
7
|
+
const result = await hook.handler(ctx);
|
|
8
|
+
if (!result.ok)
|
|
9
|
+
throw result.error;
|
|
10
|
+
return ctx;
|
|
11
|
+
};
|
|
12
|
+
export const hookRegistryMap = new WeakMap();
|
|
13
|
+
export const createHookRegistry = () => ({
|
|
14
|
+
tools: new Map(),
|
|
15
|
+
});
|
|
16
|
+
export const setToolHooks = (registry, toolHooks) => ({
|
|
17
|
+
tools: new Map([...registry.tools, [toolHooks.toolName, toolHooks]]),
|
|
18
|
+
});
|
|
19
|
+
export const getToolHooks = (registry, toolName) => {
|
|
20
|
+
return registry.tools.get(toolName);
|
|
21
|
+
};
|
|
22
|
+
export const wrapToolWithHooks = (tool, registry) => {
|
|
23
|
+
const parsedName = toolNameSchema.safeParse(tool.schema.name);
|
|
24
|
+
if (!parsedName.success)
|
|
25
|
+
return tool; // Tool name not in our schema, don't apply hooks
|
|
26
|
+
// NOTE: This means tool call events won't be tracked for tools not in toolNameSchema.
|
|
27
|
+
// All tools exposed by this MCP server should be added to the schema.
|
|
28
|
+
const toolName = parsedName.data;
|
|
29
|
+
const toolHooks = getToolHooks(registry, toolName);
|
|
30
|
+
// Even if no hooks configured, we still need to consume events and track tool calls
|
|
31
|
+
if (!toolHooks || (toolHooks.preHooks.length === 0 && toolHooks.postHooks.length === 0)) {
|
|
32
|
+
return {
|
|
33
|
+
...tool,
|
|
34
|
+
handle: async (context, params, response) => {
|
|
35
|
+
const eventStore = getEventStore(context);
|
|
36
|
+
// Consume pre-tool events
|
|
37
|
+
consumeEvents(context, eventStore, response);
|
|
38
|
+
// Track tool execution
|
|
39
|
+
const startTime = Date.now();
|
|
40
|
+
let success = true;
|
|
41
|
+
try {
|
|
42
|
+
await tool.handle(context, params, response);
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
success = false;
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
finally {
|
|
49
|
+
// Record tool call completion
|
|
50
|
+
const executionTime = Date.now() - startTime;
|
|
51
|
+
trackEvent(context, {
|
|
52
|
+
type: 'tool-call',
|
|
53
|
+
data: {
|
|
54
|
+
toolName,
|
|
55
|
+
params: params,
|
|
56
|
+
executionTime,
|
|
57
|
+
success,
|
|
58
|
+
},
|
|
59
|
+
timestamp: startTime,
|
|
60
|
+
});
|
|
61
|
+
// Consume post-tool events
|
|
62
|
+
consumeEvents(context, eventStore, response);
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
...tool,
|
|
69
|
+
handle: async (context, params, response) => {
|
|
70
|
+
const eventStore = getEventStore(context);
|
|
71
|
+
// Consume pre-tool events
|
|
72
|
+
consumeEvents(context, eventStore, response);
|
|
73
|
+
// Track tool execution
|
|
74
|
+
const startTime = Date.now();
|
|
75
|
+
// Run pre-hooks
|
|
76
|
+
const hookContext = {
|
|
77
|
+
context,
|
|
78
|
+
tab: context.currentTab(),
|
|
79
|
+
params,
|
|
80
|
+
toolName,
|
|
81
|
+
response,
|
|
82
|
+
eventStore,
|
|
83
|
+
};
|
|
84
|
+
try {
|
|
85
|
+
await reduce(async (ctx, hook) => runHook(hook, await ctx), Promise.resolve(hookContext), toolHooks.preHooks);
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
// Pre-hook already handled messaging (e.g., require-tab pre-hook sets tabs section)
|
|
89
|
+
// Avoid adding a duplicate error line in the Result section.
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
// Run original tool
|
|
93
|
+
let success = true;
|
|
94
|
+
try {
|
|
95
|
+
await tool.handle(context, params, response);
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
success = false;
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
// Record tool call completion
|
|
103
|
+
const executionTime = Date.now() - startTime;
|
|
104
|
+
trackEvent(context, {
|
|
105
|
+
type: 'tool-call',
|
|
106
|
+
data: {
|
|
107
|
+
toolName,
|
|
108
|
+
params: params,
|
|
109
|
+
executionTime,
|
|
110
|
+
success,
|
|
111
|
+
},
|
|
112
|
+
timestamp: startTime,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
// Run post-hooks
|
|
116
|
+
const postHookContext = {
|
|
117
|
+
context,
|
|
118
|
+
tab: context.currentTab(),
|
|
119
|
+
params,
|
|
120
|
+
toolName,
|
|
121
|
+
response,
|
|
122
|
+
eventStore: getEventStore(context),
|
|
123
|
+
};
|
|
124
|
+
try {
|
|
125
|
+
await reduce(async (ctx, hook) => runHook(hook, await ctx), Promise.resolve(postHookContext), toolHooks.postHooks);
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
response.addError(error instanceof Error ? error.message : 'Post-hook failed');
|
|
129
|
+
}
|
|
130
|
+
// Consume post-tool events
|
|
131
|
+
consumeEvents(context, eventStore, response);
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
};
|
|
135
|
+
export const getHookRegistry = (context) => {
|
|
136
|
+
const registry = hookRegistryMap.get(context);
|
|
137
|
+
return registry || createHookRegistry();
|
|
138
|
+
};
|
|
139
|
+
export const applyHooksToTools = (tools, context) => {
|
|
140
|
+
const registry = getHookRegistry(context);
|
|
141
|
+
if (registry.tools.size === 0)
|
|
142
|
+
return tools;
|
|
143
|
+
return tools.map(tool => wrapToolWithHooks(tool, registry));
|
|
144
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { getEventsAfter, updateLastSeenId } from './events.js';
|
|
2
|
+
import { formatWaitEvent } from './waitHook.js';
|
|
3
|
+
import { formatPageHeightEvent } from './pageHeightHook.js';
|
|
4
|
+
import { formatNetworkEvent } from './networkTrackingHook.js';
|
|
5
|
+
import { planGroupedMessages } from './grouping.js';
|
|
6
|
+
import { formatToolCallEvent } from './formatToolCallEvent.js';
|
|
7
|
+
import { formatFrameworkStateEvent } from './frameworkStateHook.js';
|
|
8
|
+
import { formatJsonLdEvent } from './jsonLdDetectionHook.js';
|
|
9
|
+
const eventFormatters = {
|
|
10
|
+
'wait': formatWaitEvent,
|
|
11
|
+
'page-height-change': formatPageHeightEvent,
|
|
12
|
+
'network-request': formatNetworkEvent,
|
|
13
|
+
'tool-call': formatToolCallEvent,
|
|
14
|
+
'framework-state': formatFrameworkStateEvent,
|
|
15
|
+
'json-ld': formatJsonLdEvent,
|
|
16
|
+
};
|
|
17
|
+
const formatEvent = (event) => {
|
|
18
|
+
const formatter = eventFormatters[event.type];
|
|
19
|
+
return formatter(event);
|
|
20
|
+
};
|
|
21
|
+
const consumeEvent = (event, response, plan) => {
|
|
22
|
+
if (plan.skipIds.has(event.id))
|
|
23
|
+
return;
|
|
24
|
+
const replacement = plan.replacementById.get(event.id);
|
|
25
|
+
const formattedMessage = replacement ?? formatEvent(event);
|
|
26
|
+
response.addEvent(`[${event.id}] ${formattedMessage}`);
|
|
27
|
+
};
|
|
28
|
+
export const consumeEvents = (context, eventStore, response) => {
|
|
29
|
+
const unconsumedEvents = getEventsAfter(eventStore, eventStore.lastSeenEventId);
|
|
30
|
+
if (unconsumedEvents.length === 0)
|
|
31
|
+
return;
|
|
32
|
+
const plan = planGroupedMessages(unconsumedEvents);
|
|
33
|
+
// Consume all events in chronological order
|
|
34
|
+
for (const event of unconsumedEvents)
|
|
35
|
+
consumeEvent(event, response, plan);
|
|
36
|
+
// Update last seen event ID
|
|
37
|
+
const latestEvent = unconsumedEvents[unconsumedEvents.length - 1];
|
|
38
|
+
updateLastSeenId(context, latestEvent.id);
|
|
39
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { pipe, filter, toArray } from '@fxts/core';
|
|
2
|
+
export const isEventType = (type) => (event) => event.type === type;
|
|
3
|
+
export const createEventStore = () => ({
|
|
4
|
+
events: new Map(),
|
|
5
|
+
lastSeenEventId: undefined,
|
|
6
|
+
nextEventId: 1,
|
|
7
|
+
});
|
|
8
|
+
export const trackEvent = (context, params) => {
|
|
9
|
+
const store = getEventStore(context);
|
|
10
|
+
const eventId = store.nextEventId++;
|
|
11
|
+
const event = {
|
|
12
|
+
id: eventId,
|
|
13
|
+
type: params.type,
|
|
14
|
+
data: params.data,
|
|
15
|
+
timestamp: params.timestamp ?? Date.now()
|
|
16
|
+
};
|
|
17
|
+
store.events.set(eventId, event);
|
|
18
|
+
return eventId;
|
|
19
|
+
};
|
|
20
|
+
export const updateLastSeenId = (context, eventId) => {
|
|
21
|
+
const store = getEventStore(context);
|
|
22
|
+
store.lastSeenEventId = eventId;
|
|
23
|
+
return context;
|
|
24
|
+
};
|
|
25
|
+
export const getEventsAfter = (store, afterEventId) => {
|
|
26
|
+
if (!afterEventId) {
|
|
27
|
+
return pipe(store.events.values(), toArray);
|
|
28
|
+
}
|
|
29
|
+
return pipe(store.events.values(), filter(event => event.id > afterEventId), toArray);
|
|
30
|
+
};
|
|
31
|
+
const eventStoreMap = new WeakMap();
|
|
32
|
+
export const getEventStore = (context) => {
|
|
33
|
+
let store = eventStoreMap.get(context);
|
|
34
|
+
if (!store) {
|
|
35
|
+
store = createEventStore();
|
|
36
|
+
eventStoreMap.set(context, store);
|
|
37
|
+
}
|
|
38
|
+
return store;
|
|
39
|
+
};
|
|
40
|
+
export const setEventStore = (context, store) => {
|
|
41
|
+
eventStoreMap.set(context, store);
|
|
42
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export const formatToolCallEvent = (event) => {
|
|
2
|
+
const { toolName, params, executionTime, success } = event.data;
|
|
3
|
+
// Format parameters (truncate if too long)
|
|
4
|
+
const paramStr = params && Object.keys(params).length > 0
|
|
5
|
+
? ` with params: ${JSON.stringify(params, null, 0).slice(0, 100)}`
|
|
6
|
+
: '';
|
|
7
|
+
// Format execution time if available
|
|
8
|
+
const timeStr = executionTime !== undefined
|
|
9
|
+
? ` (${executionTime}ms)`
|
|
10
|
+
: '';
|
|
11
|
+
// Format success status if available
|
|
12
|
+
const statusStr = success !== undefined
|
|
13
|
+
? success ? ' ✓' : ' ✗'
|
|
14
|
+
: '';
|
|
15
|
+
return `Tool ${toolName}${paramStr}${timeStr}${statusStr}`;
|
|
16
|
+
};
|