chrome-devtools-mcp 0.20.3 → 0.22.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.
Files changed (55) hide show
  1. package/README.md +97 -20
  2. package/build/src/HeapSnapshotManager.js +94 -0
  3. package/build/src/McpContext.js +26 -49
  4. package/build/src/McpPage.js +16 -0
  5. package/build/src/McpResponse.js +220 -12
  6. package/build/src/PageCollector.js +14 -28
  7. package/build/src/WaitForHelper.js +31 -0
  8. package/build/src/bin/check-latest-version.js +25 -0
  9. package/build/src/bin/chrome-devtools-mcp-cli-options.js +28 -9
  10. package/build/src/bin/chrome-devtools-mcp-main.js +2 -0
  11. package/build/src/bin/chrome-devtools-mcp.js +1 -0
  12. package/build/src/bin/chrome-devtools.js +9 -3
  13. package/build/src/bin/cliDefinitions.js +15 -9
  14. package/build/src/daemon/client.js +1 -1
  15. package/build/src/daemon/daemon.js +2 -6
  16. package/build/src/daemon/utils.js +1 -0
  17. package/build/src/formatters/HeapSnapshotFormatter.js +38 -0
  18. package/build/src/formatters/NetworkFormatter.js +24 -7
  19. package/build/src/index.js +22 -1
  20. package/build/src/telemetry/ClearcutLogger.js +145 -6
  21. package/build/src/telemetry/flagUtils.js +46 -4
  22. package/build/src/telemetry/toolMetricsUtils.js +88 -0
  23. package/build/src/telemetry/types.js +5 -0
  24. package/build/src/telemetry/watchdog/ClearcutSender.js +4 -3
  25. package/build/src/third_party/THIRD_PARTY_NOTICES +1400 -483
  26. package/build/src/third_party/bundled-packages.json +6 -5
  27. package/build/src/third_party/devtools-formatter-worker.js +61 -66
  28. package/build/src/third_party/devtools-heap-snapshot-worker.js +9690 -0
  29. package/build/src/third_party/index.js +61622 -52803
  30. package/build/src/third_party/issue-descriptions/sharedDictionaryUseErrorCrossOriginNoCorsRequest.md +1 -0
  31. package/build/src/third_party/lighthouse-devtools-mcp-bundle.js +10589 -4647
  32. package/build/src/tools/categories.js +5 -0
  33. package/build/src/tools/console.js +42 -39
  34. package/build/src/tools/emulation.js +1 -1
  35. package/build/src/tools/extensions.js +5 -11
  36. package/build/src/tools/inPage.js +105 -0
  37. package/build/src/tools/input.js +18 -16
  38. package/build/src/tools/lighthouse.js +3 -3
  39. package/build/src/tools/memory.js +50 -5
  40. package/build/src/tools/network.js +2 -2
  41. package/build/src/tools/pages.js +14 -6
  42. package/build/src/tools/performance.js +1 -1
  43. package/build/src/tools/screencast.js +2 -1
  44. package/build/src/tools/screenshot.js +3 -3
  45. package/build/src/tools/script.js +22 -16
  46. package/build/src/tools/tools.js +4 -0
  47. package/build/src/tools/webmcp.js +63 -0
  48. package/build/src/utils/check-for-updates.js +73 -0
  49. package/build/src/utils/files.js +4 -0
  50. package/build/src/utils/id.js +15 -0
  51. package/build/src/version.js +1 -1
  52. package/package.json +13 -9
  53. package/build/src/third_party/issue-descriptions/sharedDictionaryUseErrorNoCorpCrossOriginNoCorsRequest.md +0 -3
  54. package/build/src/third_party/issue-descriptions/sharedDictionaryWriteErrorNoCorpCossOriginNoCorsRequest.md +0 -3
  55. package/build/src/utils/ExtensionRegistry.js +0 -35
@@ -4,14 +4,17 @@
4
4
  * Copyright 2026 Google LLC
5
5
  * SPDX-License-Identifier: Apache-2.0
6
6
  */
7
+ process.title = 'chrome-devtools';
7
8
  import process from 'node:process';
8
9
  import { startDaemon, stopDaemon, sendCommand, handleResponse, } from '../daemon/client.js';
