chrome-devtools-mcp 0.21.0 → 0.23.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 +87 -21
- package/build/src/HeapSnapshotManager.js +94 -0
- package/build/src/McpContext.js +26 -181
- package/build/src/McpPage.js +214 -0
- package/build/src/McpResponse.js +151 -13
- package/build/src/PageCollector.js +10 -24
- package/build/src/TextSnapshot.js +230 -0
- 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 +34 -10
- package/build/src/bin/chrome-devtools-mcp-main.js +2 -0
- package/build/src/bin/chrome-devtools.js +25 -14
- package/build/src/bin/cliDefinitions.js +14 -8
- package/build/src/daemon/client.js +11 -11
- package/build/src/daemon/daemon.js +6 -9
- package/build/src/daemon/utils.js +19 -14
- package/build/src/formatters/HeapSnapshotFormatter.js +38 -0
- package/build/src/formatters/NetworkFormatter.js +24 -7
- package/build/src/index.js +12 -1
- package/build/src/telemetry/ClearcutLogger.js +34 -12
- package/build/src/telemetry/flagUtils.js +46 -4
- package/build/src/telemetry/toolMetricsUtils.js +88 -0
- package/build/src/telemetry/watchdog/ClearcutSender.js +4 -3
- package/build/src/third_party/THIRD_PARTY_NOTICES +59 -32
- package/build/src/third_party/bundled-packages.json +6 -4
- package/build/src/third_party/devtools-formatter-worker.js +61 -64
- package/build/src/third_party/devtools-heap-snapshot-worker.js +9690 -0
- package/build/src/third_party/index.js +62661 -60590
- package/build/src/third_party/lighthouse-devtools-mcp-bundle.js +3501 -2658
- package/build/src/tools/categories.js +3 -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 +3 -13
- package/build/src/tools/input.js +15 -16
- package/build/src/tools/lighthouse.js +2 -2
- package/build/src/tools/memory.js +48 -3
- package/build/src/tools/network.js +4 -4
- package/build/src/tools/pages.js +212 -146
- package/build/src/tools/performance.js +1 -1
- package/build/src/tools/screencast.js +20 -8
- package/build/src/tools/screenshot.js +3 -3
- package/build/src/tools/script.js +22 -16
- package/build/src/tools/tools.js +2 -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 -8
- package/build/src/utils/ExtensionRegistry.js +0 -35
|
@@ -11,19 +11,20 @@ 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 { DAEMON_CLIENT_NAME,
|
|
15
|
-
const
|
|
16
|
-
|
|
14
|
+
import { DAEMON_CLIENT_NAME, getPidFilePath, getSocketPath, INDEX_SCRIPT_PATH, IS_WINDOWS, isDaemonRunning, } from './utils.js';
|
|
15
|
+
const sessionId = process.env.CHROME_DEVTOOLS_MCP_SESSION_ID || '';
|
|
16
|
+
logger(`Daemon sessionId: ${sessionId}`);
|
|
17
|
+
if (isDaemonRunning(sessionId)) {
|
|
17
18
|
logger('Another daemon process is running.');
|
|
18
19
|
process.exit(1);
|
|
19
20
|
}
|
|
20
|
-
const pidFilePath = getPidFilePath();
|
|
21
|
+
const pidFilePath = getPidFilePath(sessionId);
|
|
21
22
|
fs.mkdirSync(path.dirname(pidFilePath), {
|
|
22
23
|
recursive: true,
|
|
23
24
|
});
|
|
24
25
|
fs.writeFileSync(pidFilePath, process.pid.toString());
|
|
25
26
|
logger(`Writing ${process.pid.toString()} to ${pidFilePath}`);
|
|
26
|
-
const socketPath = getSocketPath();
|
|
27
|
+
const socketPath = getSocketPath(sessionId);
|
|
27
28
|
const startDate = new Date();
|
|
28
29
|
const mcpServerArgs = process.argv.slice(2);
|
|
29
30
|
let mcpClient = null;
|
|
@@ -32,10 +33,6 @@ let server = null;
|
|
|
32
33
|
async function setupMCPClient() {
|
|
33
34
|
console.log('Setting up MCP client connection...');
|
|
34
35
|
// 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
36
|
mcpTransport = new StdioClientTransport({
|
|
40
37
|
command: process.execPath,
|
|
41
38
|
args: [INDEX_SCRIPT_PATH, ...mcpServerArgs],
|
|
@@ -13,46 +13,50 @@ export const INDEX_SCRIPT_PATH = path.join(import.meta.dirname, '..', 'bin', 'ch
|
|
|
13
13
|
const APP_NAME = 'chrome-devtools-mcp';
|
|
14
14
|
export const DAEMON_CLIENT_NAME = 'chrome-devtools-cli-daemon';
|
|
15
15
|
// Using these paths due to strict limits on the POSIX socket path length.
|
|
16
|
-
export function getSocketPath() {
|
|
16
|
+
export function getSocketPath(sessionId) {
|
|
17
17
|
const uid = os.userInfo().uid;
|
|
18
|
+
const suffix = sessionId ? `-${sessionId}` : '';
|
|
19
|
+
const appName = APP_NAME + suffix;
|
|
18
20
|
if (IS_WINDOWS) {
|
|
19
21
|
// Windows uses Named Pipes, not file paths.
|
|
20
22
|
// This format is required for server.listen()
|
|
21
|
-
return path.join('\\\\.\\pipe',
|
|
23
|
+
return path.join('\\\\.\\pipe', appName, 'server.sock');
|
|
22
24
|
}
|
|
23
25
|
// 1. Try XDG_RUNTIME_DIR (Linux standard, sometimes macOS)
|
|
24
26
|
if (process.env.XDG_RUNTIME_DIR) {
|
|
25
|
-
return path.join(process.env.XDG_RUNTIME_DIR,
|
|
27
|
+
return path.join(process.env.XDG_RUNTIME_DIR, appName, 'server.sock');
|
|
26
28
|
}
|
|
27
29
|
// 2. macOS/Unix Fallback: Use /tmp/
|
|
28
30
|
// We use /tmp/ because it is much shorter than ~/Library/Application Support/
|
|
29
31
|
// and keeps us well under the 104-character limit.
|
|
30
|
-
return path.join('/tmp', `${
|
|
32
|
+
return path.join('/tmp', `${appName}-${uid}.sock`);
|
|
31
33
|
}
|
|
32
|
-
export function getRuntimeHome() {
|
|
34
|
+
export function getRuntimeHome(sessionId) {
|
|
33
35
|
const platform = os.platform();
|
|
34
36
|
const uid = os.userInfo().uid;
|
|
37
|
+
const suffix = sessionId ? `-${sessionId}` : '';
|
|
38
|
+
const appName = APP_NAME + suffix;
|
|
35
39
|
// 1. Check for the modern Unix standard
|
|
36
40
|
if (process.env.XDG_RUNTIME_DIR) {
|
|
37
|
-
return path.join(process.env.XDG_RUNTIME_DIR,
|
|
41
|
+
return path.join(process.env.XDG_RUNTIME_DIR, appName);
|
|
38
42
|
}
|
|
39
43
|
// 2. Fallback for macOS and older Linux
|
|
40
44
|
if (platform === 'darwin' || platform === 'linux') {
|
|
41
45
|
// /tmp is cleared on boot, making it perfect for PIDs
|
|
42
|
-
return path.join('/tmp', `${
|
|
46
|
+
return path.join('/tmp', `${appName}-${uid}`);
|
|
43
47
|
}
|
|
44
48
|
// 3. Windows Fallback
|
|
45
|
-
return path.join(os.tmpdir(),
|
|
49
|
+
return path.join(os.tmpdir(), appName);
|
|
46
50
|
}
|
|
47
51
|
export const IS_WINDOWS = os.platform() === 'win32';
|
|
48
|
-
export function getPidFilePath() {
|
|
49
|
-
const runtimeDir = getRuntimeHome();
|
|
52
|
+
export function getPidFilePath(sessionId) {
|
|
53
|
+
const runtimeDir = getRuntimeHome(sessionId);
|
|
50
54
|
return path.join(runtimeDir, 'daemon.pid');
|
|
51
55
|
}
|
|
52
|
-
export function getDaemonPid() {
|
|
56
|
+
export function getDaemonPid(sessionId) {
|
|
53
57
|
try {
|
|
54
|
-
const pidFile = getPidFilePath();
|
|
55
|
-
logger(`Daemon pid file ${pidFile}`);
|
|
58
|
+
const pidFile = getPidFilePath(sessionId);
|
|
59
|
+
logger(`Daemon pid file ${pidFile} sessionId=${sessionId}`);
|
|
56
60
|
if (!fs.existsSync(pidFile)) {
|
|
57
61
|
return null;
|
|
58
62
|
}
|
|
@@ -68,7 +72,8 @@ export function getDaemonPid() {
|
|
|
68
72
|
return null;
|
|
69
73
|
}
|
|
70
74
|
}
|
|
71
|
-
export function isDaemonRunning(
|
|
75
|
+
export function isDaemonRunning(sessionId) {
|
|
76
|
+
const pid = getDaemonPid(sessionId);
|
|
72
77
|
if (pid) {
|
|
73
78
|
try {
|
|
74
79
|
process.kill(pid, 0); // Throws if process doesn't exist
|
|
@@ -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
|
@@ -101,7 +101,7 @@ export async function createMcpServer(serverArgs, options) {
|
|
|
101
101
|
return;
|
|
102
102
|
}
|
|
103
103
|
if (tool.annotations.category === ToolCategory.EXTENSIONS &&
|
|
104
|
-
|
|
104
|
+
serverArgs.categoryExtensions === false) {
|
|
105
105
|
return;
|
|
106
106
|
}
|
|
107
107
|
if (tool.annotations.category === ToolCategory.IN_PAGE &&
|
|
@@ -112,6 +112,10 @@ export async function createMcpServer(serverArgs, options) {
|
|
|
112
112
|
!serverArgs.experimentalVision) {
|
|
113
113
|
return;
|
|
114
114
|
}
|
|
115
|
+
if (tool.annotations.conditions?.includes('experimentalMemory') &&
|
|
116
|
+
!serverArgs.experimentalMemory) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
115
119
|
if (tool.annotations.conditions?.includes('experimentalInteropTools') &&
|
|
116
120
|
!serverArgs.experimentalInteropTools) {
|
|
117
121
|
return;
|
|
@@ -120,6 +124,10 @@ export async function createMcpServer(serverArgs, options) {
|
|
|
120
124
|
!serverArgs.experimentalScreencast) {
|
|
121
125
|
return;
|
|
122
126
|
}
|
|
127
|
+
if (tool.annotations.conditions?.includes('experimentalWebmcp') &&
|
|
128
|
+
!serverArgs.experimentalWebmcp) {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
123
131
|
const schema = 'pageScoped' in tool &&
|
|
124
132
|
tool.pageScoped &&
|
|
125
133
|
serverArgs.experimentalPageIdRouting &&
|
|
@@ -142,6 +150,7 @@ export async function createMcpServer(serverArgs, options) {
|
|
|
142
150
|
const response = serverArgs.slim
|
|
143
151
|
? new SlimMcpResponse(serverArgs)
|
|
144
152
|
: new McpResponse(serverArgs);
|
|
153
|
+
response.setRedactNetworkHeaders(serverArgs.redactNetworkHeaders);
|
|
145
154
|
if ('pageScoped' in tool && tool.pageScoped) {
|
|
146
155
|
const page = serverArgs.experimentalPageIdRouting &&
|
|
147
156
|
params.pageId &&
|
|
@@ -190,6 +199,8 @@ export async function createMcpServer(serverArgs, options) {
|
|
|
190
199
|
finally {
|
|
191
200
|
void clearcutLogger?.logToolInvocation({
|
|
192
201
|
toolName: tool.name,
|
|
202
|
+
params,
|
|
203
|
+
schema,
|
|
193
204
|
success,
|
|
194
205
|
latencyMs: bucketizeLatency(Date.now() - startTime),
|
|
195
206
|
});
|
|
@@ -10,7 +10,7 @@ import { FilePersistence } from './persistence.js';
|
|
|
10
10
|
import { McpClient, WatchdogMessageType, OsType, } from './types.js';
|
|
11
11
|
import { WatchdogClient } from './WatchdogClient.js';
|
|
12
12
|
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
13
|
-
const PARAM_BLOCKLIST = new Set(['uid']);
|
|
13
|
+
export const PARAM_BLOCKLIST = new Set(['uid', 'reqid', 'msgid']);
|
|
14
14
|
const SUPPORTED_ZOD_TYPES = [
|
|
15
15
|
'ZodString',
|
|
16
16
|
'ZodNumber',
|
|
@@ -21,7 +21,7 @@ const SUPPORTED_ZOD_TYPES = [
|
|
|
21
21
|
function isZodType(type) {
|
|
22
22
|
return SUPPORTED_ZOD_TYPES.includes(type);
|
|
23
23
|
}
|
|
24
|
-
function getZodType(zodType) {
|
|
24
|
+
export function getZodType(zodType) {
|
|
25
25
|
const def = zodType._def;
|
|
26
26
|
const typeName = def.typeName;
|
|
27
27
|
if (typeName === 'ZodOptional' ||
|
|
@@ -37,15 +37,31 @@ function getZodType(zodType) {
|
|
|
37
37
|
}
|
|
38
38
|
throw new Error(`Unsupported zod type for tool parameter: ${typeName}`);
|
|
39
39
|
}
|
|
40
|
-
function
|
|
40
|
+
export function transformArgName(zodType, name) {
|
|
41
|
+
const snakeCaseName = name.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`);
|
|
41
42
|
if (zodType === 'ZodString') {
|
|
42
|
-
return `${
|
|
43
|
+
return `${snakeCaseName}_length`;
|
|
43
44
|
}
|
|
44
45
|
else if (zodType === 'ZodArray') {
|
|
45
|
-
return `${
|
|
46
|
+
return `${snakeCaseName}_count`;
|
|
46
47
|
}
|
|
47
48
|
else {
|
|
48
|
-
return
|
|
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}`);
|
|
49
65
|
}
|
|
50
66
|
}
|
|
51
67
|
function transformValue(zodType, value) {
|
|
@@ -91,7 +107,7 @@ export function sanitizeParams(params, schema) {
|
|
|
91
107
|
if (!hasEquivalentType(zodType, value)) {
|
|
92
108
|
throw new Error(`parameter ${name} has type ${zodType} but value ${value} is not of equivalent type`);
|
|
93
109
|
}
|
|
94
|
-
const transformedName =
|
|
110
|
+
const transformedName = transformArgName(zodType, name);
|
|
95
111
|
const transformedValue = transformValue(zodType, value);
|
|
96
112
|
transformed[transformedName] = transformedValue;
|
|
97
113
|
}
|
|
@@ -153,15 +169,21 @@ export class ClearcutLogger {
|
|
|
153
169
|
}
|
|
154
170
|
}
|
|
155
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
|
+
}
|
|
156
182
|
this.#watchdog.send({
|
|
157
183
|
type: WatchdogMessageType.LOG_EVENT,
|
|
158
184
|
payload: {
|
|
159
185
|
mcp_client: this.#mcpClient,
|
|
160
|
-
tool_invocation:
|
|
161
|
-
tool_name: args.toolName,
|
|
162
|
-
success: args.success,
|
|
163
|
-
latency_ms: args.latencyMs,
|
|
164
|
-
},
|
|
186
|
+
tool_invocation: tool_invocation,
|
|
165
187
|
},
|
|
166
188
|
});
|
|
167
189
|
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Google LLC
|
|
4
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
*/
|
|
6
|
+
import { transformArgName, transformArgType, getZodType, PARAM_BLOCKLIST, } from './ClearcutLogger.js';
|
|
7
|
+
/**
|
|
8
|
+
* Validates that all values in an enum are of the homogeneous primitive type.
|
|
9
|
+
* Returns the primitive type string. Throws an error if heterogeneous.
|
|
10
|
+
*/
|
|
11
|
+
export function validateEnumHomogeneity(values) {
|
|
12
|
+
const firstType = typeof values[0];
|
|
13
|
+
for (const val of values) {
|
|
14
|
+
if (typeof val !== firstType) {
|
|
15
|
+
throw new Error('Heterogeneous enum types found');
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return firstType;
|
|
19
|
+
}
|
|
20
|
+
export function applyToExistingMetrics(existing, update) {
|
|
21
|
+
const updated = applyToExisting(existing, update);
|
|
22
|
+
const existingByName = new Map(existing.map(tool => [tool.name, tool]));
|
|
23
|
+
const updatedByName = new Map(update.map(tool => [tool.name, tool]));
|
|
24
|
+
return updated.map(tool => {
|
|
25
|
+
const existingTool = existingByName.get(tool.name);
|
|
26
|
+
const updatedTool = updatedByName.get(tool.name);
|
|
27
|
+
// If the tool still exists in the update, we will update the args.
|
|
28
|
+
if (existingTool && updatedTool) {
|
|
29
|
+
const updatedArgs = applyToExisting(existingTool.args, updatedTool.args);
|
|
30
|
+
return { ...tool, args: updatedArgs };
|
|
31
|
+
}
|
|
32
|
+
return tool;
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
function applyToExisting(existing, update) {
|
|
36
|
+
const existingNames = new Set(existing.map(item => item.name));
|
|
37
|
+
const updatedNames = new Set(update.map(item => item.name));
|
|
38
|
+
const result = [];
|
|
39
|
+
// Keep the original ordering.
|
|
40
|
+
for (const entry of existing) {
|
|
41
|
+
const toAdd = { ...entry };
|
|
42
|
+
if (!updatedNames.has(entry.name)) {
|
|
43
|
+
toAdd.isDeprecated = true;
|
|
44
|
+
}
|
|
45
|
+
result.push(toAdd);
|
|
46
|
+
}
|
|
47
|
+
// New entries must be added to the very back of the list.
|
|
48
|
+
for (const entry of update) {
|
|
49
|
+
if (!existingNames.has(entry.name)) {
|
|
50
|
+
result.push({ ...entry });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return result;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Generates tool metrics from tool definitions.
|
|
57
|
+
*/
|
|
58
|
+
export function generateToolMetrics(tools) {
|
|
59
|
+
return tools.map(tool => {
|
|
60
|
+
const args = [];
|
|
61
|
+
for (const [name, schema] of Object.entries(tool.schema)) {
|
|
62
|
+
if (PARAM_BLOCKLIST.has(name)) {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
const zodType = getZodType(schema);
|
|
66
|
+
const transformedName = transformArgName(zodType, name);
|
|
67
|
+
let argType = transformArgType(zodType);
|
|
68
|
+
if (argType === 'enum') {
|
|
69
|
+
let values;
|
|
70
|
+
if (schema._def.values?.length > 0) {
|
|
71
|
+
values = schema._def.values;
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
values = schema._def.innerType._def.values;
|
|
75
|
+
}
|
|
76
|
+
argType = validateEnumHomogeneity(values);
|
|
77
|
+
}
|
|
78
|
+
args.push({
|
|
79
|
+
name: transformedName,
|
|
80
|
+
argType,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
name: tool.name,
|
|
85
|
+
args,
|
|
86
|
+
};
|
|
87
|
+
});
|
|
88
|
+
}
|
|
@@ -42,13 +42,14 @@ export class ClearcutSender {
|
|
|
42
42
|
this.#sessionId = crypto.randomUUID();
|
|
43
43
|
this.#sessionCreated = Date.now();
|
|
44
44
|
}
|
|
45
|
-
|
|
46
|
-
this.#addToBuffer({
|
|
45
|
+
const eventToSend = {
|
|
47
46
|
...event,
|
|
48
47
|
session_id: this.#sessionId,
|
|
49
48
|
app_version: this.#appVersion,
|
|
50
49
|
os_type: this.#osType,
|
|
51
|
-
}
|
|
50
|
+
};
|
|
51
|
+
logger('Enqueing telemetry event', JSON.stringify(eventToSend, null, 2));
|
|
52
|
+
this.#addToBuffer(eventToSend);
|
|
52
53
|
if (!this.#timerStarted) {
|
|
53
54
|
this.#timerStarted = true;
|
|
54
55
|
this.#scheduleFlush(this.#flushIntervalMs);
|