chrome-devtools-mcp 0.14.0 → 0.15.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/README.md CHANGED
@@ -27,6 +27,22 @@ allowing them to inspect, debug, and modify any data in the browser or DevTools.
27
27
  Avoid sharing sensitive or personal information that you don't want to share with
28
28
  MCP clients.
29
29
 
30
+ ## **Usage statistics**
31
+
32
+ Google collects usage statistics (such as tool invocation success rates, latency, and environment information) to improve the reliability and performance of Chrome DevTools MCP.
33
+
34
+ Data collection is **enabled by default**. You can opt-out by passing the `--no-usage-statistics` flag when starting the server:
35
+
36
+ ```json
37
+ "args": ["-y", "chrome-devtools-mcp@latest", "--no-usage-statistics"]
38
+ ```
39
+
40
+ Google handles this data in accordance with the [Google Privacy Policy](https://policies.google.com/privacy).
41
+
42
+ Google's collection of usage statistics for Chrome DevTools MCP is independent from the Chrome browser's usage statistics. Opting out of Chrome metrics does not automatically opt you out of this tool, and vice-versa.
43
+
44
+ Collection is disabled if CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS or CI env variables are set.
45
+
30
46
  ## Requirements
31
47
 
32
48
  - [Node.js](https://nodejs.org/) v20.19 or a newer [latest maintenance LTS](https://github.com/nodejs/Release#release-schedule) version.
@@ -450,6 +466,11 @@ The Chrome DevTools MCP server supports the following configuration option:
450
466
  - **Type:** boolean
451
467
  - **Default:** `true`
452
468
 
469
+ - **`--usageStatistics`/ `--usage-statistics`**
470
+ Set to false to opt-out of usage statistics collection. Google collects usage data to improve the tool, handled under the Google Privacy Policy (https://policies.google.com/privacy). This is independent from Chrome browser metrics. Disabled if CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS or CI env variables are set.
471
+ - **Type:** boolean
472
+ - **Default:** `true`
473
+
453
474
  <!-- END AUTO GENERATED OPTIONS -->
454
475
 
455
476
  Pass them via the `args` property in the JSON configuration. For example:
@@ -60,6 +60,7 @@ export class McpContext {
60
60
  #geolocationMap = new WeakMap();
61
61
  #viewportMap = new WeakMap();
62
62
  #userAgentMap = new WeakMap();
63
+ #colorSchemeMap = new WeakMap();
63
64
  #dialog;
64
65
  #pageIdMap = new WeakMap();
65
66
  #nextPageId = 1;
@@ -249,6 +250,19 @@ export class McpContext {
249
250
  const page = this.getSelectedPage();
250
251
  return this.#userAgentMap.get(page) ?? null;
251
252
  }
253
+ setColorScheme(scheme) {
254
+ const page = this.getSelectedPage();
255
+ if (scheme === null) {
256
+ this.#colorSchemeMap.delete(page);
257
+ }
258
+ else {
259
+ this.#colorSchemeMap.set(page, scheme);
260
+ }
261
+ }
262
+ getColorScheme() {
263
+ const page = this.getSelectedPage();
264
+ return this.#colorSchemeMap.get(page) ?? null;
265
+ }
252
266
  setIsRunningPerformanceTrace(x) {
253
267
  this.#isRunningTrace = x;
254
268
  }
@@ -537,12 +551,12 @@ export class McpContext {
537
551
  getWaitForHelper(page, cpuMultiplier, networkMultiplier) {
538
552
  return new WaitForHelper(page, cpuMultiplier, networkMultiplier);
539
553
  }
540
- waitForEventsAfterAction(action) {
554
+ waitForEventsAfterAction(action, options) {
541
555
  const page = this.getSelectedPage();
542
556
  const cpuMultiplier = this.getCpuThrottlingRate();
543
557
  const networkMultiplier = getNetworkMultiplierFromString(this.getNetworkConditions());
544
558
  const waitForHelper = this.getWaitForHelper(page, cpuMultiplier, networkMultiplier);
545
- return waitForHelper.waitForEventsAfterAction(action);
559
+ return waitForHelper.waitForEventsAfterAction(action, options);
546
560
  }
547
561
  getNetworkRequestStableId(request) {
548
562
  return this.#networkCollector.getIdForResource(request);
@@ -308,6 +308,12 @@ export class McpResponse {
308
308
  response.push(`Emulating: ${cpuThrottlingRate}x slowdown`);
309
309
  structuredContent.cpuThrottlingRate = cpuThrottlingRate;
310
310
  }
311
+ const colorScheme = context.getColorScheme();
312
+ if (colorScheme) {
313
+ response.push(`## Color Scheme emulation`);
314
+ response.push(`Emulating: ${colorScheme}`);
315
+ structuredContent.colorScheme = colorScheme;
316
+ }
311
317
  const dialog = context.getDialog();
312
318
  if (dialog) {
313
319
  const defaultValueIfNeeded = dialog.type() === 'prompt'
@@ -103,12 +103,12 @@ export class WaitForHelper {
103
103
  });
104
104
  });
105
105
  }
106
- async waitForEventsAfterAction(action) {
106
+ async waitForEventsAfterAction(action, options) {
107
107
  const navigationFinished = this.waitForNavigationStarted()
108
108
  .then(navigationStated => {
109
109
  if (navigationStated) {
110
110
  return this.#page.waitForNavigation({
111
- timeout: this.#navigationTimeout,
111
+ timeout: options?.timeout ?? this.#navigationTimeout,
112
112
  signal: this.#abortController.signal,
113
113
  });
114
114
  }
package/build/src/cli.js CHANGED
@@ -190,10 +190,23 @@ export const cliOptions = {
190
190
  },
191
191
  usageStatistics: {
192
192
  type: 'boolean',
193
- // Marked as `false` until the feature is ready to be enabled by default.
194
- default: false,
193
+ default: true,
194
+ describe: 'Set to false to opt-out of usage statistics collection. Google collects usage data to improve the tool, handled under the Google Privacy Policy (https://policies.google.com/privacy). This is independent from Chrome browser metrics. Disabled if CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS or CI env variables are set.',
195
+ },
196
+ clearcutEndpoint: {
197
+ type: 'string',
198
+ hidden: true,
199
+ describe: 'Endpoint for Clearcut telemetry.',
200
+ },
201
+ clearcutForceFlushIntervalMs: {
202
+ type: 'number',
195
203
  hidden: true,
196
- describe: 'Set to false to opt-out of usage statistics collection.',
204
+ describe: 'Force flush interval in milliseconds (for testing).',
205
+ },
206
+ clearcutIncludePidHeader: {
207
+ type: 'boolean',
208
+ hidden: true,
209
+ describe: 'Include watchdog PID in Clearcut request headers (for testing).',
197
210
  },
198
211
  };
199
212
  export function parseArguments(version, argv = process.argv) {
@@ -260,6 +273,10 @@ export function parseArguments(version, argv = process.argv) {
260
273
  '$0 --auto-connect --channel=canary',
261
274
  'Connect to a canary Chrome instance (Chrome 144+) running instead of launching a new instance',
262
275
  ],
276
+ [
277
+ '$0 --no-usage-statistics',
278
+ 'Do not send usage statistics https://github.com/ChromeDevTools/chrome-devtools-mcp#usage-statistics.',
279
+ ],
263
280
  ]);
264
281
  return yargsInstance
265
282
  .wrap(Math.min(120, yargsInstance.terminalWidth()))
package/build/src/main.js CHANGED
@@ -20,15 +20,23 @@ import { ToolCategory } from './tools/categories.js';
20
20
  import { tools } from './tools/tools.js';
21
21
  // If moved update release-please config
22
22
  // x-release-please-start-version
23
- const VERSION = '0.14.0';
23
+ const VERSION = '0.15.1';
24
24
  // x-release-please-end
25
25
  export const args = parseArguments(VERSION);
26
26
  const logFile = args.logFile ? saveLogsToFile(args.logFile) : undefined;
27
+ if (process.env['CI'] ||
28
+ process.env['CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS']) {
29
+ console.error("turning off usage statistics. process.env['CI'] || process.env['CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS'] is set.");
30
+ args.usageStatistics = false;
31
+ }
27
32
  let clearcutLogger;
28
33
  if (args.usageStatistics) {
29
34
  clearcutLogger = new ClearcutLogger({
30
35
  logFile: args.logFile,
31
36
  appVersion: VERSION,
37
+ clearcutEndpoint: args.clearcutEndpoint,
38
+ clearcutForceFlushIntervalMs: args.clearcutForceFlushIntervalMs,
39
+ clearcutIncludePidHeader: args.clearcutIncludePidHeader,
32
40
  });
33
41
  }
34
42
  process.on('unhandledRejection', (reason, promise) => {
@@ -33,6 +33,9 @@ export class ClearcutLogger {
33
33
  appVersion: options.appVersion,
34
34
  osType: detectOsType(),
35
35
  logFile: options.logFile,
36
+ clearcutEndpoint: options.clearcutEndpoint,
37
+ clearcutForceFlushIntervalMs: options.clearcutForceFlushIntervalMs,
38
+ clearcutIncludePidHeader: options.clearcutIncludePidHeader,
36
39
  });
37
40
  }
38
41
  async logToolInvocation(args) {
@@ -5,44 +5,197 @@
5
5
  */
6
6
  import crypto from 'node:crypto';
7
7
  import { logger } from '../../logger.js';
8
+ const MAX_BUFFER_SIZE = 1000;
9
+ const DEFAULT_CLEARCUT_ENDPOINT = 'https://play.googleapis.com/log?format=json_proto';
10
+ const DEFAULT_FLUSH_INTERVAL_MS = 15 * 60 * 1000;
11
+ const LOG_SOURCE = 2839;
12
+ const CLIENT_TYPE = 47;
13
+ const MIN_RATE_LIMIT_WAIT_MS = 30_000;
14
+ const REQUEST_TIMEOUT_MS = 30_000;
15
+ const SHUTDOWN_TIMEOUT_MS = 5_000;
8
16
  const SESSION_ROTATION_INTERVAL_MS = 24 * 60 * 60 * 1000;
9
17
  export class ClearcutSender {
10
18
  #appVersion;
11
19
  #osType;
20
+ #clearcutEndpoint;
21
+ #flushIntervalMs;
22
+ #includePidHeader;
12
23
  #sessionId;
13
24
  #sessionCreated;
14
- constructor(appVersion, osType) {
15
- this.#appVersion = appVersion;
16
- this.#osType = osType;
25
+ #buffer = [];
26
+ #flushTimer = null;
27
+ #isFlushing = false;
28
+ #timerStarted = false;
29
+ constructor(config) {
30
+ this.#appVersion = config.appVersion;
31
+ this.#osType = config.osType;
32
+ this.#clearcutEndpoint =
33
+ config.clearcutEndpoint ?? DEFAULT_CLEARCUT_ENDPOINT;
34
+ this.#flushIntervalMs =
35
+ config.forceFlushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS;
36
+ this.#includePidHeader = config.includePidHeader ?? false;
17
37
  this.#sessionId = crypto.randomUUID();
18
38
  this.#sessionCreated = Date.now();
19
39
  }
20
- async send(event) {
21
- this.#rotateSessionIfNeeded();
22
- const enrichedEvent = this.#enrichEvent(event);
23
- this.transport(enrichedEvent);
24
- }
25
- transport(event) {
26
- logger('Telemetry event', JSON.stringify(event, null, 2));
27
- }
28
- async sendShutdownEvent() {
29
- const shutdownEvent = {
30
- server_shutdown: {},
31
- };
32
- await this.send(shutdownEvent);
33
- }
34
- #rotateSessionIfNeeded() {
40
+ enqueueEvent(event) {
35
41
  if (Date.now() - this.#sessionCreated > SESSION_ROTATION_INTERVAL_MS) {
36
42
  this.#sessionId = crypto.randomUUID();
37
43
  this.#sessionCreated = Date.now();
38
44
  }
39
- }
40
- #enrichEvent(event) {
41
- return {
45
+ logger('Enqueing telemetry event', JSON.stringify(event, null, 2));
46
+ this.#addToBuffer({
42
47
  ...event,
43
48
  session_id: this.#sessionId,
44
49
  app_version: this.#appVersion,
45
50
  os_type: this.#osType,
51
+ });
52
+ if (!this.#timerStarted) {
53
+ this.#timerStarted = true;
54
+ this.#scheduleFlush(this.#flushIntervalMs);
55
+ }
56
+ }
57
+ async sendShutdownEvent() {
58
+ if (this.#flushTimer) {
59
+ clearTimeout(this.#flushTimer);
60
+ this.#flushTimer = null;
61
+ }
62
+ const shutdownEvent = {
63
+ server_shutdown: {},
46
64
  };
65
+ this.enqueueEvent(shutdownEvent);
66
+ try {
67
+ await Promise.race([
68
+ this.#finalFlush(),
69
+ new Promise(resolve => setTimeout(resolve, SHUTDOWN_TIMEOUT_MS)),
70
+ ]);
71
+ logger('Final flush completed');
72
+ }
73
+ catch (error) {
74
+ logger('Final flush failed:', error);
75
+ }
76
+ }
77
+ async #flush() {
78
+ if (this.#isFlushing) {
79
+ return;
80
+ }
81
+ if (this.#buffer.length === 0) {
82
+ this.#scheduleFlush(this.#flushIntervalMs);
83
+ return;
84
+ }
85
+ this.#isFlushing = true;
86
+ let nextDelayMs = this.#flushIntervalMs;
87
+ // Optimistically remove events from buffer before sending.
88
+ // This prevents race conditions where a simultaneous #finalFlush would include these same events.
89
+ const eventsToSend = [...this.#buffer];
90
+ this.#buffer = [];
91
+ try {
92
+ const result = await this.#sendBatch(eventsToSend);
93
+ if (result.success) {
94
+ if (result.nextRequestWaitMs !== undefined) {
95
+ nextDelayMs = Math.max(result.nextRequestWaitMs, MIN_RATE_LIMIT_WAIT_MS);
96
+ }
97
+ }
98
+ else if (result.isPermanentError) {
99
+ logger('Permanent error, dropped batch of', eventsToSend.length, 'events');
100
+ }
101
+ else {
102
+ // Transient error: Requeue events at the front of the buffer
103
+ // to maintain order and retry them later.
104
+ this.#buffer = [...eventsToSend, ...this.#buffer];
105
+ }
106
+ }
107
+ catch (error) {
108
+ // Safety catch for unexpected errors, requeue events
109
+ this.#buffer = [...eventsToSend, ...this.#buffer];
110
+ logger('Flush failed unexpectedly:', error);
111
+ }
112
+ finally {
113
+ this.#isFlushing = false;
114
+ this.#scheduleFlush(nextDelayMs);
115
+ }
116
+ }
117
+ #addToBuffer(event) {
118
+ if (this.#buffer.length >= MAX_BUFFER_SIZE) {
119
+ this.#buffer.shift();
120
+ logger('Telemetry buffer overflow: dropped oldest event');
121
+ }
122
+ this.#buffer.push({
123
+ event,
124
+ timestamp: Date.now(),
125
+ });
126
+ }
127
+ #scheduleFlush(delayMs) {
128
+ if (this.#flushTimer) {
129
+ clearTimeout(this.#flushTimer);
130
+ }
131
+ this.#flushTimer = setTimeout(() => {
132
+ this.#flush().catch(err => {
133
+ logger('Flush error:', err);
134
+ });
135
+ }, delayMs);
136
+ }
137
+ async #sendBatch(events) {
138
+ const requestBody = {
139
+ log_source: LOG_SOURCE,
140
+ request_time_ms: Date.now().toString(),
141
+ client_info: {
142
+ client_type: CLIENT_TYPE,
143
+ },
144
+ log_event: events.map(({ event, timestamp }) => ({
145
+ event_time_ms: timestamp.toString(),
146
+ source_extension_json: JSON.stringify(event),
147
+ })),
148
+ };
149
+ const controller = new AbortController();
150
+ const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
151
+ try {
152
+ const response = await fetch(this.#clearcutEndpoint, {
153
+ method: 'POST',
154
+ headers: {
155
+ 'Content-Type': 'application/json',
156
+ // Used in E2E tests to confirm that the watchdog process is killed
157
+ ...(this.#includePidHeader
158
+ ? { 'X-Watchdog-Pid': process.pid.toString() }
159
+ : {}),
160
+ },
161
+ body: JSON.stringify(requestBody),
162
+ signal: controller.signal,
163
+ });
164
+ clearTimeout(timeoutId);
165
+ if (response.ok) {
166
+ const data = (await response.json());
167
+ return {
168
+ success: true,
169
+ nextRequestWaitMs: data.next_request_wait_millis,
170
+ };
171
+ }
172
+ const status = response.status;
173
+ if (status >= 500 || status === 429) {
174
+ return { success: false };
175
+ }
176
+ logger('Telemetry permanent error:', status);
177
+ return { success: false, isPermanentError: true };
178
+ }
179
+ catch {
180
+ clearTimeout(timeoutId);
181
+ return { success: false };
182
+ }
183
+ }
184
+ async #finalFlush() {
185
+ if (this.#buffer.length === 0) {
186
+ return;
187
+ }
188
+ const eventsToSend = [...this.#buffer];
189
+ await this.#sendBatch(eventsToSend);
190
+ }
191
+ stopForTesting() {
192
+ if (this.#flushTimer) {
193
+ clearTimeout(this.#flushTimer);
194
+ this.#flushTimer = null;
195
+ }
196
+ this.#timerStarted = false;
197
+ }
198
+ get bufferSizeForTesting() {
199
+ return this.#buffer.length;
47
200
  }
48
201
  }
