chrome-devtools-mcp 0.14.0 → 0.15.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.
package/README.md CHANGED
@@ -27,6 +27,20 @@ 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
+
30
44
  ## Requirements
31
45
 
32
46
  - [Node.js](https://nodejs.org/) v20.19 or a newer [latest maintenance LTS](https://github.com/nodejs/Release#release-schedule) version.
@@ -450,6 +464,11 @@ The Chrome DevTools MCP server supports the following configuration option:
450
464
  - **Type:** boolean
451
465
  - **Default:** `true`
452
466
 
467
+ - **`--usageStatistics`/ `--usage-statistics`**
468
+ 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.
469
+ - **Type:** boolean
470
+ - **Default:** `true`
471
+
453
472
  <!-- END AUTO GENERATED OPTIONS -->
454
473
 
455
474
  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
  }
@@ -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'
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.',
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,7 +20,7 @@ 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.0';
24
24
  // x-release-please-end
25
25
  export const args = parseArguments(VERSION);
26
26
  const logFile = args.logFile ? saveLogsToFile(args.logFile) : undefined;
@@ -29,6 +29,9 @@ if (args.usageStatistics) {
29
29
  clearcutLogger = new ClearcutLogger({
30
30
  logFile: args.logFile,
31
31
  appVersion: VERSION,
32
+ clearcutEndpoint: args.clearcutEndpoint,
33
+ clearcutForceFlushIntervalMs: args.clearcutForceFlushIntervalMs,
34
+ clearcutIncludePidHeader: args.clearcutIncludePidHeader,
32
35
  });
33
36
  }
34
37
  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'],