@playwright/mcp 0.0.31 → 0.0.32
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 +25 -4
- package/config.d.ts +5 -0
- package/index.d.ts +1 -6
- package/lib/browserServerBackend.js +54 -0
- package/lib/config.js +2 -1
- package/lib/context.js +48 -171
- package/lib/extension/cdpRelay.js +370 -0
- package/lib/extension/main.js +33 -0
- package/lib/httpServer.js +20 -182
- package/lib/index.js +3 -2
- 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 +49 -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/server.js +88 -0
- package/lib/{transport.js → mcp/transport.js} +30 -42
- package/lib/package.js +3 -3
- package/lib/program.js +38 -9
- package/lib/response.js +98 -0
- package/lib/sessionLog.js +70 -0
- package/lib/tab.js +133 -22
- package/lib/tools/common.js +11 -23
- package/lib/tools/console.js +4 -15
- package/lib/tools/dialogs.js +12 -17
- package/lib/tools/evaluate.js +12 -21
- package/lib/tools/files.js +10 -16
- package/lib/tools/install.js +3 -7
- package/lib/tools/keyboard.js +30 -42
- package/lib/tools/mouse.js +27 -50
- package/lib/tools/navigate.js +15 -35
- package/lib/tools/network.js +5 -15
- package/lib/tools/pdf.js +8 -15
- package/lib/tools/screenshot.js +29 -30
- package/lib/tools/snapshot.js +45 -65
- package/lib/tools/tabs.js +10 -41
- package/lib/tools/tool.js +14 -0
- package/lib/tools/utils.js +2 -2
- package/lib/tools/wait.js +3 -6
- package/lib/tools.js +3 -0
- package/package.json +9 -3
- package/lib/connection.js +0 -81
- package/lib/pageSnapshot.js +0 -43
- package/lib/server.js +0 -48
|
@@ -0,0 +1,370 @@
|
|
|
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 { spawn } from 'child_process';
|
|
17
|
+
import { WebSocket, WebSocketServer } from 'ws';
|
|
18
|
+
import debug from 'debug';
|
|
19
|
+
import * as playwright from 'playwright';
|
|
20
|
+
// @ts-ignore
|
|
21
|
+
const { registry } = await import('playwright-core/lib/server/registry/index');
|
|
22
|
+
import { httpAddressToString, startHttpServer } from '../httpServer.js';
|
|
23
|
+
import { logUnhandledError } from '../log.js';
|
|
24
|
+
import { ManualPromise } from '../manualPromise.js';
|
|
25
|
+
const debugLogger = debug('pw:mcp:relay');
|
|
26
|
+
export class CDPRelayServer {
|
|
27
|
+
_wsHost;
|
|
28
|
+
_browserChannel;
|
|
29
|
+
_cdpPath;
|
|
30
|
+
_extensionPath;
|
|
31
|
+
_wss;
|
|
32
|
+
_playwrightConnection = null;
|
|
33
|
+
_extensionConnection = null;
|
|
34
|
+
_connectedTabInfo;
|
|
35
|
+
_nextSessionId = 1;
|
|
36
|
+
_extensionConnectionPromise;
|
|
37
|
+
constructor(server, browserChannel) {
|
|
38
|
+
this._wsHost = httpAddressToString(server.address()).replace(/^http/, 'ws');
|
|
39
|
+
this._browserChannel = browserChannel;
|
|
40
|
+
const uuid = crypto.randomUUID();
|
|
41
|
+
this._cdpPath = `/cdp/${uuid}`;
|
|
42
|
+
this._extensionPath = `/extension/${uuid}`;
|
|
43
|
+
this._resetExtensionConnection();
|
|
44
|
+
this._wss = new WebSocketServer({ server });
|
|
45
|
+
this._wss.on('connection', this._onConnection.bind(this));
|
|
46
|
+
}
|
|
47
|
+
cdpEndpoint() {
|
|
48
|
+
return `${this._wsHost}${this._cdpPath}`;
|
|
49
|
+
}
|
|
50
|
+
extensionEndpoint() {
|
|
51
|
+
return `${this._wsHost}${this._extensionPath}`;
|
|
52
|
+
}
|
|
53
|
+
async ensureExtensionConnectionForMCPContext(clientInfo) {
|
|
54
|
+
debugLogger('Ensuring extension connection for MCP context');
|
|
55
|
+
if (this._extensionConnection)
|
|
56
|
+
return;
|
|
57
|
+
await this._connectBrowser(clientInfo);
|
|
58
|
+
debugLogger('Waiting for incoming extension connection');
|
|
59
|
+
await this._extensionConnectionPromise;
|
|
60
|
+
debugLogger('Extension connection established');
|
|
61
|
+
}
|
|
62
|
+
async _connectBrowser(clientInfo) {
|
|
63
|
+
const mcpRelayEndpoint = `${this._wsHost}${this._extensionPath}`;
|
|
64
|
+
// Need to specify "key" in the manifest.json to make the id stable when loading from file.
|
|
65
|
+
const url = new URL('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html');
|
|
66
|
+
url.searchParams.set('mcpRelayUrl', mcpRelayEndpoint);
|
|
67
|
+
url.searchParams.set('client', JSON.stringify(clientInfo));
|
|
68
|
+
const href = url.toString();
|
|
69
|
+
const executableInfo = registry.findExecutable(this._browserChannel);
|
|
70
|
+
if (!executableInfo)
|
|
71
|
+
throw new Error(`Unsupported channel: "${this._browserChannel}"`);
|
|
72
|
+
const executablePath = executableInfo.executablePath();
|
|
73
|
+
if (!executablePath)
|
|
74
|
+
throw new Error(`"${this._browserChannel}" executable not found. Make sure it is installed at a standard location.`);
|
|
75
|
+
spawn(executablePath, [href], {
|
|
76
|
+
windowsHide: true,
|
|
77
|
+
detached: true,
|
|
78
|
+
shell: false,
|
|
79
|
+
stdio: 'ignore',
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
stop() {
|
|
83
|
+
this.closeConnections('Server stopped');
|
|
84
|
+
this._wss.close();
|
|
85
|
+
}
|
|
86
|
+
closeConnections(reason) {
|
|
87
|
+
this._closePlaywrightConnection(reason);
|
|
88
|
+
this._closeExtensionConnection(reason);
|
|
89
|
+
}
|
|
90
|
+
_onConnection(ws, request) {
|
|
91
|
+
const url = new URL(`http://localhost${request.url}`);
|
|
92
|
+
debugLogger(`New connection to ${url.pathname}`);
|
|
93
|
+
if (url.pathname === this._cdpPath) {
|
|
94
|
+
this._handlePlaywrightConnection(ws);
|
|
95
|
+
}
|
|
96
|
+
else if (url.pathname === this._extensionPath) {
|
|
97
|
+
this._handleExtensionConnection(ws);
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
debugLogger(`Invalid path: ${url.pathname}`);
|
|
101
|
+
ws.close(4004, 'Invalid path');
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
_handlePlaywrightConnection(ws) {
|
|
105
|
+
if (this._playwrightConnection) {
|
|
106
|
+
debugLogger('Rejecting second Playwright connection');
|
|
107
|
+
ws.close(1000, 'Another CDP client already connected');
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
this._playwrightConnection = ws;
|
|
111
|
+
ws.on('message', async (data) => {
|
|
112
|
+
try {
|
|
113
|
+
const message = JSON.parse(data.toString());
|
|
114
|
+
await this._handlePlaywrightMessage(message);
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
debugLogger(`Error while handling Playwright message\n${data.toString()}\n`, error);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
ws.on('close', () => {
|
|
121
|
+
if (this._playwrightConnection !== ws)
|
|
122
|
+
return;
|
|
123
|
+
this._playwrightConnection = null;
|
|
124
|
+
this._closeExtensionConnection('Playwright client disconnected');
|
|
125
|
+
debugLogger('Playwright WebSocket closed');
|
|
126
|
+
});
|
|
127
|
+
ws.on('error', error => {
|
|
128
|
+
debugLogger('Playwright WebSocket error:', error);
|
|
129
|
+
});
|
|
130
|
+
debugLogger('Playwright MCP connected');
|
|
131
|
+
}
|
|
132
|
+
_closeExtensionConnection(reason) {
|
|
133
|
+
this._extensionConnection?.close(reason);
|
|
134
|
+
this._extensionConnectionPromise.reject(new Error(reason));
|
|
135
|
+
this._resetExtensionConnection();
|
|
136
|
+
}
|
|
137
|
+
_resetExtensionConnection() {
|
|
138
|
+
this._connectedTabInfo = undefined;
|
|
139
|
+
this._extensionConnection = null;
|
|
140
|
+
this._extensionConnectionPromise = new ManualPromise();
|
|
141
|
+
void this._extensionConnectionPromise.catch(logUnhandledError);
|
|
142
|
+
}
|
|
143
|
+
_closePlaywrightConnection(reason) {
|
|
144
|
+
if (this._playwrightConnection?.readyState === WebSocket.OPEN)
|
|
145
|
+
this._playwrightConnection.close(1000, reason);
|
|
146
|
+
this._playwrightConnection = null;
|
|
147
|
+
}
|
|
148
|
+
_handleExtensionConnection(ws) {
|
|
149
|
+
if (this._extensionConnection) {
|
|
150
|
+
ws.close(1000, 'Another extension connection already established');
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
this._extensionConnection = new ExtensionConnection(ws);
|
|
154
|
+
this._extensionConnection.onclose = (c, reason) => {
|
|
155
|
+
debugLogger('Extension WebSocket closed:', reason, c === this._extensionConnection);
|
|
156
|
+
if (this._extensionConnection !== c)
|
|
157
|
+
return;
|
|
158
|
+
this._resetExtensionConnection();
|
|
159
|
+
this._closePlaywrightConnection(`Extension disconnected: ${reason}`);
|
|
160
|
+
};
|
|
161
|
+
this._extensionConnection.onmessage = this._handleExtensionMessage.bind(this);
|
|
162
|
+
this._extensionConnectionPromise.resolve();
|
|
163
|
+
}
|
|
164
|
+
_handleExtensionMessage(method, params) {
|
|
165
|
+
switch (method) {
|
|
166
|
+
case 'forwardCDPEvent':
|
|
167
|
+
const sessionId = params.sessionId || this._connectedTabInfo?.sessionId;
|
|
168
|
+
this._sendToPlaywright({
|
|
169
|
+
sessionId,
|
|
170
|
+
method: params.method,
|
|
171
|
+
params: params.params
|
|
172
|
+
});
|
|
173
|
+
break;
|
|
174
|
+
case 'detachedFromTab':
|
|
175
|
+
debugLogger('← Debugger detached from tab:', params);
|
|
176
|
+
this._connectedTabInfo = undefined;
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
async _handlePlaywrightMessage(message) {
|
|
181
|
+
debugLogger('← Playwright:', `${message.method} (id=${message.id})`);
|
|
182
|
+
const { id, sessionId, method, params } = message;
|
|
183
|
+
try {
|
|
184
|
+
const result = await this._handleCDPCommand(method, params, sessionId);
|
|
185
|
+
this._sendToPlaywright({ id, sessionId, result });
|
|
186
|
+
}
|
|
187
|
+
catch (e) {
|
|
188
|
+
debugLogger('Error in the extension:', e);
|
|
189
|
+
this._sendToPlaywright({
|
|
190
|
+
id,
|
|
191
|
+
sessionId,
|
|
192
|
+
error: { message: e.message }
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
async _handleCDPCommand(method, params, sessionId) {
|
|
197
|
+
switch (method) {
|
|
198
|
+
case 'Browser.getVersion': {
|
|
199
|
+
return {
|
|
200
|
+
protocolVersion: '1.3',
|
|
201
|
+
product: 'Chrome/Extension-Bridge',
|
|
202
|
+
userAgent: 'CDP-Bridge-Server/1.0.0',
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
case 'Browser.setDownloadBehavior': {
|
|
206
|
+
return {};
|
|
207
|
+
}
|
|
208
|
+
case 'Target.setAutoAttach': {
|
|
209
|
+
// Forward child session handling.
|
|
210
|
+
if (sessionId)
|
|
211
|
+
break;
|
|
212
|
+
// Simulate auto-attach behavior with real target info
|
|
213
|
+
const { targetInfo } = await this._extensionConnection.send('attachToTab');
|
|
214
|
+
this._connectedTabInfo = {
|
|
215
|
+
targetInfo,
|
|
216
|
+
sessionId: `pw-tab-${this._nextSessionId++}`,
|
|
217
|
+
};
|
|
218
|
+
debugLogger('Simulating auto-attach');
|
|
219
|
+
this._sendToPlaywright({
|
|
220
|
+
method: 'Target.attachedToTarget',
|
|
221
|
+
params: {
|
|
222
|
+
sessionId: this._connectedTabInfo.sessionId,
|
|
223
|
+
targetInfo: {
|
|
224
|
+
...this._connectedTabInfo.targetInfo,
|
|
225
|
+
attached: true,
|
|
226
|
+
},
|
|
227
|
+
waitingForDebugger: false
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
return {};
|
|
231
|
+
}
|
|
232
|
+
case 'Target.getTargetInfo': {
|
|
233
|
+
return this._connectedTabInfo?.targetInfo;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return await this._forwardToExtension(method, params, sessionId);
|
|
237
|
+
}
|
|
238
|
+
async _forwardToExtension(method, params, sessionId) {
|
|
239
|
+
if (!this._extensionConnection)
|
|
240
|
+
throw new Error('Extension not connected');
|
|
241
|
+
// Top level sessionId is only passed between the relay and the client.
|
|
242
|
+
if (this._connectedTabInfo?.sessionId === sessionId)
|
|
243
|
+
sessionId = undefined;
|
|
244
|
+
return await this._extensionConnection.send('forwardCDPCommand', { sessionId, method, params });
|
|
245
|
+
}
|
|
246
|
+
_sendToPlaywright(message) {
|
|
247
|
+
debugLogger('→ Playwright:', `${message.method ?? `response(id=${message.id})`}`);
|
|
248
|
+
this._playwrightConnection?.send(JSON.stringify(message));
|
|
249
|
+
}
|
|
250
|
+
}
|
|
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
|
+
class ExtensionConnection {
|
|
291
|
+
_ws;
|
|
292
|
+
_callbacks = new Map();
|
|
293
|
+
_lastId = 0;
|
|
294
|
+
onmessage;
|
|
295
|
+
onclose;
|
|
296
|
+
constructor(ws) {
|
|
297
|
+
this._ws = ws;
|
|
298
|
+
this._ws.on('message', this._onMessage.bind(this));
|
|
299
|
+
this._ws.on('close', this._onClose.bind(this));
|
|
300
|
+
this._ws.on('error', this._onError.bind(this));
|
|
301
|
+
}
|
|
302
|
+
async send(method, params, sessionId) {
|
|
303
|
+
if (this._ws.readyState !== WebSocket.OPEN)
|
|
304
|
+
throw new Error(`Unexpected WebSocket state: ${this._ws.readyState}`);
|
|
305
|
+
const id = ++this._lastId;
|
|
306
|
+
this._ws.send(JSON.stringify({ id, method, params, sessionId }));
|
|
307
|
+
const error = new Error(`Protocol error: ${method}`);
|
|
308
|
+
return new Promise((resolve, reject) => {
|
|
309
|
+
this._callbacks.set(id, { resolve, reject, error });
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
close(message) {
|
|
313
|
+
debugLogger('closing extension connection:', message);
|
|
314
|
+
if (this._ws.readyState === WebSocket.OPEN)
|
|
315
|
+
this._ws.close(1000, message);
|
|
316
|
+
}
|
|
317
|
+
_onMessage(event) {
|
|
318
|
+
const eventData = event.toString();
|
|
319
|
+
let parsedJson;
|
|
320
|
+
try {
|
|
321
|
+
parsedJson = JSON.parse(eventData);
|
|
322
|
+
}
|
|
323
|
+
catch (e) {
|
|
324
|
+
debugLogger(`<closing ws> Closing websocket due to malformed JSON. eventData=${eventData} e=${e?.message}`);
|
|
325
|
+
this._ws.close();
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
try {
|
|
329
|
+
this._handleParsedMessage(parsedJson);
|
|
330
|
+
}
|
|
331
|
+
catch (e) {
|
|
332
|
+
debugLogger(`<closing ws> Closing websocket due to failed onmessage callback. eventData=${eventData} e=${e?.message}`);
|
|
333
|
+
this._ws.close();
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
_handleParsedMessage(object) {
|
|
337
|
+
if (object.id && this._callbacks.has(object.id)) {
|
|
338
|
+
const callback = this._callbacks.get(object.id);
|
|
339
|
+
this._callbacks.delete(object.id);
|
|
340
|
+
if (object.error) {
|
|
341
|
+
const error = callback.error;
|
|
342
|
+
error.message = object.error;
|
|
343
|
+
callback.reject(error);
|
|
344
|
+
}
|
|
345
|
+
else {
|
|
346
|
+
callback.resolve(object.result);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
else if (object.id) {
|
|
350
|
+
debugLogger('← Extension: unexpected response', object);
|
|
351
|
+
}
|
|
352
|
+
else {
|
|
353
|
+
this.onmessage?.(object.method, object.params);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
_onClose(event) {
|
|
357
|
+
debugLogger(`<ws closed> code=${event.code} reason=${event.reason}`);
|
|
358
|
+
this._dispose();
|
|
359
|
+
this.onclose?.(this, event.reason);
|
|
360
|
+
}
|
|
361
|
+
_onError(event) {
|
|
362
|
+
debugLogger(`<ws error> message=${event.message} type=${event.type} target=${event.target}`);
|
|
363
|
+
this._dispose();
|
|
364
|
+
}
|
|
365
|
+
_dispose() {
|
|
366
|
+
for (const callback of this._callbacks.values())
|
|
367
|
+
callback.reject(new Error('WebSocket closed'));
|
|
368
|
+
this._callbacks.clear();
|
|
369
|
+
}
|
|
370
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
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 { startCDPRelayServer } from './cdpRelay.js';
|
|
17
|
+
import { BrowserServerBackend } from '../browserServerBackend.js';
|
|
18
|
+
import * as mcpTransport from '../mcp/transport.js';
|
|
19
|
+
export async function runWithExtension(config, abortController) {
|
|
20
|
+
const contextFactory = await startCDPRelayServer(config.browser.launchOptions.channel || 'chrome', abortController);
|
|
21
|
+
let backend;
|
|
22
|
+
const serverBackendFactory = () => {
|
|
23
|
+
if (backend)
|
|
24
|
+
throw new Error('Another MCP client is still connected. Only one connection at a time is allowed.');
|
|
25
|
+
backend = new BrowserServerBackend(config, contextFactory);
|
|
26
|
+
backend.onclose = () => {
|
|
27
|
+
contextFactory.clientDisconnected();
|
|
28
|
+
backend = undefined;
|
|
29
|
+
};
|
|
30
|
+
return backend;
|
|
31
|
+
};
|
|
32
|
+
await mcpTransport.start(serverBackendFactory, config.server);
|
|
33
|
+
}
|
package/lib/httpServer.js
CHANGED
|
@@ -13,189 +13,27 @@
|
|
|
13
13
|
* See the License for the specific language governing permissions and
|
|
14
14
|
* limitations under the License.
|
|
15
15
|
*/
|
|
16
|
-
import
|
|
17
|
-
import path from 'path';
|
|
16
|
+
import assert from 'assert';
|
|
18
17
|
import http from 'http';
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
constructor() {
|
|
28
|
-
this._server = http.createServer(this._onRequest.bind(this));
|
|
29
|
-
decorateServer(this._server);
|
|
30
|
-
}
|
|
31
|
-
server() {
|
|
32
|
-
return this._server;
|
|
33
|
-
}
|
|
34
|
-
routePrefix(prefix, handler) {
|
|
35
|
-
this._routes.push({ prefix, handler });
|
|
36
|
-
}
|
|
37
|
-
routePath(path, handler) {
|
|
38
|
-
this._routes.push({ exact: path, handler });
|
|
39
|
-
}
|
|
40
|
-
port() {
|
|
41
|
-
return this._port;
|
|
42
|
-
}
|
|
43
|
-
async _tryStart(port, host) {
|
|
44
|
-
const errorPromise = new ManualPromise();
|
|
45
|
-
const errorListener = (error) => errorPromise.reject(error);
|
|
46
|
-
this._server.on('error', errorListener);
|
|
47
|
-
try {
|
|
48
|
-
this._server.listen(port, host);
|
|
49
|
-
await Promise.race([
|
|
50
|
-
new Promise(cb => this._server.once('listening', cb)),
|
|
51
|
-
errorPromise,
|
|
52
|
-
]);
|
|
53
|
-
}
|
|
54
|
-
finally {
|
|
55
|
-
this._server.removeListener('error', errorListener);
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
async start(options = {}) {
|
|
59
|
-
const host = options.host || 'localhost';
|
|
60
|
-
if (options.preferredPort) {
|
|
61
|
-
try {
|
|
62
|
-
await this._tryStart(options.preferredPort, host);
|
|
63
|
-
}
|
|
64
|
-
catch (e) {
|
|
65
|
-
if (!e || !e.message || !e.message.includes('EADDRINUSE'))
|
|
66
|
-
throw e;
|
|
67
|
-
await this._tryStart(undefined, host);
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
else {
|
|
71
|
-
await this._tryStart(options.port, host);
|
|
72
|
-
}
|
|
73
|
-
const address = this._server.address();
|
|
74
|
-
if (typeof address === 'string') {
|
|
75
|
-
this._urlPrefixPrecise = address;
|
|
76
|
-
this._urlPrefixHumanReadable = address;
|
|
77
|
-
}
|
|
78
|
-
else {
|
|
79
|
-
this._port = address.port;
|
|
80
|
-
const resolvedHost = address.family === 'IPv4' ? address.address : `[${address.address}]`;
|
|
81
|
-
this._urlPrefixPrecise = `http://${resolvedHost}:${address.port}`;
|
|
82
|
-
this._urlPrefixHumanReadable = `http://${host}:${address.port}`;
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
async stop() {
|
|
86
|
-
await new Promise(cb => this._server.close(cb));
|
|
87
|
-
}
|
|
88
|
-
urlPrefix(purpose) {
|
|
89
|
-
return purpose === 'human-readable' ? this._urlPrefixHumanReadable : this._urlPrefixPrecise;
|
|
90
|
-
}
|
|
91
|
-
serveFile(request, response, absoluteFilePath, headers) {
|
|
92
|
-
try {
|
|
93
|
-
for (const [name, value] of Object.entries(headers || {}))
|
|
94
|
-
response.setHeader(name, value);
|
|
95
|
-
if (request.headers.range)
|
|
96
|
-
this._serveRangeFile(request, response, absoluteFilePath);
|
|
97
|
-
else
|
|
98
|
-
this._serveFile(response, absoluteFilePath);
|
|
99
|
-
return true;
|
|
100
|
-
}
|
|
101
|
-
catch (e) {
|
|
102
|
-
return false;
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
_serveFile(response, absoluteFilePath) {
|
|
106
|
-
const content = fs.readFileSync(absoluteFilePath);
|
|
107
|
-
response.statusCode = 200;
|
|
108
|
-
const contentType = mime.getType(path.extname(absoluteFilePath)) || 'application/octet-stream';
|
|
109
|
-
response.setHeader('Content-Type', contentType);
|
|
110
|
-
response.setHeader('Content-Length', content.byteLength);
|
|
111
|
-
response.end(content);
|
|
112
|
-
}
|
|
113
|
-
_serveRangeFile(request, response, absoluteFilePath) {
|
|
114
|
-
const range = request.headers.range;
|
|
115
|
-
if (!range || !range.startsWith('bytes=') || range.includes(', ') || [...range].filter(char => char === '-').length !== 1) {
|
|
116
|
-
response.statusCode = 400;
|
|
117
|
-
return response.end('Bad request');
|
|
118
|
-
}
|
|
119
|
-
// Parse the range header: https://datatracker.ietf.org/doc/html/rfc7233#section-2.1
|
|
120
|
-
const [startStr, endStr] = range.replace(/bytes=/, '').split('-');
|
|
121
|
-
// Both start and end (when passing to fs.createReadStream) and the range header are inclusive and start counting at 0.
|
|
122
|
-
let start;
|
|
123
|
-
let end;
|
|
124
|
-
const size = fs.statSync(absoluteFilePath).size;
|
|
125
|
-
if (startStr !== '' && endStr === '') {
|
|
126
|
-
// No end specified: use the whole file
|
|
127
|
-
start = +startStr;
|
|
128
|
-
end = size - 1;
|
|
129
|
-
}
|
|
130
|
-
else if (startStr === '' && endStr !== '') {
|
|
131
|
-
// No start specified: calculate start manually
|
|
132
|
-
start = size - +endStr;
|
|
133
|
-
end = size - 1;
|
|
134
|
-
}
|
|
135
|
-
else {
|
|
136
|
-
start = +startStr;
|
|
137
|
-
end = +endStr;
|
|
138
|
-
}
|
|
139
|
-
// Handle unavailable range request
|
|
140
|
-
if (Number.isNaN(start) || Number.isNaN(end) || start >= size || end >= size || start > end) {
|
|
141
|
-
// Return the 416 Range Not Satisfiable: https://datatracker.ietf.org/doc/html/rfc7233#section-4.4
|
|
142
|
-
response.writeHead(416, {
|
|
143
|
-
'Content-Range': `bytes */${size}`
|
|
144
|
-
});
|
|
145
|
-
return response.end();
|
|
146
|
-
}
|
|
147
|
-
// Sending Partial Content: https://datatracker.ietf.org/doc/html/rfc7233#section-4.1
|
|
148
|
-
response.writeHead(206, {
|
|
149
|
-
'Content-Range': `bytes ${start}-${end}/${size}`,
|
|
150
|
-
'Accept-Ranges': 'bytes',
|
|
151
|
-
'Content-Length': end - start + 1,
|
|
152
|
-
'Content-Type': mime.getType(path.extname(absoluteFilePath)),
|
|
18
|
+
export async function startHttpServer(config) {
|
|
19
|
+
const { host, port } = config;
|
|
20
|
+
const httpServer = http.createServer();
|
|
21
|
+
await new Promise((resolve, reject) => {
|
|
22
|
+
httpServer.on('error', reject);
|
|
23
|
+
httpServer.listen(port, host, () => {
|
|
24
|
+
resolve();
|
|
25
|
+
httpServer.removeListener('error', reject);
|
|
153
26
|
});
|
|
154
|
-
const readable = fs.createReadStream(absoluteFilePath, { start, end });
|
|
155
|
-
readable.pipe(response);
|
|
156
|
-
}
|
|
157
|
-
_onRequest(request, response) {
|
|
158
|
-
if (request.method === 'OPTIONS') {
|
|
159
|
-
response.writeHead(200);
|
|
160
|
-
response.end();
|
|
161
|
-
return;
|
|
162
|
-
}
|
|
163
|
-
request.on('error', () => response.end());
|
|
164
|
-
try {
|
|
165
|
-
if (!request.url) {
|
|
166
|
-
response.end();
|
|
167
|
-
return;
|
|
168
|
-
}
|
|
169
|
-
const url = new URL('http://localhost' + request.url);
|
|
170
|
-
for (const route of this._routes) {
|
|
171
|
-
if (route.exact && url.pathname === route.exact) {
|
|
172
|
-
route.handler(request, response);
|
|
173
|
-
return;
|
|
174
|
-
}
|
|
175
|
-
if (route.prefix && url.pathname.startsWith(route.prefix)) {
|
|
176
|
-
route.handler(request, response);
|
|
177
|
-
return;
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
response.statusCode = 404;
|
|
181
|
-
response.end();
|
|
182
|
-
}
|
|
183
|
-
catch (e) {
|
|
184
|
-
response.end();
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
function decorateServer(server) {
|
|
189
|
-
const sockets = new Set();
|
|
190
|
-
server.on('connection', socket => {
|
|
191
|
-
sockets.add(socket);
|
|
192
|
-
socket.once('close', () => sockets.delete(socket));
|
|
193
27
|
});
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
return
|
|
200
|
-
|
|
28
|
+
return httpServer;
|
|
29
|
+
}
|
|
30
|
+
export function httpAddressToString(address) {
|
|
31
|
+
assert(address, 'Could not bind server socket');
|
|
32
|
+
if (typeof address === 'string')
|
|
33
|
+
return address;
|
|
34
|
+
const resolvedPort = address.port;
|
|
35
|
+
let resolvedHost = address.family === 'IPv4' ? address.address : `[${address.address}]`;
|
|
36
|
+
if (resolvedHost === '0.0.0.0' || resolvedHost === '[::]')
|
|
37
|
+
resolvedHost = 'localhost';
|
|
38
|
+
return `http://${resolvedHost}:${resolvedPort}`;
|
|
201
39
|
}
|
package/lib/index.js
CHANGED
|
@@ -13,13 +13,14 @@
|
|
|
13
13
|
* See the License for the specific language governing permissions and
|
|
14
14
|
* limitations under the License.
|
|
15
15
|
*/
|
|
16
|
-
import {
|
|
16
|
+
import { BrowserServerBackend } from './browserServerBackend.js';
|
|
17
17
|
import { resolveConfig } from './config.js';
|
|
18
18
|
import { contextFactory } from './browserContextFactory.js';
|
|
19
|
+
import * as mcpServer from './mcp/server.js';
|
|
19
20
|
export async function createConnection(userConfig = {}, contextGetter) {
|
|
20
21
|
const config = await resolveConfig(userConfig);
|
|
21
22
|
const factory = contextGetter ? new SimpleBrowserContextFactory(contextGetter) : contextFactory(config.browser);
|
|
22
|
-
return
|
|
23
|
+
return mcpServer.createServer(new BrowserServerBackend(config, factory), false);
|
|
23
24
|
}
|
|
24
25
|
class SimpleBrowserContextFactory {
|
|
25
26
|
_contextGetter;
|