9
10
  import { isDaemonRunning, serializeArgs } from '../daemon/utils.js';
10
11
  import { logDisclaimers } from '../index.js';
11
12
  import { hideBin, yargs } from '../third_party/index.js';
13
+ import { checkForUpdates } from '../utils/check-for-updates.js';
12
14
  import { VERSION } from '../version.js';
13
15
  import { commands } from './chrome-devtools-cli-options.js';
14
16
  import { cliOptions, parseArguments } from './chrome-devtools-mcp-cli-options.js';
17
+ await checkForUpdates('Run `npm install -g chrome-devtools-mcp@latest` and `chrome-devtools start` to update and restart the daemon.');
15
18
  async function start(args) {
16
19
  const combinedArgs = [...args, ...defaultArgs];
17
20
  await startDaemon(combinedArgs);
@@ -26,9 +29,10 @@ delete startCliOptions.autoConnect;
26
29
  // Missing CLI serialization.
27
30
  delete startCliOptions.viewport;
28
31
  // CLI is generated based on the default tool definitions. To enable conditional
29
- // tools, they needs to be enabled during CLI generation.
32
+ // tools, they need to be enabled during CLI generation.
30
33
  delete startCliOptions.experimentalPageIdRouting;
31
34
  delete startCliOptions.experimentalVision;
35
+ delete startCliOptions.experimentalWebmcp;
32
36
  delete startCliOptions.experimentalInteropTools;
33
37
  delete startCliOptions.experimentalScreencast;
34
38
  delete startCliOptions.categoryEmulation;
@@ -42,9 +46,11 @@ if (!('default' in cliOptions.headless)) {
42
46
  throw new Error('headless cli option unexpectedly does not have a default');
43
47
  }
44
48
  if ('default' in cliOptions.isolated) {
45
- throw new Error('headless cli option unexpectedly does not have a default');
49
+ throw new Error('isolated cli option unexpectedly has a default');
46
50
  }
47
51
  startCliOptions.headless.default = true;
52
+ startCliOptions.isolated.description =
53
+ 'If specified, creates a temporary user-data-dir that is automatically cleaned up after the browser is closed. Defaults to true unless userDataDir is provided.';
48
54
  const y = yargs(hideBin(process.argv))
49
55
  .scriptName('chrome-devtools')
50
56
  .showHelpOnFail(true)
@@ -63,7 +69,7 @@ y.command('start', 'Start or restart chrome-devtools-mcp', y => y
63
69
  await stopDaemon();
64
70
  }
65
71
  // Defaults but we do not want to affect the yargs conflict resolution.
66
- if (argv.isolated === undefined) {
72
+ if (argv.isolated === undefined && argv.userDataDir === undefined) {
67
73
  argv.isolated = true;
68
74
  }
69
75
  if (argv.headless === undefined) {
@@ -84,7 +84,7 @@ export const commands = {
84
84
  geolocation: {
85
85
  name: 'geolocation',
86
86
  type: 'string',
87
- description: 'Geolocation (`<latitude>x<longitude>`) to emulate. Latitude between -90 and 90. Longitude between -180 and 180. Omit clear the geolocation override.',
87
+ description: 'Geolocation (`<latitude>x<longitude>`) to emulate. Latitude between -90 and 90. Longitude between -180 and 180. Omit to clear the geolocation override.',
88
88
  required: false,
89
89
  },
90
90
  userAgent: {
@@ -124,10 +124,16 @@ export const commands = {
124
124
  description: 'An optional list of arguments to pass to the function.',
125
125
  required: false,
126
126
  },
127
+ dialogAction: {
128
+ name: 'dialogAction',
129
+ type: 'string',
130
+ description: 'Handle dialogs while execution. "accept", "dismiss", or string for response of window.prompt. Defaults to accept.',
131
+ required: false,
132
+ },
127
133
  },
128
134
  },
129
135
  fill: {
130
- description: 'Type text into a input, text area or select an option from a <select> element.',
136
+ description: 'Type text into an input, text area or select an option from a <select> element.',
131
137
  category: 'Input automation',
132
138
  args: {
133
139
  uid: {
@@ -175,13 +181,13 @@ export const commands = {
175
181
  requestFilePath: {
176
182
  name: 'requestFilePath',
177
183
  type: 'string',
178
- description: 'The absolute or relative path to save the request body to. If omitted, the body is returned inline.',
184
+ description: 'The absolute or relative path to a .network-request file to save the request body to. If omitted, the body is returned inline.',
179
185
  required: false,
180
186
  },
181
187
  responseFilePath: {
182
188
  name: 'responseFilePath',
183
189
  type: 'string',
184
- description: 'The absolute or relative path to save the response body to. If omitted, the body is returned inline.',
190
+ description: 'The absolute or relative path to a .network-response file to save the response body to. If omitted, the body is returned inline.',
185
191
  required: false,
186
192
  },
187
193
  },
@@ -258,7 +264,7 @@ export const commands = {
258
264
  pageSize: {
259
265
  name: 'pageSize',
260
266
  type: 'integer',
261
- description: 'Maximum number of messages to return. When omitted, returns all requests.',
267
+ description: 'Maximum number of messages to return. When omitted, returns all messages.',
262
268
  required: false,
263
269
  },
264
270
  pageIdx: {
@@ -314,7 +320,7 @@ export const commands = {
314
320
  },
315
321
  },
316
322
  list_pages: {
317
- description: 'Get a list of pages open in the browser.',
323
+ description: 'Get a list of pages open in the browser.',
318
324
  category: 'Navigation automation',
319
325
  args: {},
320
326
  },
@@ -503,8 +509,8 @@ export const commands = {
503
509
  },
504
510
  },
505
511
  take_memory_snapshot: {
506
- description: 'Capture a memory heapsnapshot of the currently selected page to memory leak debugging',
507
- category: 'Performance',
512
+ description: 'Capture a heap snapshot of the currently selected page. Use to analyze the memory distribution of JavaScript objects and debug memory leaks.',
513
+ category: 'Memory',
508
514
  args: {
509
515
  filePath: {
510
516
  name: 'filePath',
@@ -535,7 +541,7 @@ export const commands = {
535
541
  uid: {
536
542
  name: 'uid',
537
543
  type: 'string',
538
- description: 'The uid of an element on the page from the page content snapshot. If omitted takes a pages screenshot.',
544
+ description: 'The uid of an element on the page from the page content snapshot. If omitted, takes a page screenshot.',
539
545
  required: false,
540
546
  },
541
547
  fullPage: {
@@ -135,7 +135,7 @@ export async function handleResponse(response, format) {
135
135
  case 'image/jpeg':
136
136
  extension = '.jpeg';
137
137
  break;
138
- case 'webp':
138
+ case 'image/webp':
139
139
  extension = '.webp';
140
140
  break;
141
141
  }
@@ -11,7 +11,7 @@ import process from 'node:process';
11
11
  import { logger } from '../logger.js';
12
12
  import { Client, PipeTransport, StdioClientTransport, } from '../third_party/index.js';
13
13
  import { VERSION } from '../version.js';
14
- import { getDaemonPid, getPidFilePath, getSocketPath, INDEX_SCRIPT_PATH, IS_WINDOWS, isDaemonRunning, } from './utils.js';
14
+ import { DAEMON_CLIENT_NAME, getDaemonPid, getPidFilePath, getSocketPath, INDEX_SCRIPT_PATH, IS_WINDOWS, isDaemonRunning, } from './utils.js';
15
15
  const pid = getDaemonPid();
16
16
  if (isDaemonRunning(pid)) {
17
17
  logger('Another daemon process is running.');
@@ -32,17 +32,13 @@ let server = null;
32
32
  async function setupMCPClient() {
33
33
  console.log('Setting up MCP client connection...');
34
34
  // Create stdio transport for chrome-devtools-mcp
35
- // Workaround for https://github.com/modelcontextprotocol/typescript-sdk/blob/v1.x/src/client/stdio.ts#L128
36
- // which causes the console window to show on Windows.
37
- // @ts-expect-error no types for type.
38
- process.type = 'mcp-client';
39
35
  mcpTransport = new StdioClientTransport({
40
36
  command: process.execPath,
41
37
  args: [INDEX_SCRIPT_PATH, ...mcpServerArgs],
42
38
  env: process.env,
43
39
  });
44
40
  mcpClient = new Client({
45
- name: 'chrome-devtools-cli-daemon',
41
+ name: DAEMON_CLIENT_NAME,
46
42
  version: VERSION,
47
43
  }, {
48
44
  capabilities: {},
@@ -11,6 +11,7 @@ import { logger } from '../logger.js';
11
11
  export const DAEMON_SCRIPT_PATH = path.join(import.meta.dirname, 'daemon.js');
12
12
  export const INDEX_SCRIPT_PATH = path.join(import.meta.dirname, '..', 'bin', 'chrome-devtools-mcp.js');
13
13
  const APP_NAME = 'chrome-devtools-mcp';
14
+ export const DAEMON_CLIENT_NAME = 'chrome-devtools-cli-daemon';
14
15
  // Using these paths due to strict limits on the POSIX socket path length.
15
16
  export function getSocketPath() {
16
17
  const uid = os.userInfo().uid;
@@ -0,0 +1,38 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+ import { stableIdSymbol } from '../utils/id.js';
7
+ export class HeapSnapshotFormatter {
8
+ #aggregates;
9
+ constructor(aggregates) {
10
+ this.#aggregates = aggregates;
11
+ }
12
+ #getSortedAggregates() {
13
+ return Object.values(this.#aggregates).sort((a, b) => b.self - a.self);
14
+ }
15
+ toString() {
16
+ const sorted = this.#getSortedAggregates();
17
+ const lines = [];
18
+ lines.push('uid,className,count,selfSize,maxRetainedSize');
19
+ for (const info of sorted) {
20
+ const uid = info[stableIdSymbol] ?? '';
21
+ lines.push(`${uid},"${info.name}",${info.count},${info.self},${info.maxRet}`);
22
+ }
23
+ return lines.join('\n');
24
+ }
25
+ toJSON() {
26
+ const sorted = this.#getSortedAggregates();
27
+ return sorted.map(info => ({
28
+ uid: info[stableIdSymbol],
29
+ className: info.name,
30
+ count: info.count,
31
+ selfSize: info.self,
32
+ retainedSize: info.maxRet,
33
+ }));
34
+ }
35
+ static sort(aggregates) {
36
+ return Object.entries(aggregates).sort((a, b) => b[1].self - a[1].self);
37
+ }
38
+ }
@@ -4,6 +4,7 @@
4
4
  * SPDX-License-Identifier: Apache-2.0
5
5
  * */
6
6
  import { isUtf8 } from 'node:buffer';
7
+ import { DevTools, } from '../third_party/index.js';
7
8
  const BODY_CONTEXT_SIZE_LIMIT = 10000;
8
9
  export class NetworkFormatter {
9
10
  #request;
@@ -40,8 +41,8 @@ export class NetworkFormatter {
40
41
  throw new Error('saveFile is not provided');
41
42
  }
42
43
  if (data) {
43
- await this.#options.saveFile(Buffer.from(data), this.#options.requestFilePath);
44
- this.#requestBodyFilePath = this.#options.requestFilePath;
44
+ const result = await this.#options.saveFile(Buffer.from(data), this.#options.requestFilePath, '.network-request');
45
+ this.#requestBodyFilePath = result.filename;
45
46
  }
46
47
  else {
47
48
  this.#requestBody = requestBodyNotAvailableMessage;
@@ -66,8 +67,8 @@ export class NetworkFormatter {
66
67
  if (!this.#options.saveFile) {
67
68
  throw new Error('saveFile is not provided');
68
69
  }
69
- await this.#options.saveFile(buffer, this.#options.responseFilePath);
70
- this.#responseBodyFilePath = this.#options.responseFilePath;
70
+ const result = await this.#options.saveFile(buffer, this.#options.responseFilePath, '.network-response');
71
+ this.#responseBodyFilePath = result.filename;
71
72
  }
72
73
  catch {
73
74
  // Flatten error handling for buffer() failure and save failure
@@ -96,6 +97,16 @@ export class NetworkFormatter {
96
97
  selectedInDevToolsUI: this.#options.selectedInDevToolsUI,
97
98
  };
98
99
  }
100
+ #redactNetworkHeaders(headers) {
101
+ const headersList = Object.entries(headers).map(item => {
102
+ return { name: item[0], value: item[1] };
103
+ });
104
+ const redacted = DevTools.NetworkRequestFormatter.sanitizeHeaders(headersList);
105
+ return redacted.reduce((acc, item) => {
106
+ acc[item.name] = item.value;
107
+ return acc;
108
+ }, {});
109
+ }
99
110
  toJSONDetailed() {
100
111
  const redirectChain = this.#request.redirectChain();
101
112
  const formattedRedirectChain = redirectChain.reverse().map(request => {
@@ -105,15 +116,21 @@ export class NetworkFormatter {
105
116
  const formatter = new NetworkFormatter(request, {
106
117
  requestId: id,
107
118
  saveFile: this.#options.saveFile,
119
+ redactNetworkHeaders: this.#options.redactNetworkHeaders,
108
120
  });
109
121
  return formatter.toJSON();
110
122
  });
123
+ const responseHeaders = this.#request.response()?.headers();
111
124
  return {
112
125
  ...this.toJSON(),
113
- requestHeaders: this.#request.headers(),
126
+ requestHeaders: this.#options.redactNetworkHeaders
127
+ ? this.#redactNetworkHeaders(this.#request.headers())
128
+ : this.#request.headers(),
114
129
  requestBody: this.#requestBody,
115
130
  requestBodyFilePath: this.#requestBodyFilePath,
116
- responseHeaders: this.#request.response()?.headers(),
131
+ responseHeaders: this.#options.redactNetworkHeaders && responseHeaders
132
+ ? this.#redactNetworkHeaders(responseHeaders)
133
+ : this.#request.response()?.headers(),
117
134
  responseBody: this.#responseBody,
118
135
  responseBodyFilePath: this.#responseBodyFilePath,
119
136
  failure: this.#request.failure()?.errorText,
@@ -210,7 +227,7 @@ function converNetworkRequestDetailedToStringDetailed(data) {
210
227
  response.push(`### Redirect chain`);
211
228
  let indent = 0;
212
229
  for (const request of redirectChain.reverse()) {
213
- response.push(`${' '.repeat(indent)}${convertNetworkRequestConciseToString(request)})}`);
230
+ response.push(`${' '.repeat(indent)}${convertNetworkRequestConciseToString(request)}`);
214
231
  indent++;
215
232
  }
216
233
  }
@@ -36,6 +36,12 @@ export async function createMcpServer(serverArgs, options) {
36
36
  server.server.setRequestHandler(SetLevelRequestSchema, () => {
37
37
  return {};
38
38
  });
39
+ server.server.oninitialized = () => {
40
+ const clientName = server.server.getClientVersion()?.name;
41
+ if (clientName) {
42
+ clearcutLogger?.setClientName(clientName);
43
+ }
44
+ };
39
45
  let context;
40
46
  async function getContext() {
41
47
  const chromeArgs = (serverArgs.chromeArg ?? []).map(String);
@@ -95,13 +101,21 @@ export async function createMcpServer(serverArgs, options) {
95
101
  return;
96
102
  }
97
103
  if (tool.annotations.category === ToolCategory.EXTENSIONS &&
98
- !serverArgs.categoryExtensions) {
104
+ serverArgs.categoryExtensions === false) {
105
+ return;
106
+ }
107
+ if (tool.annotations.category === ToolCategory.IN_PAGE &&
108
+ !serverArgs.categoryInPageTools) {
99
109
  return;
100
110
  }
101
111
  if (tool.annotations.conditions?.includes('computerVision') &&
102
112
  !serverArgs.experimentalVision) {
103
113
  return;
104
114
  }
115
+ if (tool.annotations.conditions?.includes('experimentalMemory') &&
116
+ !serverArgs.experimentalMemory) {
117
+ return;
118
+ }
105
119
  if (tool.annotations.conditions?.includes('experimentalInteropTools') &&
106
120
  !serverArgs.experimentalInteropTools) {
107
121
  return;
@@ -110,6 +124,10 @@ export async function createMcpServer(serverArgs, options) {
110
124
  !serverArgs.experimentalScreencast) {
111
125
  return;
112
126
  }
127
+ if (tool.annotations.conditions?.includes('experimentalWebmcp') &&
128
+ !serverArgs.experimentalWebmcp) {
129
+ return;
130
+ }
113
131
  const schema = 'pageScoped' in tool &&
114
132
  tool.pageScoped &&
115
133
  serverArgs.experimentalPageIdRouting &&
@@ -132,6 +150,7 @@ export async function createMcpServer(serverArgs, options) {
132
150
  const response = serverArgs.slim
133
151
  ? new SlimMcpResponse(serverArgs)
134
152
  : new McpResponse(serverArgs);
153
+ response.setRedactNetworkHeaders(serverArgs.redactNetworkHeaders);
135
154
  if ('pageScoped' in tool && tool.pageScoped) {
136
155
  const page = serverArgs.experimentalPageIdRouting &&
137
156
  params.pageId &&
@@ -180,6 +199,8 @@ export async function createMcpServer(serverArgs, options) {
180
199
  finally {
181
200
  void clearcutLogger?.logToolInvocation({
182
201
  toolName: tool.name,
202
+ params,
203
+ schema,
183
204
  success,
184
205
  latencyMs: bucketizeLatency(Date.now() - startTime),
185
206
  });
@@ -4,11 +4,115 @@
4
4
  * SPDX-License-Identifier: Apache-2.0
5
5
  */
6
6
  import process from 'node:process';
7
+ import { DAEMON_CLIENT_NAME } from '../daemon/utils.js';
7
8
  import { logger } from '../logger.js';
8
9
  import { FilePersistence } from './persistence.js';
9
- import { WatchdogMessageType, OsType } from './types.js';
10
+ import { McpClient, WatchdogMessageType, OsType, } from './types.js';
10
11
  import { WatchdogClient } from './WatchdogClient.js';
11
12
  const MS_PER_DAY = 24 * 60 * 60 * 1000;
13
+ export const PARAM_BLOCKLIST = new Set(['uid', 'reqid', 'msgid']);
14
+ const SUPPORTED_ZOD_TYPES = [
15
+ 'ZodString',
16
+ 'ZodNumber',
17
+ 'ZodBoolean',
18
+ 'ZodArray',
19
+ 'ZodEnum',
20
+ ];
21
+ function isZodType(type) {
22
+ return SUPPORTED_ZOD_TYPES.includes(type);
23
+ }
24
+ export function getZodType(zodType) {
25
+ const def = zodType._def;
26
+ const typeName = def.typeName;
27
+ if (typeName === 'ZodOptional' ||
28
+ typeName === 'ZodDefault' ||
29
+ typeName === 'ZodNullable') {
30
+ return getZodType(def.innerType);
31
+ }
32
+ if (typeName === 'ZodEffects') {
33
+ return getZodType(def.schema);
34
+ }
35
+ if (isZodType(typeName)) {
36
+ return typeName;
37
+ }
38
+ throw new Error(`Unsupported zod type for tool parameter: ${typeName}`);
39
+ }
40
+ export function transformArgName(zodType, name) {
41
+ const snakeCaseName = name.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`);
42
+ if (zodType === 'ZodString') {
43
+ return `${snakeCaseName}_length`;
44
+ }
45
+ else if (zodType === 'ZodArray') {
46
+ return `${snakeCaseName}_count`;
47
+ }
48
+ else {
49
+ return snakeCaseName;
50
+ }
51
+ }
52
+ export function transformArgType(zodType) {
53
+ if (zodType === 'ZodString' || zodType === 'ZodArray') {
54
+ return 'number';
55
+ }
56
+ switch (zodType) {
57
+ case 'ZodNumber':
58
+ return 'number';
59
+ case 'ZodBoolean':
60
+ return 'boolean';
61
+ case 'ZodEnum':
62
+ return 'enum';
63
+ default:
64
+ throw new Error(`Unsupported zod type for tool parameter: ${zodType}`);
65
+ }
66
+ }
67
+ function transformValue(zodType, value) {
68
+ if (zodType === 'ZodString') {
69
+ return value.length;
70
+ }
71
+ else if (zodType === 'ZodArray') {
72
+ return value.length;
73
+ }
74
+ else {
75
+ return value;
76
+ }
77
+ }
78
+ function hasEquivalentType(zodType, value) {
79
+ if (zodType === 'ZodString') {
80
+ return typeof value === 'string';
81
+ }
82
+ else if (zodType === 'ZodArray') {
83
+ return Array.isArray(value);
84
+ }
85
+ else if (zodType === 'ZodNumber') {
86
+ return typeof value === 'number';
87
+ }
88
+ else if (zodType === 'ZodBoolean') {
89
+ return typeof value === 'boolean';
90
+ }
91
+ else if (zodType === 'ZodEnum') {
92
+ return (typeof value === 'string' ||
93
+ typeof value === 'number' ||
94
+ typeof value === 'boolean');
95
+ }
96
+ else {
97
+ return false;
98
+ }
99
+ }
100
+ export function sanitizeParams(params, schema) {
101
+ const transformed = {};
102
+ for (const [name, value] of Object.entries(params)) {
103
+ if (PARAM_BLOCKLIST.has(name)) {
104
+ continue;
105
+ }
106
+ const zodType = getZodType(schema[name]);
107
+ if (!hasEquivalentType(zodType, value)) {
108
+ throw new Error(`parameter ${name} has type ${zodType} but value ${value} is not of equivalent type`);
109
+ }
110
+ const transformedName = transformArgName(zodType, name);
111
+ const transformedValue = transformValue(zodType, value);
112
+ transformed[transformedName] = transformedValue;
113
+ }
114
+ return transformed;
115
+ }
12
116
  function detectOsType() {
13
117
  switch (process.platform) {
14
118
  case 'win32':
@@ -24,6 +128,7 @@ function detectOsType() {
24
128
  export class ClearcutLogger {
25
129
  #persistence;
26
130
  #watchdog;
131
+ #mcpClient;
27
132
  constructor(options) {
28
133
  this.#persistence = options.persistence ?? new FilePersistence();
29
134
  this.#watchdog =
@@ -37,16 +142,48 @@ export class ClearcutLogger {
37
142
  clearcutForceFlushIntervalMs: options.clearcutForceFlushIntervalMs,
38
143
  clearcutIncludePidHeader: options.clearcutIncludePidHeader,
39
144
  });
145
+ this.#mcpClient = McpClient.MCP_CLIENT_UNSPECIFIED;
146
+ }
147
+ setClientName(clientName) {
148
+ const lowerName = clientName.toLowerCase();
149
+ if (lowerName.includes('claude')) {
150
+ this.#mcpClient = McpClient.MCP_CLIENT_CLAUDE_CODE;
151
+ }
152
+ else if (lowerName.includes('gemini')) {
153
+ this.#mcpClient = McpClient.MCP_CLIENT_GEMINI_CLI;
154
+ }
155
+ else if (clientName === DAEMON_CLIENT_NAME) {
156
+ this.#mcpClient = McpClient.MCP_CLIENT_DT_MCP_CLI;
157
+ }
158
+ else if (lowerName.includes('openclaw')) {
159
+ this.#mcpClient = McpClient.MCP_CLIENT_OPENCLAW;
160
+ }
161
+ else if (lowerName.includes('codex')) {
162
+ this.#mcpClient = McpClient.MCP_CLIENT_CODEX;
163
+ }
164
+ else if (lowerName.includes('antigravity')) {
165
+ this.#mcpClient = McpClient.MCP_CLIENT_ANTIGRAVITY;
166
+ }
167
+ else {
168
+ this.#mcpClient = McpClient.MCP_CLIENT_OTHER;
169
+ }
40
170
  }
41
171
  async logToolInvocation(args) {
172
+ const tool_invocation = {
173
+ tool_name: args.toolName,
174
+ success: args.success,
175
+ latency_ms: args.latencyMs,
176
+ };
177
+ if (Object.keys(args.params).length > 0) {
178
+ tool_invocation.tool_params = {
179
+ [`${args.toolName}_params`]: sanitizeParams(args.params, args.schema),
180
+ };
181
+ }
42
182
  this.#watchdog.send({
43
183
  type: WatchdogMessageType.LOG_EVENT,
44
184
  payload: {
45
- tool_invocation: {
46
- tool_name: args.toolName,
47
- success: args.success,
48
- latency_ms: args.latencyMs,
49
- },
185
+ mcp_client: this.#mcpClient,
186
+ tool_invocation: tool_invocation,
50
187
  },
51
188
  });
52
189
  }
@@ -54,6 +191,7 @@ export class ClearcutLogger {
54
191
  this.#watchdog.send({
55
192
  type: WatchdogMessageType.LOG_EVENT,
56
193
  payload: {
194
+ mcp_client: this.#mcpClient,
57
195
  server_start: {
58
196
  flag_usage: flagUsage,
59
197
  },
@@ -74,6 +212,7 @@ export class ClearcutLogger {
74
212
  this.#watchdog.send({
75
213
  type: WatchdogMessageType.LOG_EVENT,
76
214
  payload: {
215
+ mcp_client: this.#mcpClient,
77
216
  daily_active: {
78
217
  days_since_last_active: daysSince,
79
218
  },
@@ -4,6 +4,14 @@
4
4
  * SPDX-License-Identifier: Apache-2.0
5
5
  */
6
6
  import { toSnakeCase } from '../utils/string.js';
7
+ /**
8
+ * For enums, log the value as uppercase.
9
+ * We're going to have an enum for such flags with choices represented
10
+ * as an `enum` where the keys of the enum will map to the uppercase `choice`.
11
+ */
12
+ function formatEnumChoice(snakeCaseName, choice) {
13
+ return `${snakeCaseName}_${choice}`.toUpperCase();
14
+ }
7
15
  /**
8
16
  * Computes telemetry flag usage from parsed arguments and CLI options.
9
17
  *
@@ -14,6 +22,8 @@ import { toSnakeCase } from '../utils/string.js';
14
22
  * - The provided value differs from the default value.
15
23
  * - Boolean flags are logged with their literal value.
16
24
  * - String flags with defined `choices` (Enums) are logged as their uppercase value.
25
+ *
26
+ * IMPORTANT: keep getPossibleFlagMetrics() in sync with this function.
17
27
  */
18
28
  export function computeFlagUsage(args, options) {
19
29
  const usage = {};
@@ -35,11 +45,43 @@ export function computeFlagUsage(args, options) {
35
45
  typeof value === 'string' &&
36
46
  'choices' in config &&
37
47
  config.choices) {
38
- // For enums, log the value as uppercase
39
- // We're going to have an enum for such flags with choices represented
40
- // as an `enum` where the keys of the enum will map to the uppercase `choice`.
41
- usage[snakeCaseName] = `${snakeCaseName}_${value}`.toUpperCase();
48
+ usage[snakeCaseName] = formatEnumChoice(snakeCaseName, value);
42
49
  }
43
50
  }
44
51
  return usage;
45
52
  }
53
+ /**
54
+ * Computes the list of possible flag metrics based on the CLI options.
55
+ *
56
+ * IMPORTANT: keep this function in sync with computeFlagUsage().
57
+ */
58
+ export function getPossibleFlagMetrics(options) {
59
+ const metrics = [];
60
+ for (const [flagName, config] of Object.entries(options)) {
61
+ const snakeCaseName = toSnakeCase(flagName);
62
+ // _present is always a possible metric
63
+ metrics.push({
64
+ name: `${snakeCaseName}_present`,
65
+ flagType: 'boolean',
66
+ });
67
+ if (config.type === 'boolean') {
68
+ metrics.push({
69
+ name: snakeCaseName,
70
+ flagType: 'boolean',
71
+ });
72
+ }
73
+ else if (config.type === 'string' &&
74
+ 'choices' in config &&
75
+ config.choices) {
76
+ metrics.push({
77
+ name: snakeCaseName,
78
+ flagType: 'enum',
79
+ choices: [
80
+ `${snakeCaseName.toUpperCase()}_UNSPECIFIED`,
81
+ ...config.choices.map(choice => formatEnumChoice(snakeCaseName, choice)),
82
+ ],
83
+ });
84
+ }
85
+ }
86
+ return metrics;
87
+ }