chrome-devtools-mcp-for-extension 0.12.0 → 0.14.0

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.
@@ -10,6 +10,7 @@ import { NetworkCollector, PageCollector } from './PageCollector.js';
10
10
  import { listPages } from './tools/pages.js';
11
11
  import { CLOSE_PAGE_ERROR } from './tools/ToolDefinition.js';
12
12
  import { WaitForHelper } from './WaitForHelper.js';
13
+ import { BrowserConnectionManager, } from './browser-connection-manager.js';
13
14
  const DEFAULT_TIMEOUT = 5_000;
14
15
  const NAVIGATION_TIMEOUT = 10_000;
15
16
  function getNetworkMultiplierFromString(condition) {
@@ -29,6 +30,7 @@ function getNetworkMultiplierFromString(condition) {
29
30
  export class McpContext {
30
31
  browser;
31
32
  logger;
33
+ connectionManager;
32
34
  // The most recent page state.
33
35
  #pages = [];
34
36
  #selectedPageIdx = 0;
@@ -42,9 +44,14 @@ export class McpContext {
42
44
  #dialog;
43
45
  #nextSnapshotId = 1;
44
46
  #traceResults = [];
45
- constructor(browser, logger) {
47
+ constructor(browser, logger, browserFactory, connectionOptions) {
46
48
  this.browser = browser;
47
49
  this.logger = logger;
50
+ this.connectionManager = new BrowserConnectionManager(connectionOptions);
51
+ // Set up browser instance and factory for reconnection
52
+ if (browserFactory) {
53
+ this.connectionManager.setBrowser(browser, browserFactory);
54
+ }
48
55
  this.#networkCollector = new NetworkCollector(this.browser, (page, collect) => {
49
56
  page.on('request', request => {
50
57
  collect(request);
@@ -65,11 +72,87 @@ export class McpContext {
65
72
  await this.#networkCollector.init();
66
73
  await this.#consoleCollector.init();
67
74
  }
68
- static async from(browser, logger) {
69
- const context = new McpContext(browser, logger);
75
+ static async from(browser, logger, browserFactory, connectionOptions) {
76
+ const context = new McpContext(browser, logger, browserFactory, connectionOptions);
70
77
  await context.#init();
71
78
  return context;
72
79
  }
80
+ /**
81
+ * Reinitialize CDP protocol domains after reconnection
82
+ * This ensures proper CDP event handling after browser restart
83
+ */
84
+ async reinitializeCDP() {
85
+ try {
86
+ // Get CDP client from the browser
87
+ const client = await this.browser._client();
88
+ if (!client) {
89
+ this.logger('Warning: Unable to get CDP client for reinitialization');
90
+ return;
91
+ }
92
+ // 1. Enable Target discovery and auto-attach
93
+ // This is critical for multi-target scenarios (iframes, workers, etc.)
94
+ try {
95
+ await client.send('Target.setDiscoverTargets', { discover: true });
96
+ this.logger('CDP: Target discovery enabled');
97
+ }
98
+ catch (err) {
99
+ this.logger('Warning: Failed to enable target discovery:', err);
100
+ }
101
+ try {
102
+ await client.send('Target.setAutoAttach', {
103
+ autoAttach: true,
104
+ waitForDebuggerOnStart: false,
105
+ flatten: true
106
+ });
107
+ this.logger('CDP: Auto-attach configured');
108
+ }
109
+ catch (err) {
110
+ this.logger('Warning: Failed to configure auto-attach:', err);
111
+ }
112
+ // 2. Enable essential CDP domains for each page
113
+ const pages = await this.browser.pages();
114
+ for (const page of pages) {
115
+ try {
116
+ const pageClient = page._client();
117
+ if (pageClient) {
118
+ // Enable Network domain for request interception
119
+ await pageClient.send('Network.enable');
120
+ // Enable Runtime domain for console messages and exceptions
121
+ await pageClient.send('Runtime.enable');
122
+ // Enable Log domain for browser logs
123
+ await pageClient.send('Log.enable');
124
+ this.logger(`CDP domains enabled for page: ${page.url()}`);
125
+ }
126
+ }
127
+ catch (err) {
128
+ this.logger(`Warning: Failed to enable CDP domains for page ${page.url()}:`, err);
129
+ }
130
+ }
131
+ this.logger('CDP protocol reinitialization completed');
132
+ }
133
+ catch (err) {
134
+ this.logger('Error during CDP reinitialization:', err);
135
+ // Don't throw - this is a best-effort operation
136
+ // The browser should still be usable even if CDP setup partially fails
137
+ }
138
+ }
139
+ /**
140
+ * Update browser instance after reconnection
141
+ */
142
+ async updateBrowser(newBrowser) {
143
+ this.browser = newBrowser;
144
+ this.connectionManager.setBrowser(newBrowser, async () => newBrowser);
145
+ // Reinitialize CDP protocol domains BEFORE collectors
146
+ // This ensures CDP events are properly set up
147
+ await this.reinitializeCDP();
148
+ // Reinitialize collectors for new browser
149
+ await this.#networkCollector.init();
150
+ await this.#consoleCollector.init();
151
+ // Recreate pages snapshot
152
+ await this.createPagesSnapshot();
153
+ this.setSelectedPageIdx(0);
154
+ this.logger('Browser instance updated after reconnection');
155
+ }
73
156
  getNetworkRequests() {
74
157
  const page = this.getSelectedPage();
75
158
  return this.#networkCollector.getData(page);
@@ -0,0 +1,356 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+ import { ProtocolError, TimeoutError } from 'puppeteer';
7
+ /**
8
+ * Default connection manager options
9
+ */
10
+ const DEFAULT_OPTIONS = {
11
+ maxReconnectAttempts: 3,
12
+ initialRetryDelay: 1000, // 1 second
13
+ maxRetryDelay: 10000, // 10 seconds
14
+ enableLogging: true,
15
+ onReconnect: undefined,
16
+ };
17
+ /**
18
+ * Connection state enum
19
+ */
20
+ var ConnectionState;
21
+ (function (ConnectionState) {
22
+ ConnectionState["CONNECTED"] = "CONNECTED";
23
+ ConnectionState["RECONNECTING"] = "RECONNECTING";
24
+ ConnectionState["CLOSED"] = "CLOSED";
25
+ })(ConnectionState || (ConnectionState = {}));
26
+ /**
27
+ * Browser Connection Manager
28
+ *
29
+ * Provides automatic reconnection for CDP operations with:
30
+ * - Single-flight pattern to prevent concurrent reconnections
31
+ * - Event-driven detection via browser 'disconnected' event
32
+ * - State machine tracking (CONNECTED | RECONNECTING | CLOSED)
33
+ * - Exponential backoff with jitter to prevent thundering herd
34
+ */
35
+ export class BrowserConnectionManager {
36
+ reconnectAttempts = 0;
37
+ options;
38
+ browser = null;
39
+ browserFactory = null;
40
+ /** Single-flight pattern: prevents concurrent reconnection attempts */
41
+ reconnectInFlight = null;
42
+ /** Shared reconnection sequence for multiple operations */
43
+ reconnectSequenceInFlight = null;
44
+ /** Current connection state */
45
+ state = ConnectionState.CLOSED;
46
+ constructor(options = {}) {
47
+ this.options = { ...DEFAULT_OPTIONS, ...options };
48
+ }
49
+ /**
50
+ * Set browser instance and factory for reconnection
51
+ *
52
+ * @param browser - Browser instance to manage
53
+ * @param factory - Factory function to create new browser instances on reconnection
54
+ */
55
+ setBrowser(browser, factory) {
56
+ this.browser = browser;
57
+ this.browserFactory = factory;
58
+ this.state = ConnectionState.CONNECTED;
59
+ // Event-driven detection: hook into browser 'disconnected' event
60
+ this.browser.on('disconnected', () => {
61
+ this.log('Browser disconnected event fired');
62
+ this.setState(ConnectionState.CLOSED);
63
+ // Trigger immediate reconnection (will be handled by next operation)
64
+ });
65
+ this.log('Browser instance set, state: CONNECTED');
66
+ }
67
+ /**
68
+ * Execute an operation with automatic retry on CDP connection errors
69
+ */
70
+ async executeWithRetry(operation, operationName) {
71
+ try {
72
+ return await operation();
73
+ }
74
+ catch (error) {
75
+ if (this.isCDPConnectionError(error)) {
76
+ this.log(`CDP connection error in ${operationName}, attempting reconnect...`);
77
+ return await this.retryWithReconnect(operation, operationName);
78
+ }
79
+ throw error;
80
+ }
81
+ }
82
+ /**
83
+ * Retry operation with exponential backoff and jitter
84
+ *
85
+ * Implements single-flight pattern at the sequence level:
86
+ * - Multiple concurrent operations share the same reconnection sequence
87
+ * - Only one reconnection sequence runs at a time
88
+ * - All waiting operations retry after the shared sequence completes
89
+ *
90
+ * Adds ±20% randomness to retry delays to prevent thundering herd problem.
91
+ *
92
+ * @param operation - Operation to retry
93
+ * @param operationName - Name of operation for logging
94
+ * @returns Result of operation
95
+ */
96
+ async retryWithReconnect(operation, operationName) {
97
+ // Single-flight pattern: if a reconnection sequence is already running,
98
+ // wait for it to complete, then try the operation once
99
+ if (this.reconnectSequenceInFlight) {
100
+ this.log(`${operationName}: Waiting for ongoing reconnection sequence...`);
101
+ try {
102
+ await this.reconnectSequenceInFlight;
103
+ this.log(`${operationName}: Reconnection sequence completed, retrying operation...`);
104
+ return await operation();
105
+ }
106
+ catch (error) {
107
+ // Reconnection sequence failed, propagate the error
108
+ throw error;
109
+ }
110
+ }
111
+ // Start a new reconnection sequence
112
+ this.reconnectSequenceInFlight = this._runReconnectionSequence();
113
+ try {
114
+ await this.reconnectSequenceInFlight;
115
+ this.log(`${operationName}: Reconnection sequence completed, retrying operation...`);
116
+ return await operation();
117
+ }
118
+ catch (error) {
119
+ throw error;
120
+ }
121
+ finally {
122
+ // Clear the in-flight sequence
123
+ this.reconnectSequenceInFlight = null;
124
+ }
125
+ }
126
+ /**
127
+ * Run a full reconnection sequence with exponential backoff
128
+ *
129
+ * This is the actual retry loop that multiple operations can share.
130
+ *
131
+ * @private
132
+ */
133
+ async _runReconnectionSequence() {
134
+ const maxAttempts = this.options.maxReconnectAttempts ?? 3;
135
+ const initialDelay = this.options.initialRetryDelay ?? 1000;
136
+ const maxDelay = this.options.maxRetryDelay ?? 10000;
137
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
138
+ this.reconnectAttempts++;
139
+ const attemptNum = attempt + 1;
140
+ this.log(`Reconnect attempt ${attemptNum}/${maxAttempts}...`);
141
+ // Exponential backoff with max delay
142
+ const baseDelay = Math.min(initialDelay * Math.pow(2, attempt), maxDelay);
143
+ // Add jitter: ±20% randomness to prevent thundering herd
144
+ const jitter = baseDelay * 0.2 * (Math.random() * 2 - 1);
145
+ const delay = Math.max(0, baseDelay + jitter);
146
+ this.log(`Waiting ${delay.toFixed(0)}ms before reconnect attempt...`);
147
+ await this.sleep(delay);
148
+ try {
149
+ await this.reconnectBrowser();
150
+ this.log(`Reconnection successful`);
151
+ return; // Success!
152
+ }
153
+ catch (error) {
154
+ if (attempt === maxAttempts - 1) {
155
+ // Last attempt failed
156
+ throw this.createReconnectionFailedError(attemptNum, error);
157
+ }
158
+ this.log(`Reconnect attempt ${attemptNum} failed: ${error}`);
159
+ }
160
+ }
161
+ throw new Error('Reconnection logic error'); // Should never reach here
162
+ }
163
+ /**
164
+ * Check if error is a CDP connection error
165
+ *
166
+ * Uses type-safe error detection with instanceof checks and falls back
167
+ * to string matching for compatibility.
168
+ *
169
+ * @param error - Error to check
170
+ * @returns true if error is a CDP connection error
171
+ */
172
+ isCDPConnectionError(error) {
173
+ // Type-safe detection using instanceof
174
+ if (error instanceof ProtocolError || error instanceof TimeoutError) {
175
+ return true;
176
+ }
177
+ // Fallback: string matching for error messages
178
+ const errorMessage = error?.message || '';
179
+ return (errorMessage.includes('Target closed') ||
180
+ errorMessage.includes('Protocol error') ||
181
+ errorMessage.includes('Session closed') ||
182
+ errorMessage.includes('Connection closed') ||
183
+ errorMessage.includes('WebSocket is not open'));
184
+ }
185
+ /**
186
+ * Reconnect browser instance with single-flight pattern
187
+ *
188
+ * Ensures only one reconnection attempt happens at a time.
189
+ * Multiple concurrent calls will wait for the same reconnection promise.
190
+ *
191
+ * @returns Promise that resolves when reconnection is complete
192
+ */
193
+ async reconnectBrowser() {
194
+ // Single-flight pattern: return existing promise if reconnection is already in progress
195
+ if (this.reconnectInFlight) {
196
+ this.log('Reconnection already in progress, waiting...');
197
+ return this.reconnectInFlight;
198
+ }
199
+ // Create new reconnection promise
200
+ this.reconnectInFlight = this._doReconnect();
201
+ try {
202
+ await this.reconnectInFlight;
203
+ }
204
+ finally {
205
+ // Clear in-flight promise when complete
206
+ this.reconnectInFlight = null;
207
+ }
208
+ }
209
+ /**
210
+ * Internal reconnection logic
211
+ *
212
+ * @private
213
+ */
214
+ async _doReconnect() {
215
+ if (!this.browserFactory) {
216
+ throw new Error('Browser factory not set. Cannot reconnect.');
217
+ }
218
+ this.setState(ConnectionState.RECONNECTING);
219
+ try {
220
+ // Close old browser if still connected
221
+ if (this.browser?.isConnected()) {
222
+ await this.browser.close().catch(() => {
223
+ // Ignore errors during close
224
+ });
225
+ }
226
+ }
227
+ catch (error) {
228
+ // Ignore errors during cleanup
229
+ }
230
+ // Create new browser instance
231
+ const newBrowser = await this.browserFactory();
232
+ this.browser = newBrowser;
233
+ // Re-attach disconnected event handler
234
+ this.browser.on('disconnected', () => {
235
+ this.log('Browser disconnected event fired');
236
+ this.setState(ConnectionState.CLOSED);
237
+ });
238
+ this.setState(ConnectionState.CONNECTED);
239
+ this.log('Browser reconnected successfully');
240
+ // Notify callback if provided
241
+ if (this.options.onReconnect) {
242
+ await this.options.onReconnect(newBrowser);
243
+ }
244
+ }
245
+ /**
246
+ * Create user-friendly error for reconnection failure
247
+ */
248
+ createReconnectionFailedError(attempts, lastError) {
249
+ const message = `
250
+ ❌ Chrome DevTools接続エラー
251
+
252
+ ${attempts}回の再接続を試みましたが、Chrome DevToolsとの接続を回復できませんでした。
253
+
254
+ 📋 最後のエラー:
255
+ ${lastError?.message || 'Unknown error'}
256
+
257
+ 🔧 解決方法:
258
+ 1. Claude Codeを再起動してください
259
+ 2. Chromeブラウザを完全に終了して再起動してください
260
+ 3. chrome://extensions でChrome DevTools拡張機能を確認してください
261
+
262
+ 詳細: docs/troubleshooting.md#cdp-connection-error
263
+ `.trim();
264
+ const error = new Error(message);
265
+ error.name = 'CDPReconnectionError';
266
+ return error;
267
+ }
268
+ /**
269
+ * Set connection state and log transition
270
+ *
271
+ * @param newState - New connection state
272
+ */
273
+ setState(newState) {
274
+ if (this.state !== newState) {
275
+ this.log(`State transition: ${this.state} -> ${newState}`);
276
+ this.state = newState;
277
+ }
278
+ }
279
+ /**
280
+ * Log message if logging is enabled
281
+ *
282
+ * @param message - Message to log
283
+ */
284
+ log(message) {
285
+ if (this.options.enableLogging !== false) {
286
+ console.log(`[ConnectionManager] ${message}`);
287
+ }
288
+ }
289
+ /**
290
+ * Sleep for specified milliseconds
291
+ */
292
+ sleep(ms) {
293
+ return new Promise((resolve) => setTimeout(resolve, ms));
294
+ }
295
+ /**
296
+ * Get current browser instance
297
+ */
298
+ getBrowser() {
299
+ return this.browser;
300
+ }
301
+ /**
302
+ * Get total reconnection attempts made
303
+ */
304
+ getReconnectAttempts() {
305
+ return this.reconnectAttempts;
306
+ }
307
+ /**
308
+ * Reset reconnection attempt counter
309
+ */
310
+ resetReconnectAttempts() {
311
+ this.reconnectAttempts = 0;
312
+ }
313
+ /**
314
+ * Check if browser is currently connected
315
+ *
316
+ * @returns true if browser is connected
317
+ */
318
+ isConnected() {
319
+ return this.browser?.isConnected() ?? false;
320
+ }
321
+ /**
322
+ * Get current connection state
323
+ *
324
+ * @returns Current connection state (CONNECTED | RECONNECTING | CLOSED)
325
+ */
326
+ getState() {
327
+ return this.state;
328
+ }
329
+ /**
330
+ * Check if reconnection is currently in progress
331
+ *
332
+ * @returns true if reconnection is in progress
333
+ */
334
+ isReconnecting() {
335
+ return this.state === ConnectionState.RECONNECTING;
336
+ }
337
+ }
338
+ /**
339
+ * Global connection manager instance
340
+ */
341
+ let globalConnectionManager = null;
342
+ /**
343
+ * Get or create global connection manager
344
+ */
345
+ export function getConnectionManager(options) {
346
+ if (!globalConnectionManager) {
347
+ globalConnectionManager = new BrowserConnectionManager(options);
348
+ }
349
+ return globalConnectionManager;
350
+ }
351
+ /**
352
+ * Reset global connection manager (for testing)
353
+ */
354
+ export function resetConnectionManager() {
355
+ globalConnectionManager = null;
356
+ }
package/build/src/main.js CHANGED
@@ -60,7 +60,7 @@ server.server.setRequestHandler(SetLevelRequestSchema, () => {
60
60
  let context;
61
61
  let uiHealthCheckRun = false; // Track if UI health check has been run
62
62
  async function getContext() {
63
- const browser = await resolveBrowser({
63
+ const browserOptions = {
64
64
  browserUrl: args.browserUrl,
65
65
  headless: args.headless,
66
66
  executablePath: args.executablePath,
@@ -73,10 +73,23 @@ async function getContext() {
73
73
  chromeProfile: args.chromeProfile,
74
74
  userDataDir: args.userDataDir,
75
75
  logFile,
76
- });
76
+ };
77
+ const browser = await resolveBrowser(browserOptions);
78
+ // Browser factory function for reconnection
79
+ const browserFactory = async () => {
80
+ logger('Reconnecting browser...');
81
+ return await resolveBrowser(browserOptions);
82
+ };
77
83
  // Always recreate context if browser reference changed or context doesn't exist
78
84
  if (!context || context.browser !== browser) {
79
- context = await McpContext.from(browser, logger);
85
+ // Connection manager options with reconnect callback
86
+ const connectionOptions = {
87
+ onReconnect: async (newBrowser) => {
88
+ logger('Updating context with reconnected browser...');
89
+ await context.updateBrowser(newBrowser);
90
+ },
91
+ };
92
+ context = await McpContext.from(browser, logger, browserFactory, connectionOptions);
80
93
  // Run UI health check only once per browser instance
81
94
  if (!uiHealthCheckRun) {
82
95
  uiHealthCheckRun = true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chrome-devtools-mcp-for-extension",
3
- "version": "0.12.0",
3
+ "version": "0.14.0",
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",