chrome-devtools-mcp-for-extension 0.13.0 → 0.14.1
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/build/src/McpContext.js
CHANGED
|
@@ -44,6 +44,9 @@ export class McpContext {
|
|
|
44
44
|
#dialog;
|
|
45
45
|
#nextSnapshotId = 1;
|
|
46
46
|
#traceResults = [];
|
|
47
|
+
// CDP Session management (v0.8.4+)
|
|
48
|
+
#browserSession;
|
|
49
|
+
#pageSessions = new Map();
|
|
47
50
|
constructor(browser, logger, browserFactory, connectionOptions) {
|
|
48
51
|
this.browser = browser;
|
|
49
52
|
this.logger = logger;
|
|
@@ -77,12 +80,90 @@ export class McpContext {
|
|
|
77
80
|
await context.#init();
|
|
78
81
|
return context;
|
|
79
82
|
}
|
|
83
|
+
/**
|
|
84
|
+
* Dispose all CDP sessions to prevent memory leaks
|
|
85
|
+
*/
|
|
86
|
+
async #disposeSessions() {
|
|
87
|
+
await Promise.allSettled([
|
|
88
|
+
this.#browserSession?.detach(),
|
|
89
|
+
...[...this.#pageSessions.values()].map(s => s.detach())
|
|
90
|
+
]);
|
|
91
|
+
this.#browserSession = undefined;
|
|
92
|
+
this.#pageSessions.clear();
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Reinitialize CDP protocol domains after reconnection
|
|
96
|
+
* This ensures proper CDP event handling after browser restart
|
|
97
|
+
*/
|
|
98
|
+
async reinitializeCDP() {
|
|
99
|
+
try {
|
|
100
|
+
// 1. Dispose old sessions to prevent leaks
|
|
101
|
+
await this.#disposeSessions();
|
|
102
|
+
// 2. Create browser-level CDP session (public API, not _client())
|
|
103
|
+
this.#browserSession = await this.browser.target().createCDPSession();
|
|
104
|
+
this.logger('CDP: Browser-level session created');
|
|
105
|
+
// 3. Enable Target discovery and auto-attach
|
|
106
|
+
try {
|
|
107
|
+
await this.#browserSession.send('Target.setDiscoverTargets', { discover: true });
|
|
108
|
+
this.logger('CDP: Target discovery enabled');
|
|
109
|
+
}
|
|
110
|
+
catch (err) {
|
|
111
|
+
this.logger('Warning: Failed to enable target discovery:', err);
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
await this.#browserSession.send('Target.setAutoAttach', {
|
|
115
|
+
autoAttach: true,
|
|
116
|
+
waitForDebuggerOnStart: false,
|
|
117
|
+
flatten: true
|
|
118
|
+
});
|
|
119
|
+
this.logger('CDP: Auto-attach configured');
|
|
120
|
+
}
|
|
121
|
+
catch (err) {
|
|
122
|
+
this.logger('Warning: Failed to configure auto-attach:', err);
|
|
123
|
+
}
|
|
124
|
+
// 4. Enable essential CDP domains for all targets
|
|
125
|
+
for (const target of this.browser.targets()) {
|
|
126
|
+
const type = target.type();
|
|
127
|
+
if (type === 'page') {
|
|
128
|
+
const page = await target.page();
|
|
129
|
+
if (!page)
|
|
130
|
+
continue;
|
|
131
|
+
try {
|
|
132
|
+
const session = await page.createCDPSession();
|
|
133
|
+
await Promise.allSettled([
|
|
134
|
+
session.send('Network.enable'),
|
|
135
|
+
session.send('Runtime.enable'),
|
|
136
|
+
session.send('Log.enable'),
|
|
137
|
+
]);
|
|
138
|
+
this.#pageSessions.set(page, session);
|
|
139
|
+
this.logger(`CDP domains enabled for ${type}: ${page.url()}`);
|
|
140
|
+
}
|
|
141
|
+
catch (err) {
|
|
142
|
+
this.logger(`Warning: Failed to enable CDP domains for ${type} ${page.url()}:`, err);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
// Service Worker / Worker support can be added here if needed
|
|
146
|
+
// if (type === 'service_worker' || type === 'worker') { ... }
|
|
147
|
+
}
|
|
148
|
+
this.logger('CDP protocol reinitialization completed');
|
|
149
|
+
}
|
|
150
|
+
catch (err) {
|
|
151
|
+
this.logger('Error during CDP reinitialization:', err);
|
|
152
|
+
// Don't throw - this is a best-effort operation
|
|
153
|
+
// The browser should still be usable even if CDP setup partially fails
|
|
154
|
+
}
|
|
155
|
+
}
|
|
80
156
|
/**
|
|
81
157
|
* Update browser instance after reconnection
|
|
82
158
|
*/
|
|
83
159
|
async updateBrowser(newBrowser) {
|
|
160
|
+
// Dispose old sessions first
|
|
161
|
+
await this.#disposeSessions();
|
|
84
162
|
this.browser = newBrowser;
|
|
85
163
|
this.connectionManager.setBrowser(newBrowser, async () => newBrowser);
|
|
164
|
+
// Reinitialize CDP protocol domains BEFORE collectors
|
|
165
|
+
// This ensures CDP events are properly set up
|
|
166
|
+
await this.reinitializeCDP();
|
|
86
167
|
// Reinitialize collectors for new browser
|
|
87
168
|
await this.#networkCollector.init();
|
|
88
169
|
await this.#consoleCollector.init();
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* Copyright 2025 Google LLC
|
|
4
4
|
* SPDX-License-Identifier: Apache-2.0
|
|
5
5
|
*/
|
|
6
|
+
import { ProtocolError, TimeoutError } from 'puppeteer';
|
|
6
7
|
/**
|
|
7
8
|
* Default connection manager options
|
|
8
9
|
*/
|
|
@@ -10,28 +11,66 @@ const DEFAULT_OPTIONS = {
|
|
|
10
11
|
maxReconnectAttempts: 3,
|
|
11
12
|
initialRetryDelay: 1000, // 1 second
|
|
12
13
|
maxRetryDelay: 10000, // 10 seconds
|
|
14
|
+
reconnectOverallTimeoutMs: 30000, // 30 seconds
|
|
13
15
|
enableLogging: true,
|
|
14
16
|
onReconnect: undefined,
|
|
15
17
|
};
|
|
18
|
+
/**
|
|
19
|
+
* Connection state enum
|
|
20
|
+
*/
|
|
21
|
+
var ConnectionState;
|
|
22
|
+
(function (ConnectionState) {
|
|
23
|
+
ConnectionState["CONNECTED"] = "CONNECTED";
|
|
24
|
+
ConnectionState["RECONNECTING"] = "RECONNECTING";
|
|
25
|
+
ConnectionState["CLOSED"] = "CLOSED";
|
|
26
|
+
})(ConnectionState || (ConnectionState = {}));
|
|
16
27
|
/**
|
|
17
28
|
* Browser Connection Manager
|
|
18
29
|
*
|
|
19
|
-
* Provides automatic reconnection for CDP operations
|
|
30
|
+
* Provides automatic reconnection for CDP operations with:
|
|
31
|
+
* - Single-flight pattern to prevent concurrent reconnections
|
|
32
|
+
* - Event-driven detection via browser 'disconnected' event
|
|
33
|
+
* - State machine tracking (CONNECTED | RECONNECTING | CLOSED)
|
|
34
|
+
* - Exponential backoff with jitter to prevent thundering herd
|
|
20
35
|
*/
|
|
21
36
|
export class BrowserConnectionManager {
|
|
22
37
|
reconnectAttempts = 0;
|
|
23
38
|
options;
|
|
24
39
|
browser = null;
|
|
25
40
|
browserFactory = null;
|
|
41
|
+
/** Single-flight pattern: prevents concurrent reconnection attempts */
|
|
42
|
+
reconnectInFlight = null;
|
|
43
|
+
/** Shared reconnection sequence for multiple operations */
|
|
44
|
+
reconnectSequenceInFlight = null;
|
|
45
|
+
/** Current connection state */
|
|
46
|
+
state = ConnectionState.CLOSED;
|
|
47
|
+
/** Disconnected event handler (arrow function to preserve 'this') */
|
|
48
|
+
onDisconnected = () => {
|
|
49
|
+
this.log('Browser disconnected');
|
|
50
|
+
this.setState(ConnectionState.RECONNECTING);
|
|
51
|
+
// Trigger immediate reconnection (single-flight prevents duplicates)
|
|
52
|
+
void this.triggerReconnect('event:disconnected');
|
|
53
|
+
};
|
|
26
54
|
constructor(options = {}) {
|
|
27
55
|
this.options = { ...DEFAULT_OPTIONS, ...options };
|
|
28
56
|
}
|
|
29
57
|
/**
|
|
30
58
|
* Set browser instance and factory for reconnection
|
|
59
|
+
*
|
|
60
|
+
* @param browser - Browser instance to manage
|
|
61
|
+
* @param factory - Factory function to create new browser instances on reconnection
|
|
31
62
|
*/
|
|
32
63
|
setBrowser(browser, factory) {
|
|
64
|
+
// Remove old listener to prevent memory leak
|
|
65
|
+
if (this.browser) {
|
|
66
|
+
this.browser.off('disconnected', this.onDisconnected);
|
|
67
|
+
}
|
|
33
68
|
this.browser = browser;
|
|
34
69
|
this.browserFactory = factory;
|
|
70
|
+
this.state = ConnectionState.CONNECTED;
|
|
71
|
+
// Event-driven detection: hook into browser 'disconnected' event
|
|
72
|
+
this.browser.on('disconnected', this.onDisconnected);
|
|
73
|
+
this.log('Browser instance set, state: CONNECTED');
|
|
35
74
|
}
|
|
36
75
|
/**
|
|
37
76
|
* Execute an operation with automatic retry on CDP connection errors
|
|
@@ -49,23 +88,85 @@ export class BrowserConnectionManager {
|
|
|
49
88
|
}
|
|
50
89
|
}
|
|
51
90
|
/**
|
|
52
|
-
*
|
|
91
|
+
* Trigger reconnection sequence
|
|
92
|
+
*
|
|
93
|
+
* Can be called from events (disconnected) or operations (executeWithRetry).
|
|
94
|
+
* Uses single-flight pattern to prevent duplicate reconnections.
|
|
95
|
+
*
|
|
96
|
+
* @param reason - Reason for triggering reconnection
|
|
97
|
+
* @returns Promise that resolves when reconnection completes
|
|
98
|
+
*/
|
|
99
|
+
async triggerReconnect(reason) {
|
|
100
|
+
// Single-flight: return existing sequence if already running
|
|
101
|
+
if (this.reconnectSequenceInFlight) {
|
|
102
|
+
this.log(`Reconnection already in progress (${reason}), waiting...`);
|
|
103
|
+
return this.reconnectSequenceInFlight;
|
|
104
|
+
}
|
|
105
|
+
this.log(`Triggering reconnection: ${reason}`);
|
|
106
|
+
// Overall timeout with AbortController
|
|
107
|
+
const abortController = new AbortController();
|
|
108
|
+
const overallTimeout = this.options.reconnectOverallTimeoutMs ?? 30000;
|
|
109
|
+
const timeoutId = setTimeout(() => {
|
|
110
|
+
abortController.abort();
|
|
111
|
+
this.log(`Reconnection overall timeout (${overallTimeout}ms) exceeded`);
|
|
112
|
+
}, overallTimeout);
|
|
113
|
+
// Start reconnection sequence
|
|
114
|
+
this.reconnectSequenceInFlight = this._runReconnectionSequence(abortController.signal)
|
|
115
|
+
.finally(() => {
|
|
116
|
+
clearTimeout(timeoutId);
|
|
117
|
+
this.reconnectSequenceInFlight = null;
|
|
118
|
+
});
|
|
119
|
+
return this.reconnectSequenceInFlight;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Retry operation with automatic reconnection
|
|
123
|
+
*
|
|
124
|
+
* @param operation - Operation to retry
|
|
125
|
+
* @param operationName - Name of operation for logging
|
|
126
|
+
* @returns Result of operation
|
|
53
127
|
*/
|
|
54
128
|
async retryWithReconnect(operation, operationName) {
|
|
129
|
+
// Use triggerReconnect for unified reconnection logic
|
|
130
|
+
await this.triggerReconnect(`operation:${operationName}`);
|
|
131
|
+
this.log(`${operationName}: Reconnection completed, retrying operation...`);
|
|
132
|
+
return await operation();
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Run a full reconnection sequence with exponential backoff
|
|
136
|
+
*
|
|
137
|
+
* This is the actual retry loop that multiple operations can share.
|
|
138
|
+
*
|
|
139
|
+
* @param signal - AbortSignal to cancel reconnection
|
|
140
|
+
* @private
|
|
141
|
+
*/
|
|
142
|
+
async _runReconnectionSequence(signal) {
|
|
55
143
|
const maxAttempts = this.options.maxReconnectAttempts ?? 3;
|
|
56
144
|
const initialDelay = this.options.initialRetryDelay ?? 1000;
|
|
57
145
|
const maxDelay = this.options.maxRetryDelay ?? 10000;
|
|
146
|
+
const random = this.options.rng ?? Math.random;
|
|
58
147
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
148
|
+
// Check abort signal
|
|
149
|
+
if (signal?.aborted) {
|
|
150
|
+
throw new Error('Reconnection aborted: overall timeout exceeded');
|
|
151
|
+
}
|
|
59
152
|
this.reconnectAttempts++;
|
|
60
153
|
const attemptNum = attempt + 1;
|
|
61
|
-
this.log(`Reconnect attempt ${attemptNum}/${maxAttempts}
|
|
154
|
+
this.log(`Reconnect attempt ${attemptNum}/${maxAttempts}...`);
|
|
62
155
|
// Exponential backoff with max delay
|
|
63
|
-
const
|
|
156
|
+
const baseDelay = Math.min(initialDelay * Math.pow(2, attempt), maxDelay);
|
|
157
|
+
// Add jitter: ±20% randomness to prevent thundering herd
|
|
158
|
+
const jitter = baseDelay * 0.2 * (random() * 2 - 1);
|
|
159
|
+
const delay = Math.max(0, baseDelay + jitter);
|
|
160
|
+
this.log(`Waiting ${delay.toFixed(0)}ms before reconnect attempt...`);
|
|
64
161
|
await this.sleep(delay);
|
|
162
|
+
// Check abort signal after sleep
|
|
163
|
+
if (signal?.aborted) {
|
|
164
|
+
throw new Error('Reconnection aborted: overall timeout exceeded');
|
|
165
|
+
}
|
|
65
166
|
try {
|
|
66
167
|
await this.reconnectBrowser();
|
|
67
|
-
this.log(`Reconnection successful
|
|
68
|
-
return
|
|
168
|
+
this.log(`Reconnection successful`);
|
|
169
|
+
return; // Success!
|
|
69
170
|
}
|
|
70
171
|
catch (error) {
|
|
71
172
|
if (attempt === maxAttempts - 1) {
|
|
@@ -79,22 +180,64 @@ export class BrowserConnectionManager {
|
|
|
79
180
|
}
|
|
80
181
|
/**
|
|
81
182
|
* Check if error is a CDP connection error
|
|
183
|
+
*
|
|
184
|
+
* Uses type-safe error detection with instanceof checks and falls back
|
|
185
|
+
* to string matching for compatibility. Also checks method hints.
|
|
186
|
+
*
|
|
187
|
+
* @param error - Error to check
|
|
188
|
+
* @returns true if error is a CDP connection error
|
|
82
189
|
*/
|
|
83
190
|
isCDPConnectionError(error) {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
191
|
+
// Type-safe detection using instanceof
|
|
192
|
+
if (error instanceof ProtocolError || error instanceof TimeoutError) {
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
const msg = String(error?.message ?? '').toLowerCase();
|
|
196
|
+
const method = error?.method?.toString?.().toLowerCase?.() ?? '';
|
|
197
|
+
// Message hints
|
|
198
|
+
if (/connection closed|session closed|target closed|websocket is not open/.test(msg)) {
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
// Method hints (CDP methods like 'Target.*')
|
|
202
|
+
if (/^target\./.test(method)) {
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
return false;
|
|
90
206
|
}
|
|
91
207
|
/**
|
|
92
|
-
* Reconnect browser instance
|
|
208
|
+
* Reconnect browser instance with single-flight pattern
|
|
209
|
+
*
|
|
210
|
+
* Ensures only one reconnection attempt happens at a time.
|
|
211
|
+
* Multiple concurrent calls will wait for the same reconnection promise.
|
|
212
|
+
*
|
|
213
|
+
* @returns Promise that resolves when reconnection is complete
|
|
93
214
|
*/
|
|
94
215
|
async reconnectBrowser() {
|
|
216
|
+
// Single-flight pattern: return existing promise if reconnection is already in progress
|
|
217
|
+
if (this.reconnectInFlight) {
|
|
218
|
+
this.log('Reconnection already in progress, waiting...');
|
|
219
|
+
return this.reconnectInFlight;
|
|
220
|
+
}
|
|
221
|
+
// Create new reconnection promise
|
|
222
|
+
this.reconnectInFlight = this._doReconnect();
|
|
223
|
+
try {
|
|
224
|
+
await this.reconnectInFlight;
|
|
225
|
+
}
|
|
226
|
+
finally {
|
|
227
|
+
// Clear in-flight promise when complete
|
|
228
|
+
this.reconnectInFlight = null;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Internal reconnection logic
|
|
233
|
+
*
|
|
234
|
+
* @private
|
|
235
|
+
*/
|
|
236
|
+
async _doReconnect() {
|
|
95
237
|
if (!this.browserFactory) {
|
|
96
238
|
throw new Error('Browser factory not set. Cannot reconnect.');
|
|
97
239
|
}
|
|
240
|
+
this.setState(ConnectionState.RECONNECTING);
|
|
98
241
|
try {
|
|
99
242
|
// Close old browser if still connected
|
|
100
243
|
if (this.browser?.isConnected()) {
|
|
@@ -109,6 +252,9 @@ export class BrowserConnectionManager {
|
|
|
109
252
|
// Create new browser instance
|
|
110
253
|
const newBrowser = await this.browserFactory();
|
|
111
254
|
this.browser = newBrowser;
|
|
255
|
+
// Re-attach disconnected event handler
|
|
256
|
+
this.browser.on('disconnected', this.onDisconnected);
|
|
257
|
+
this.setState(ConnectionState.CONNECTED);
|
|
112
258
|
this.log('Browser reconnected successfully');
|
|
113
259
|
// Notify callback if provided
|
|
114
260
|
if (this.options.onReconnect) {
|
|
@@ -138,8 +284,21 @@ ${lastError?.message || 'Unknown error'}
|
|
|
138
284
|
error.name = 'CDPReconnectionError';
|
|
139
285
|
return error;
|
|
140
286
|
}
|
|
287
|
+
/**
|
|
288
|
+
* Set connection state and log transition
|
|
289
|
+
*
|
|
290
|
+
* @param newState - New connection state
|
|
291
|
+
*/
|
|
292
|
+
setState(newState) {
|
|
293
|
+
if (this.state !== newState) {
|
|
294
|
+
this.log(`State transition: ${this.state} -> ${newState}`);
|
|
295
|
+
this.state = newState;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
141
298
|
/**
|
|
142
299
|
* Log message if logging is enabled
|
|
300
|
+
*
|
|
301
|
+
* @param message - Message to log
|
|
143
302
|
*/
|
|
144
303
|
log(message) {
|
|
145
304
|
if (this.options.enableLogging !== false) {
|
|
@@ -172,10 +331,28 @@ ${lastError?.message || 'Unknown error'}
|
|
|
172
331
|
}
|
|
173
332
|
/**
|
|
174
333
|
* Check if browser is currently connected
|
|
334
|
+
*
|
|
335
|
+
* @returns true if browser is connected
|
|
175
336
|
*/
|
|
176
337
|
isConnected() {
|
|
177
338
|
return this.browser?.isConnected() ?? false;
|
|
178
339
|
}
|
|
340
|
+
/**
|
|
341
|
+
* Get current connection state
|
|
342
|
+
*
|
|
343
|
+
* @returns Current connection state (CONNECTED | RECONNECTING | CLOSED)
|
|
344
|
+
*/
|
|
345
|
+
getState() {
|
|
346
|
+
return this.state;
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Check if reconnection is currently in progress
|
|
350
|
+
*
|
|
351
|
+
* @returns true if reconnection is in progress
|
|
352
|
+
*/
|
|
353
|
+
isReconnecting() {
|
|
354
|
+
return this.state === ConnectionState.RECONNECTING;
|
|
355
|
+
}
|
|
179
356
|
}
|
|
180
357
|
/**
|
|
181
358
|
* Global connection manager instance
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "chrome-devtools-mcp-for-extension",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.1",
|
|
4
4
|
"description": "MCP server for Chrome extension development with Web Store automation. Fork of chrome-devtools-mcp with extension-specific tools.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": "./build/src/index.js",
|