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.
@@ -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
- * Retry operation with exponential backoff
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} for ${operationName}...`);
154
+ this.log(`Reconnect attempt ${attemptNum}/${maxAttempts}...`);
62
155
  // Exponential backoff with max delay
63
- const delay = Math.min(initialDelay * Math.pow(2, attempt), maxDelay);
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, retrying ${operationName}...`);
68
- return await operation();
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
- const errorMessage = error?.message || '';
85
- return (errorMessage.includes('Target closed') ||
86
- errorMessage.includes('Protocol error') ||
87
- errorMessage.includes('Session closed') ||
88
- errorMessage.includes('Connection closed') ||
89
- errorMessage.includes('WebSocket is not open'));
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.13.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",