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.
- package/README.md +97 -20
- package/build/src/HeapSnapshotManager.js +94 -0
- package/build/src/McpContext.js +26 -49
- package/build/src/McpPage.js +16 -0
- package/build/src/McpResponse.js +220 -12
- package/build/src/PageCollector.js +14 -28
- package/build/src/WaitForHelper.js +31 -0
- package/build/src/bin/check-latest-version.js +25 -0
- package/build/src/bin/chrome-devtools-mcp-cli-options.js +28 -9
- package/build/src/bin/chrome-devtools-mcp-main.js +2 -0
- package/build/src/bin/chrome-devtools-mcp.js +1 -0
- package/build/src/bin/chrome-devtools.js +9 -3
- package/build/src/bin/cliDefinitions.js +15 -9
- package/build/src/daemon/client.js +1 -1
- package/build/src/daemon/daemon.js +2 -6
- package/build/src/daemon/utils.js +1 -0
- package/build/src/formatters/HeapSnapshotFormatter.js +38 -0
- package/build/src/formatters/NetworkFormatter.js +24 -7
- package/build/src/index.js +22 -1
- package/build/src/telemetry/ClearcutLogger.js +145 -6
- package/build/src/telemetry/flagUtils.js +46 -4
- package/build/src/telemetry/toolMetricsUtils.js +88 -0
- package/build/src/telemetry/types.js +5 -0
- package/build/src/telemetry/watchdog/ClearcutSender.js +4 -3
- package/build/src/third_party/THIRD_PARTY_NOTICES +1400 -483
- package/build/src/third_party/bundled-packages.json +6 -5
- package/build/src/third_party/devtools-formatter-worker.js +61 -66
- package/build/src/third_party/devtools-heap-snapshot-worker.js +9690 -0
- package/build/src/third_party/index.js +61622 -52803
- package/build/src/third_party/issue-descriptions/sharedDictionaryUseErrorCrossOriginNoCorsRequest.md +1 -0
- package/build/src/third_party/lighthouse-devtools-mcp-bundle.js +10589 -4647
- package/build/src/tools/categories.js +5 -0
- package/build/src/tools/console.js +42 -39
- package/build/src/tools/emulation.js +1 -1
- package/build/src/tools/extensions.js +5 -11
- package/build/src/tools/inPage.js +105 -0
- package/build/src/tools/input.js +18 -16
- package/build/src/tools/lighthouse.js +3 -3
- package/build/src/tools/memory.js +50 -5
- package/build/src/tools/network.js +2 -2
- package/build/src/tools/pages.js +14 -6
- package/build/src/tools/performance.js +1 -1
- package/build/src/tools/screencast.js +2 -1
- package/build/src/tools/screenshot.js +3 -3
- package/build/src/tools/script.js +22 -16
- package/build/src/tools/tools.js +4 -0
- package/build/src/tools/webmcp.js +63 -0
- package/build/src/utils/check-for-updates.js +73 -0
- package/build/src/utils/files.js +4 -0
- package/build/src/utils/id.js +15 -0
- package/build/src/version.js +1 -1
- package/package.json +13 -9
- package/build/src/third_party/issue-descriptions/sharedDictionaryUseErrorNoCorpCrossOriginNoCorsRequest.md +0 -3
- package/build/src/third_party/issue-descriptions/sharedDictionaryWriteErrorNoCorpCossOriginNoCorsRequest.md +0 -3
- 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
|
|
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('
|
|
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
|
|
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
|
|
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
|
|
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
|
|
507
|
-
category: '
|
|
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
|
|
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: {
|
|
@@ -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:
|
|
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 =
|
|
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 =
|
|
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.#
|
|
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.#
|
|
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
|
}
|
package/build/src/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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
|
+
}
|