chrome-devtools-mcp-for-extension 0.14.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,29 +80,38 @@ 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
+ }
80
94
  /**
81
95
  * Reinitialize CDP protocol domains after reconnection
82
96
  * This ensures proper CDP event handling after browser restart
83
97
  */
84
98
  async reinitializeCDP() {
85
99
  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.)
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
94
106
  try {
95
- await client.send('Target.setDiscoverTargets', { discover: true });
107
+ await this.#browserSession.send('Target.setDiscoverTargets', { discover: true });
96
108
  this.logger('CDP: Target discovery enabled');
97
109
  }
98
110
  catch (err) {
99
111
  this.logger('Warning: Failed to enable target discovery:', err);
100
112
  }
101
113
  try {
102
- await client.send('Target.setAutoAttach', {
114
+ await this.#browserSession.send('Target.setAutoAttach', {
103
115
  autoAttach: true,
104
116
  waitForDebuggerOnStart: false,
105
117
  flatten: true
@@ -109,24 +121,29 @@ export class McpContext {
109
121
  catch (err) {
110
122
  this.logger('Warning: Failed to configure auto-attach:', err);
111
123
  }
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()}`);
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);
125
143
  }
126
144
  }
127
- catch (err) {
128
- this.logger(`Warning: Failed to enable CDP domains for page ${page.url()}:`, err);
129
- }
145
+ // Service Worker / Worker support can be added here if needed
146
+ // if (type === 'service_worker' || type === 'worker') { ... }
130
147
  }
131
148
  this.logger('CDP protocol reinitialization completed');
132
149
  }
@@ -140,6 +157,8 @@ export class McpContext {
140
157
  * Update browser instance after reconnection
141
158
  */
142
159
  async updateBrowser(newBrowser) {
160
+ // Dispose old sessions first
161
+ await this.#disposeSessions();
143
162
  this.browser = newBrowser;
144
163
  this.connectionManager.setBrowser(newBrowser, async () => newBrowser);
145
164
  // Reinitialize CDP protocol domains BEFORE collectors
@@ -11,6 +11,7 @@ const DEFAULT_OPTIONS = {
11
11
  maxReconnectAttempts: 3,
12
12
  initialRetryDelay: 1000, // 1 second
13
13
  maxRetryDelay: 10000, // 10 seconds
14
+ reconnectOverallTimeoutMs: 30000, // 30 seconds
14
15
  enableLogging: true,
15
16
  onReconnect: undefined,
16
17
  };
@@ -43,6 +44,13 @@ export class BrowserConnectionManager {
43
44
  reconnectSequenceInFlight = null;
44
45
  /** Current connection state */
45
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
+ };
46
54
  constructor(options = {}) {
47
55
  this.options = { ...DEFAULT_OPTIONS, ...options };
48
56
  }
@@ -53,15 +61,15 @@ export class BrowserConnectionManager {
53
61
  * @param factory - Factory function to create new browser instances on reconnection
54
62
  */
55
63
  setBrowser(browser, factory) {
64
+ // Remove old listener to prevent memory leak
65
+ if (this.browser) {
66
+ this.browser.off('disconnected', this.onDisconnected);
67
+ }
56
68
  this.browser = browser;
57
69
  this.browserFactory = factory;
58
70
  this.state = ConnectionState.CONNECTED;
59
71
  // 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
- });
72
+ this.browser.on('disconnected', this.onDisconnected);
65
73
  this.log('Browser instance set, state: CONNECTED');
66
74
  }
67
75
  /**
@@ -80,71 +88,81 @@ export class BrowserConnectionManager {
80
88
  }
81
89
  }
82
90
  /**
83
- * Retry operation with exponential backoff and jitter
91
+ * Trigger reconnection sequence
84
92
  *
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
93
+ * Can be called from events (disconnected) or operations (executeWithRetry).
94
+ * Uses single-flight pattern to prevent duplicate reconnections.
89
95
  *
90
- * Adds ±20% randomness to retry delays to prevent thundering herd problem.
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
91
123
  *
92
124
  * @param operation - Operation to retry
93
125
  * @param operationName - Name of operation for logging
94
126
  * @returns Result of operation
95
127
  */
96
128
  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
- }
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();
125
133
  }
126
134
  /**
127
135
  * Run a full reconnection sequence with exponential backoff
128
136
  *
129
137
  * This is the actual retry loop that multiple operations can share.
130
138
  *
139
+ * @param signal - AbortSignal to cancel reconnection
131
140
  * @private
132
141
  */
133
- async _runReconnectionSequence() {
142
+ async _runReconnectionSequence(signal) {
134
143
  const maxAttempts = this.options.maxReconnectAttempts ?? 3;
135
144
  const initialDelay = this.options.initialRetryDelay ?? 1000;
136
145
  const maxDelay = this.options.maxRetryDelay ?? 10000;
146
+ const random = this.options.rng ?? Math.random;
137
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
+ }
138
152
  this.reconnectAttempts++;
139
153
  const attemptNum = attempt + 1;
140
154
  this.log(`Reconnect attempt ${attemptNum}/${maxAttempts}...`);
141
155
  // Exponential backoff with max delay
142
156
  const baseDelay = Math.min(initialDelay * Math.pow(2, attempt), maxDelay);
143
157
  // Add jitter: ±20% randomness to prevent thundering herd
144
- const jitter = baseDelay * 0.2 * (Math.random() * 2 - 1);
158
+ const jitter = baseDelay * 0.2 * (random() * 2 - 1);
145
159
  const delay = Math.max(0, baseDelay + jitter);
146
160
  this.log(`Waiting ${delay.toFixed(0)}ms before reconnect attempt...`);
147
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
+ }
148
166
  try {
149
167
  await this.reconnectBrowser();
150
168
  this.log(`Reconnection successful`);
@@ -164,7 +182,7 @@ export class BrowserConnectionManager {
164
182
  * Check if error is a CDP connection error
165
183
  *
166
184
  * Uses type-safe error detection with instanceof checks and falls back
167
- * to string matching for compatibility.
185
+ * to string matching for compatibility. Also checks method hints.
168
186
  *
169
187
  * @param error - Error to check
170
188
  * @returns true if error is a CDP connection error
@@ -174,13 +192,17 @@ export class BrowserConnectionManager {
174
192
  if (error instanceof ProtocolError || error instanceof TimeoutError) {
175
193
  return true;
176
194
  }
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'));
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;
184
206
  }
185
207
  /**
186
208
  * Reconnect browser instance with single-flight pattern
@@ -231,10 +253,7 @@ export class BrowserConnectionManager {
231
253
  const newBrowser = await this.browserFactory();
232
254
  this.browser = newBrowser;
233
255
  // Re-attach disconnected event handler
234
- this.browser.on('disconnected', () => {
235
- this.log('Browser disconnected event fired');
236
- this.setState(ConnectionState.CLOSED);
237
- });
256
+ this.browser.on('disconnected', this.onDisconnected);
238
257
  this.setState(ConnectionState.CONNECTED);
239
258
  this.log('Browser reconnected successfully');
240
259
  // Notify callback if provided
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chrome-devtools-mcp-for-extension",
3
- "version": "0.14.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",