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.
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,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
|
-
//
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
const
|
|
117
|
-
if (
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
await
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
128
|
-
|
|
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
|
-
*
|
|
91
|
+
* Trigger reconnection sequence
|
|
84
92
|
*
|
|
85
|
-
*
|
|
86
|
-
* -
|
|
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
|
-
*
|
|
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
|
-
//
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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 * (
|
|
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
|
-
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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.
|
|
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",
|