@@ -9,20 +9,50 @@ import { parseArgs } from 'node:util';
9
9
  import { logger, flushLogs, saveLogsToFile } from '../../logger.js';
10
10
  import { WatchdogMessageType } from '../types.js';
11
11
  import { ClearcutSender } from './clearcut-sender.js';
12
- function main() {
12
+ function parseWatchdogArgs() {
13
13
  const { values } = parseArgs({
14
14
  options: {
15
15
  'parent-pid': { type: 'string' },
16
16
  'app-version': { type: 'string' },
17
17
  'os-type': { type: 'string' },
18
18
  'log-file': { type: 'string' },
19
+ 'clearcut-endpoint': { type: 'string' },
20
+ 'clearcut-force-flush-interval-ms': { type: 'string' },
21
+ 'clearcut-include-pid-header': { type: 'boolean' },
19
22
  },
20
23
  strict: true,
21
24
  });
25
+ // Verify required arguments
22
26
  const parentPid = parseInt(values['parent-pid'] ?? '', 10);
23
27
  const appVersion = values['app-version'];
24
28
  const osType = parseInt(values['os-type'] ?? '', 10);
29
+ if (isNaN(parentPid) || !appVersion || isNaN(osType)) {
30
+ console.error('Invalid arguments provided for watchdog process: ', JSON.stringify({ parentPid, appVersion, osType }));
31
+ process.exit(1);
32
+ }
33
+ // Parse Optional Arguments
25
34
  const logFile = values['log-file'];
35
+ const clearcutEndpoint = values['clearcut-endpoint'];
36
+ const clearcutIncludePidHeader = values['clearcut-include-pid-header'];
37
+ let clearcutForceFlushIntervalMs;
38
+ if (values['clearcut-force-flush-interval-ms']) {
39
+ const parsed = parseInt(values['clearcut-force-flush-interval-ms'], 10);
40
+ if (!isNaN(parsed)) {
41
+ clearcutForceFlushIntervalMs = parsed;
42
+ }
43
+ }
44
+ return {
45
+ parentPid,
46
+ appVersion,
47
+ osType,
48
+ logFile,
49
+ clearcutEndpoint,
50
+ clearcutForceFlushIntervalMs,
51
+ clearcutIncludePidHeader,
52
+ };
53
+ }
54
+ function main() {
55
+ const { parentPid, appVersion, osType, logFile, clearcutEndpoint, clearcutForceFlushIntervalMs, clearcutIncludePidHeader, } = parseWatchdogArgs();
26
56
  let logStream;
27
57
  if (logFile) {
28
58
  logStream = saveLogsToFile(logFile);
@@ -35,18 +65,19 @@ function main() {
35
65
  process.exit(code);
36
66
  });
37
67
  };
38
- if (isNaN(parentPid) || !appVersion || isNaN(osType)) {
39
- logger('Invalid arguments provided for watchdog process: ', JSON.stringify({ parentPid, appVersion, osType }));
40
- exit(1);
41
- return;
42
- }
43
68
  logger('Watchdog started', JSON.stringify({
44
69
  pid: process.pid,
45
70
  parentPid,
46
71
  version: appVersion,
47
72
  osType,
48
73
  }, null, 2));
49
- const sender = new ClearcutSender(appVersion, osType);
74
+ const sender = new ClearcutSender({
75
+ appVersion,
76
+ osType: osType,
77
+ clearcutEndpoint,
78
+ forceFlushIntervalMs: clearcutForceFlushIntervalMs,
79
+ includePidHeader: clearcutIncludePidHeader,
80
+ });
50
81
  let isShuttingDown = false;
51
82
  function onParentDeath(reason) {
52
83
  if (isShuttingDown) {
@@ -79,9 +110,7 @@ function main() {
79
110
  }
80
111
  const msg = JSON.parse(line);
81
112
  if (msg.type === WatchdogMessageType.LOG_EVENT && msg.payload) {
82
- sender.send(msg.payload).catch(err => {
83
- logger('Error sending event', err);
84
- });
113
+ sender.enqueueEvent(msg.payload);
85
114
  }
86
115
  }
87
116
  catch (err) {
@@ -19,6 +19,15 @@ export class WatchdogClient {
19
19
  if (config.logFile) {
20
20
  args.push(`--log-file=${config.logFile}`);
21
21
  }
22
+ if (config.clearcutEndpoint) {
23
+ args.push(`--clearcut-endpoint=${config.clearcutEndpoint}`);
24
+ }
25
+ if (config.clearcutForceFlushIntervalMs) {
26
+ args.push(`--clearcut-force-flush-interval-ms=${config.clearcutForceFlushIntervalMs}`);
27
+ }
28
+ if (config.clearcutIncludePidHeader) {
29
+ args.push('--clearcut-include-pid-header');
30
+ }
22
31
  const spawner = options?.spawn ?? spawn;
23
32
  this.#childProcess = spawner(process.execPath, args, {
24
33
  stdio: ['pipe', 'ignore', 'ignore'],