chrome-devtools-mcp 0.26.0 → 1.1.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 (45) hide show
  1. package/README.md +59 -10
  2. package/build/src/DevtoolsUtils.js +14 -1
  3. package/build/src/HeapSnapshotManager.js +42 -18
  4. package/build/src/McpContext.js +61 -23
  5. package/build/src/McpResponse.js +51 -21
  6. package/build/src/ToolHandler.js +30 -1
  7. package/build/src/WaitForHelper.js +18 -4
  8. package/build/src/bin/check-latest-version.js +25 -1
  9. package/build/src/bin/chrome-devtools-cli-options.js +81 -39
  10. package/build/src/bin/chrome-devtools-mcp-cli-options.js +2 -8
  11. package/build/src/bin/chrome-devtools-mcp-main.js +38 -0
  12. package/build/src/browser.js +36 -2
  13. package/build/src/daemon/client.js +12 -6
  14. package/build/src/daemon/daemon.js +62 -5
  15. package/build/src/formatters/HeapSnapshotFormatter.js +30 -9
  16. package/build/src/index.js +3 -1
  17. package/build/src/telemetry/ClearcutLogger.js +8 -119
  18. package/build/src/telemetry/errors.js +4 -0
  19. package/build/src/telemetry/flagUtils.js +4 -3
  20. package/build/src/telemetry/{toolMetricsUtils.js → metricsRegistry.js} +3 -3
  21. package/build/src/telemetry/persistence.js +20 -2
  22. package/build/src/telemetry/transformation.js +134 -0
  23. package/build/src/third_party/THIRD_PARTY_NOTICES +4 -719
  24. package/build/src/third_party/bundled-packages.json +2 -2
  25. package/build/src/third_party/devtools-formatter-worker.js +447 -114
  26. package/build/src/third_party/devtools-heap-snapshot-worker.js +2 -3
  27. package/build/src/third_party/index.js +3443 -30153
  28. package/build/src/third_party/issue-descriptions/genericBackUINavigationWouldSkipAd.md +4 -0
  29. package/build/src/tools/ToolDefinition.js +2 -2
  30. package/build/src/tools/emulation.js +28 -2
  31. package/build/src/tools/extensions.js +2 -0
  32. package/build/src/tools/input.js +19 -10
  33. package/build/src/tools/lighthouse.js +1 -1
  34. package/build/src/tools/memory.js +39 -17
  35. package/build/src/tools/network.js +2 -2
  36. package/build/src/tools/performance.js +9 -6
  37. package/build/src/tools/screencast.js +1 -1
  38. package/build/src/tools/screenshot.js +1 -1
  39. package/build/src/tools/script.js +32 -10
  40. package/build/src/tools/snapshot.js +1 -1
  41. package/build/src/trace-processing/parse.js +2 -2
  42. package/build/src/utils/files.js +43 -0
  43. package/build/src/version.js +1 -1
  44. package/package.json +7 -4
  45. package/build/src/telemetry/metricUtils.js +0 -15
@@ -11,12 +11,15 @@ export class WaitForHelper {
11
11
  #stableDomFor;
12
12
  #expectNavigationIn;
13
13
  #navigationTimeout;
14
+ #dialogOpened = false;
15
+ #initialUrl;
14
16
  constructor(page, cpuTimeoutMultiplier, networkTimeoutMultiplier) {
15
17
  this.#stableDomTimeout = 3000 * cpuTimeoutMultiplier;
16
18
  this.#stableDomFor = 100 * cpuTimeoutMultiplier;
17
19
  this.#expectNavigationIn = 100 * cpuTimeoutMultiplier;
18
20
  this.#navigationTimeout = 3000 * networkTimeoutMultiplier;
19
21
  this.#page = page;
22
+ this.#initialUrl = page.url();
20
23
  }
21
24
  /**
22
25
  * A wrapper that executes a action and waits for
@@ -104,10 +107,12 @@ export class WaitForHelper {
104
107
  });
105
108
  }
106
109
  async waitForEventsAfterAction(action, options) {
107
- let dialogOpened = false;
110
+ if (this.#abortController.signal.aborted) {
111
+ throw new Error("Can't re-use a WaitForHelper");
112
+ }
108
113
  if (options?.handleDialog) {
109
114
  const dialogHandler = (dialog) => {
110
- dialogOpened = true;
115
+ this.#dialogOpened = true;
111
116
  if (options.handleDialog === 'dismiss') {
112
117
  void dialog.dismiss();
113
118
  }
@@ -144,8 +149,8 @@ export class WaitForHelper {
144
149
  }
145
150
  try {
146
151
  await navigationFinished;
147
- if (dialogOpened) {
148
- return;
152
+ if (this.#dialogOpened) {
153
+ return this.#getResult();
149
154
  }
150
155
  // Wait for stable dom after navigation so we execute in
151
156
  // the correct context
@@ -157,6 +162,15 @@ export class WaitForHelper {
157
162
  finally {
158
163
  this.#abortController.abort();
159
164
  }
165
+ return this.#getResult();
166
+ }
167
+ #getResult() {
168
+ const urlAfterAction = this.#page.url();
169
+ return {
170
+ ...(urlAfterAction !== this.#initialUrl
171
+ ? { navigatedToUrl: urlAfterAction }
172
+ : {}),
173
+ };
160
174
  }
161
175
  }
162
176
  export function getNetworkMultiplierFromString(condition) {
@@ -3,13 +3,37 @@
3
3
  * Copyright 2026 Google LLC
4
4
  * SPDX-License-Identifier: Apache-2.0
5
5
  */
6
+ import { execSync } from 'node:child_process';
6
7
  import fs from 'node:fs/promises';
7
8
  import path from 'node:path';
8
9
  import process from 'node:process';
10
+ const DEFAULT_REGISTRY = 'https://registry.npmjs.org';
11
+ function getRegistry() {
12
+ // Use the user's configured npm registry so update checks work behind
13
+ // corporate proxies and private registries. `npm config get registry`
14
+ // honors .npmrc files at every scope and respects npm_config_registry,
15
+ // so it covers direct CLI invocations as well as `npx` / `npm run`.
16
+ try {
17
+ const registry = execSync('npm config get registry', {
18
+ encoding: 'utf8',
19
+ stdio: ['ignore', 'pipe', 'ignore'],
20
+ timeout: 5000,
21
+ })
22
+ .trim()
23
+ .replace(/\/$/, '');
24
+ if (registry && registry !== 'undefined' && /^https?:\/\//.test(registry)) {
25
+ return registry;
26
+ }
27
+ }
28
+ catch {
29
+ // npm not on PATH or other errors, fall back to default.
30
+ }
31
+ return DEFAULT_REGISTRY;
32
+ }
9
33
  const cachePath = process.argv[2];
10
34
  if (cachePath) {
11
35
  try {
12
- const response = await fetch('https://registry.npmjs.org/chrome-devtools-mcp/latest');
36
+ const response = await fetch(`${getRegistry()}/chrome-devtools-mcp/latest`);
13
37
  const data = response.ok ? await response.json() : null;
14
38
  if (data &&
15
39
  typeof data === 'object' &&
@@ -114,7 +114,7 @@ export const commands = {
114
114
  geolocation: {
115
115
  name: 'geolocation',
116
116
  type: 'string',
117
- description: 'Geolocation (`<latitude>x<longitude>`) to emulate. Latitude between -90 and 90. Longitude between -180 and 180. Omit to clear the geolocation override.',
117
+ description: 'Geolocation (`<latitude>,<longitude>`) to emulate. Latitude between -90 and 90. Longitude between -180 and 180. Omit to clear the geolocation override.',
118
118
  required: false,
119
119
  },
120
120
  userAgent: {
@@ -136,6 +136,12 @@ export const commands = {
136
136
  description: "Emulate device viewports '<width>x<height>x<devicePixelRatio>[,mobile][,touch][,landscape]'. 'touch' and 'mobile' to emulate mobile devices. 'landscape' to emulate landscape mode.",
137
137
  required: false,
138
138
  },
139
+ extraHttpHeaders: {
140
+ name: 'extraHttpHeaders',
141
+ type: 'string',
142
+ description: 'Extra HTTP headers as a JSON string object, e.g. {"X-Custom": "value", "Authorization": "Bearer token"}. Headers are included into every HTTP request originating from the page and persist across navigations until cleared. Pass an empty string to clear all extra headers.',
143
+ required: false,
144
+ },
139
145
  },
140
146
  },
141
147
  evaluate_script: {
@@ -154,6 +160,12 @@ export const commands = {
154
160
  description: 'An optional list of arguments to pass to the function.',
155
161
  required: false,
156
162
  },
163
+ filePath: {
164
+ name: 'filePath',
165
+ type: 'string',
166
+ description: 'The absolute or relative path to a file to save the script output to. If omitted, the output is returned inline.',
167
+ required: false,
168
+ },
157
169
  dialogAction: {
158
170
  name: 'dialogAction',
159
171
  type: 'string',
@@ -234,8 +246,8 @@ export const commands = {
234
246
  },
235
247
  },
236
248
  },
237
- get_memory_snapshot_details: {
238
- description: 'Loads a memory heapsnapshot and returns all available information including statistics, static data, and aggregated node information. Supports pagination for aggregates. (requires flag: --experimentalMemory=true)',
249
+ get_heapsnapshot_class_nodes: {
250
+ description: 'Loads a memory heapsnapshot and returns instances of a specific class with their IDs. (requires flag: --experimentalMemory=true)',
239
251
  category: 'Memory',
240
252
  args: {
241
253
  filePath: {
@@ -244,46 +256,52 @@ export const commands = {
244
256
  description: 'A path to a .heapsnapshot file to read.',
245
257
  required: true,
246
258
  },
259
+ id: {
260
+ name: 'id',
261
+ type: 'number',
262
+ description: 'The ID for the class, obtained from details.',
263
+ required: true,
264
+ },
247
265
  pageIdx: {
248
266
  name: 'pageIdx',
249
267
  type: 'number',
250
- description: 'The page index for pagination of aggregates.',
268
+ description: 'The page index for pagination.',
251
269
  required: false,
252
270
  },
253
271
  pageSize: {
254
272
  name: 'pageSize',
255
273
  type: 'number',
256
- description: 'The page size for pagination of aggregates.',
274
+ description: 'The page size for pagination.',
257
275
  required: false,
258
276
  },
259
277
  },
260
278
  },
261
- get_network_request: {
262
- description: 'Gets a network request by an optional reqid, if omitted returns the currently selected request in the DevTools Network panel.',
263
- category: 'Network',
279
+ get_heapsnapshot_details: {
280
+ description: 'Loads a memory heapsnapshot and returns all available information including statistics, static data, and aggregated node information. Supports pagination for aggregates. (requires flag: --experimentalMemory=true)',
281
+ category: 'Memory',
264
282
  args: {
265
- reqid: {
266
- name: 'reqid',
267
- type: 'number',
268
- description: 'The reqid of the network request. If omitted returns the currently selected request in the DevTools Network panel.',
269
- required: false,
270
- },
271
- requestFilePath: {
272
- name: 'requestFilePath',
283
+ filePath: {
284
+ name: 'filePath',
273
285
  type: 'string',
274
- description: 'The absolute or relative path to a .network-request file to save the request body to. If omitted, the body is returned inline.',
286
+ description: 'A path to a .heapsnapshot file to read.',
287
+ required: true,
288
+ },
289
+ pageIdx: {
290
+ name: 'pageIdx',
291
+ type: 'number',
292
+ description: 'The page index for pagination of aggregates.',
275
293
  required: false,
276
294
  },
277
- responseFilePath: {
278
- name: 'responseFilePath',
279
- type: 'string',
280
- description: 'The absolute or relative path to a .network-response file to save the response body to. If omitted, the body is returned inline.',
295
+ pageSize: {
296
+ name: 'pageSize',
297
+ type: 'number',
298
+ description: 'The page size for pagination of aggregates.',
281
299
  required: false,
282
300
  },
283
301
  },
284
302
  },
285
- get_nodes_by_class: {
286
- description: 'Loads a memory heapsnapshot and returns instances of a specific class with their stable IDs. (requires flag: --experimentalMemory=true)',
303
+ get_heapsnapshot_retainers: {
304
+ description: 'Loads a memory heapsnapshot and returns retainers for a specific node ID. (requires flag: --experimentalMemory=true)',
287
305
  category: 'Memory',
288
306
  args: {
289
307
  filePath: {
@@ -292,10 +310,10 @@ export const commands = {
292
310
  description: 'A path to a .heapsnapshot file to read.',
293
311
  required: true,
294
312
  },
295
- uid: {
296
- name: 'uid',
313
+ nodeId: {
314
+ name: 'nodeId',
297
315
  type: 'number',
298
- description: 'The unique UID for the class, obtained from aggregates listing.',
316
+ description: 'The node ID to get retainers for.',
299
317
  required: true,
300
318
  },
301
319
  pageIdx: {
@@ -312,6 +330,42 @@ export const commands = {
312
330
  },
313
331
  },
314
332
  },
333
+ get_heapsnapshot_summary: {
334
+ description: 'Loads a memory heapsnapshot and returns snapshot summary stats. (requires flag: --experimentalMemory=true)',
335
+ category: 'Memory',
336
+ args: {
337
+ filePath: {
338
+ name: 'filePath',
339
+ type: 'string',
340
+ description: 'A path to a .heapsnapshot file to read.',
341
+ required: true,
342
+ },
343
+ },
344
+ },
345
+ get_network_request: {
346
+ description: 'Gets a network request by an optional reqid, if omitted returns the currently selected request in the DevTools Network panel.',
347
+ category: 'Network',
348
+ args: {
349
+ reqid: {
350
+ name: 'reqid',
351
+ type: 'number',
352
+ description: 'The reqid of the network request. If omitted returns the currently selected request in the DevTools Network panel.',
353
+ required: false,
354
+ },
355
+ requestFilePath: {
356
+ name: 'requestFilePath',
357
+ type: 'string',
358
+ description: 'The absolute or relative path to a .network-request file to save the request body to. If omitted, the body is returned inline.',
359
+ required: false,
360
+ },
361
+ responseFilePath: {
362
+ name: 'responseFilePath',
363
+ type: 'string',
364
+ description: 'The absolute or relative path to a .network-response file to save the response body to. If omitted, the body is returned inline.',
365
+ required: false,
366
+ },
367
+ },
368
+ },
315
369
  handle_dialog: {
316
370
  description: 'If a browser dialog was opened, use this command to handle it',
317
371
  category: 'Input automation',
@@ -471,18 +525,6 @@ export const commands = {
471
525
  category: 'WebMCP',
472
526
  args: {},
473
527
  },
474
- load_memory_snapshot: {
475
- description: 'Loads a memory heapsnapshot and returns snapshot summary stats. (requires flag: --experimentalMemory=true)',
476
- category: 'Memory',
477
- args: {
478
- filePath: {
479
- name: 'filePath',
480
- type: 'string',
481
- description: 'A path to a .heapsnapshot file to read.',
482
- required: true,
483
- },
484
- },
485
- },
486
528
  navigate_page: {
487
529
  description: 'Go to a URL, or back, forward, or reload. Use project URL if not specified otherwise.',
488
530
  category: 'Navigation automation',
@@ -696,7 +738,7 @@ export const commands = {
696
738
  },
697
739
  },
698
740
  },
699
- take_memory_snapshot: {
741
+ take_heapsnapshot: {
700
742
  description: 'Capture a heap snapshot of the currently selected page. Use to analyze the memory distribution of JavaScript objects and debug memory leaks.',
701
743
  category: 'Memory',
702
744
  args: {
@@ -136,33 +136,27 @@ export const cliOptions = {
136
136
  },
137
137
  experimentalPageIdRouting: {
138
138
  type: 'boolean',
139
- describe: 'Whether to expose pageId on page-scoped tools and route requests by page ID.',
140
- hidden: true,
139
+ describe: 'Whether to expose pageId on page-scoped tools and route requests by page ID (useful for concurrent agent sessions).',
141
140
  },
142
141
  experimentalDevtools: {
143
142
  type: 'boolean',
144
143
  describe: 'Whether to enable automation over DevTools targets',
145
- hidden: true,
146
144
  },
147
145
  experimentalVision: {
148
146
  type: 'boolean',
149
147
  describe: 'Whether to enable coordinate-based tools such as click_at(x,y). Usually requires a computer-use model able to produce accurate coordinates by looking at screenshots.',
150
- hidden: false,
151
148
  },
152
149
  experimentalMemory: {
153
150
  type: 'boolean',
154
151
  describe: 'Whether to enable experimental memory tools.',
155
- hidden: true,
156
152
  },
157
153
  experimentalStructuredContent: {
158
154
  type: 'boolean',
159
155
  describe: 'Whether to output structured formatted content.',
160
- hidden: true,
161
156
  },
162
157
  experimentalIncludeAllPages: {
163
158
  type: 'boolean',
164
159
  describe: 'Whether to include all kinds of pages such as webviews or background pages as pages.',
165
- hidden: true,
166
160
  },
167
161
  experimentalNavigationAllowlist: {
168
162
  type: 'boolean',
@@ -257,7 +251,7 @@ export const cliOptions = {
257
251
  },
258
252
  redactNetworkHeaders: {
259
253
  type: 'boolean',
260
- describe: 'If true, redacts some of the network headers considered senstive before returning to the client.',
254
+ describe: 'If true, redacts some of the network headers considered sensitive before returning to the client.',
261
255
  default: false,
262
256
  },
263
257
  };
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import '../polyfill.js';
7
7
  import process from 'node:process';
8
+ import { closeBrowser } from '../browser.js';
8
9
  import { createMcpServer, logDisclaimers } from '../index.js';
9
10
  import { logger, saveLogsToFile } from '../logger.js';
10
11
  import { ClearcutLogger } from '../telemetry/ClearcutLogger.js';
@@ -21,6 +22,43 @@ if (process.env['CHROME_DEVTOOLS_MCP_CRASH_ON_UNCAUGHT'] !== 'true') {
21
22
  logger('Unhandled promise rejection', promise, reason);
22
23
  });
23
24
  }
25
+ // Shutdown on stdin EOF (stdio MCP convention — the client closes the
26
+ // transport to signal exit) and on standard termination signals. Without
27
+ // this, an active Chrome subprocess keeps the Node event loop ref'd after
28
+ // stdin closes and the server hangs until something else kills it.
29
+ let shuttingDown = false;
30
+ async function shutdown(reason) {
31
+ if (shuttingDown) {
32
+ return;
33
+ }
34
+ shuttingDown = true;
35
+ logger(`Shutting down (${reason})`);
36
+ // Backstop in case browser teardown hangs (e.g. unresponsive Chrome,
37
+ // slow beforeunload handlers, many tabs). Exits 0 because we still
38
+ // honored the shutdown request; the log line preserves observability.
39
+ // Unref'd so it doesn't keep the loop alive on the clean path.
40
+ setTimeout(() => {
41
+ logger('Shutdown timeout exceeded, forcing exit');
42
+ process.exit(0);
43
+ }, 10000).unref();
44
+ await closeBrowser();
45
+ process.exit(0);
46
+ }
47
+ process.stdin.on('end', () => {
48
+ void shutdown('stdin end');
49
+ });
50
+ process.stdin.on('close', () => {
51
+ void shutdown('stdin close');
52
+ });
53
+ process.on('SIGTERM', () => {
54
+ void shutdown('SIGTERM');
55
+ });
56
+ process.on('SIGINT', () => {
57
+ void shutdown('SIGINT');
58
+ });
59
+ process.on('SIGHUP', () => {
60
+ void shutdown('SIGHUP');
61
+ });
24
62
  logger(`Starting Chrome DevTools MCP Server v${VERSION}`);
25
63
  const { server } = await createMcpServer(args, {
26
64
  logFile,
@@ -10,6 +10,7 @@ import path from 'node:path';
10
10
  import { logger } from './logger.js';
11
11
  import { puppeteer } from './third_party/index.js';
12
12
  let browser;
13
+ let browserMode;
13
14
  function makeTargetFilter(enableExtensions = false) {
14
15
  const ignoredPrefixes = new Set(['chrome://', 'chrome-untrusted://']);
15
16
  if (!enableExtensions) {
@@ -95,7 +96,12 @@ export async function ensureBrowserConnected(options) {
95
96
  }
96
97
  logger('Connecting Puppeteer to ', JSON.stringify(connectOptions));
97
98
  try {
98
- browser = await puppeteer.connect(connectOptions);
99
+ // Assign mode before browser so a concurrent closeBrowser() never sees
100
+ // `browser` set with `browserMode` still undefined (would fall through
101
+ // to the disconnect() path and orphan a launched Chrome).
102
+ const connected = await puppeteer.connect(connectOptions);
103
+ browserMode = 'connected';
104
+ browser = connected;
99
105
  }
100
106
  catch (err) {
101
107
  throw new Error(`Could not connect to Chrome. ${autoConnect ? `Check if Chrome is running and remote debugging is enabled by going to chrome://inspect/#remote-debugging.` : `Check if Chrome is running.`}`, {
@@ -198,7 +204,35 @@ export async function ensureBrowserLaunched(options) {
198
204
  if (browser?.connected) {
199
205
  return browser;
200
206
  }
201
- browser = await launch(options);
207
+ // Assign mode before browser; see the connect path above for rationale.
208
+ const launched = await launch(options);
209
+ browserMode = 'launched';
210
+ browser = launched;
202
211
  return browser;
203
212
  }
213
+ /**
214
+ * Shutdown hook for the active browser. Closes a launched browser (so the
215
+ * Chrome subprocess is reaped) or disconnects from an attached browser (so
216
+ * the user's Chrome instance stays alive). No-op if no browser is active or
217
+ * the connection has already been dropped. Called from the server entrypoint
218
+ * on stdin EOF / SIGTERM / SIGINT.
219
+ */
220
+ export async function closeBrowser() {
221
+ const b = browser;
222
+ const mode = browserMode;
223
+ browser = undefined;
224
+ browserMode = undefined;
225
+ if (!b || !b.connected) {
226
+ return;
227
+ }
228
+ if (mode === 'launched') {
229
+ await b.close().catch(err => {
230
+ logger('Failed to close browser', err);
231
+ });
232
+ return;
233
+ }
234
+ await b.disconnect().catch(err => {
235
+ logger('Failed to disconnect from browser', err);
236
+ });
237
+ }
204
238
  //# sourceMappingURL=browser.js.map
@@ -115,13 +115,8 @@ export async function handleResponse(response, format) {
115
115
  if (response.isError) {
116
116
  return JSON.stringify(response.content);
117
117
  }
118
- if (format === 'json') {
119
- if (response.structuredContent) {
120
- return JSON.stringify(response.structuredContent);
121
- }
122
- // Fall-through to text for backward compatibility.
123
- }
124
118
  const chunks = [];
119
+ const images = [];
125
120
  for (const content of response.content) {
126
121
  if (content.type === 'text') {
127
122
  chunks.push(content.text);
@@ -143,12 +138,23 @@ export async function handleResponse(response, format) {
143
138
  const name = crypto.randomUUID();
144
139
  const filepath = await getTempFilePath(`${name}${extension}`);
145
140
  fs.writeFileSync(filepath, data);
141
+ images.push({ filePath: filepath, mimeType });
146
142
  chunks.push(`Saved to ${filepath}.`);
147
143
  }
148
144
  else {
149
145
  throw new Error('Not supported response content type');
150
146
  }
151
147
  }
148
+ if (format === 'json') {
149
+ if (response.structuredContent) {
150
+ const structuredContent = {
151
+ ...response.structuredContent,
152
+ ...(images.length ? { images } : {}),
153
+ };
154
+ return JSON.stringify(structuredContent);
155
+ }
156
+ // Fall-through to text for backward compatibility.
157
+ }
152
158
  return format === 'md' ? chunks.join(' ') : JSON.stringify(chunks);
153
159
  }
154
160
  //# sourceMappingURL=client.js.map
@@ -4,8 +4,9 @@
4
4
  * Copyright 2026 Google LLC
5
5
  * SPDX-License-Identifier: Apache-2.0
6
6
  */
7
- import fs from 'node:fs';
7
+ import fs, { constants, openSync, writeSync, closeSync } from 'node:fs';
8
8
  import { createServer } from 'node:net';
9
+ import os from 'node:os';
9
10
  import path from 'node:path';
10
11
  import process from 'node:process';
11
12
  import { logger } from '../logger.js';
@@ -19,10 +20,66 @@ if (isDaemonRunning(sessionId)) {
19
20
  process.exit(1);
20
21
  }
21
22
  const pidFilePath = getPidFilePath(sessionId);
22
- fs.mkdirSync(path.dirname(pidFilePath), {
23
- recursive: true,
24
- });
25
- fs.writeFileSync(pidFilePath, process.pid.toString());
23
+ const pidDir = path.dirname(pidFilePath);
24
+ const currentUserUid = os.userInfo().uid;
25
+ try {
26
+ fs.mkdirSync(pidDir, { recursive: true });
27
+ if (os.platform() !== 'win32') {
28
+ // POSIX specific checks
29
+ try {
30
+ const stats = fs.statSync(pidDir);
31
+ // 1. Check Ownership: Ensure the directory is owned by the current user.
32
+ if (stats.uid !== currentUserUid) {
33
+ console.error(`[MCP Daemon] Critical error: PID directory ${pidDir} is not owned by the current user (Expected: ${currentUserUid}, Found: ${stats.uid}). Possible tampering.`);
34
+ process.exit(1);
35
+ }
36
+ // 2. Check Permissions: Ensure the directory is not group or world-writable.
37
+ // Mode is a number, e.g., 0o700. We check if bits for group/world write are set.
38
+ const mode = stats.mode;
39
+ if (mode & constants.S_IWGRP || mode & constants.S_IWOTH) {
40
+ console.error(`[MCP Daemon] Critical error: PID directory ${pidDir} has insecure permissions (Mode: ${mode.toString(8)}). It should not be writable by group or others.`);
41
+ process.exit(1);
42
+ }
43
+ }
44
+ catch (statErr) {
45
+ console.error(`[MCP Daemon] Critical error stating PID directory ${pidDir}:`, statErr);
46
+ process.exit(1);
47
+ }
48
+ }
49
+ }
50
+ catch (err) {
51
+ console.error(`[MCP Daemon] Critical error creating/validating PID directory: ${pidDir}`, err);
52
+ process.exit(1);
53
+ }
54
+ let fd = -1;
55
+ try {
56
+ // Open the file with flags to:
57
+ // - O_WRONLY: Write-only
58
+ // - O_CREAT: Create if it doesn't exist
59
+ // - O_TRUNC: Truncate to zero length if it exists
60
+ // - O_NOFOLLOW: DO NOT follow symlinks.
61
+ // - 0o600: Permissions: read/write for owner, no permissions for others.
62
+ fd = openSync(pidFilePath, constants.O_WRONLY |
63
+ constants.O_CREAT |
64
+ constants.O_TRUNC |
65
+ constants.O_NOFOLLOW, 0o600);
66
+ writeSync(fd, process.pid.toString());
67
+ }
68
+ catch (err) {
69
+ console.error(`[MCP Daemon] Critical error writing PID file: ${pidFilePath}`, err);
70
+ // If openSync fails due to O_NOFOLLOW on a symlink, the error will be caught here.
71
+ process.exit(1);
72
+ }
73
+ finally {
74
+ if (fd !== -1) {
75
+ try {
76
+ closeSync(fd);
77
+ }
78
+ catch (err) {
79
+ console.error(`[MCP Daemon] Error closing PID file: ${pidFilePath}`, err);
80
+ }
81
+ }
82
+ }
26
83
  logger(`Writing ${process.pid.toString()} to ${pidFilePath}`);
27
84
  const socketPath = getSocketPath(sessionId);
28
85
  const startDate = new Date();
@@ -3,10 +3,22 @@
3
3
  * Copyright 2026 Google LLC
4
4
  * SPDX-License-Identifier: Apache-2.0
5
5
  */
6
+ import { DevTools } from '../third_party/index.js';
6
7
  import { stableIdSymbol } from '../utils/id.js';
7
8
  export function isNodeLike(item) {
8
9
  return (typeof item === 'object' && item !== null && 'id' in item && 'name' in item);
9
10
  }
11
+ export function isEdgeLike(item) {
12
+ return (typeof item === 'object' &&
13
+ item !== null &&
14
+ 'name' in item &&
15
+ 'node' in item &&
16
+ 'type' in item &&
17
+ typeof item.node === 'object' &&
18
+ item.node !== null &&
19
+ 'id' in item.node &&
20
+ 'name' in item.node);
21
+ }
10
22
  export class HeapSnapshotFormatter {
11
23
  #aggregates;
12
24
  constructor(aggregates) {
@@ -14,12 +26,21 @@ export class HeapSnapshotFormatter {
14
26
  }
15
27
  static formatNodes(items) {
16
28
  const lines = [];
17
- if (items.length > 0 && isNodeLike(items[0])) {
18
- lines.push('id,name,type,distance,selfSize,retainedSize');
29
+ if (items.length > 0) {
30
+ const firstItem = items[0];
31
+ if (isNodeLike(firstItem)) {
32
+ lines.push('nodeId,nodeName,type,distance,selfSize,retainedSize');
33
+ }
34
+ else if (isEdgeLike(firstItem)) {
35
+ lines.push('name,type,nodeId,nodeName');
36
+ }
19
37
  }
20
38
  for (const item of items) {
21
39
  if (isNodeLike(item)) {
22
- lines.push(`${item.id},"${item.name}",${item.type},${item.distance},${item.selfSize},${item.retainedSize}`);
40
+ lines.push(`${item.id},${item.name},${item.type},${item.distance},${DevTools.I18n.ByteUtilities.formatBytesToKb(item.selfSize)},${DevTools.I18n.ByteUtilities.formatBytesToKb(item.retainedSize)}`);
41
+ }
42
+ else if (isEdgeLike(item)) {
43
+ lines.push(`${item.name},${item.type},${item.node.id},${item.node.name}`);
23
44
  }
24
45
  }
25
46
  return lines.join('\n');
@@ -30,21 +51,21 @@ export class HeapSnapshotFormatter {
30
51
  toString() {
31
52
  const sorted = this.#getSortedAggregates();
32
53
  const lines = [];
33
- lines.push('uid,className,count,selfSize,maxRetainedSize');
54
+ lines.push('id,name,count,selfSize,maxRetainedSize');
34
55
  for (const info of sorted) {
35
- const uid = info[stableIdSymbol] ?? '';
36
- lines.push(`${uid},"${info.name}",${info.count},${info.self},${info.maxRet}`);
56
+ const id = info[stableIdSymbol] ?? '';
57
+ lines.push(`${id},${info.name},${info.count},${DevTools.I18n.ByteUtilities.formatBytesToKb(info.self)},${DevTools.I18n.ByteUtilities.formatBytesToKb(info.maxRet)}`);
37
58
  }
38
59
  return lines.join('\n');
39
60
  }
40
61
  toJSON() {
41
62
  const sorted = this.#getSortedAggregates();
42
63
  return sorted.map(info => ({
43
- uid: info[stableIdSymbol],
64
+ id: info[stableIdSymbol],
44
65
  className: info.name,
45
66
  count: info.count,
46
- selfSize: info.self,
47
- retainedSize: info.maxRet,
67
+ selfSize: DevTools.I18n.ByteUtilities.formatBytesToKb(info.self),
68
+ retainedSize: DevTools.I18n.ByteUtilities.formatBytesToKb(info.maxRet),
48
69
  }));
49
70
  }
50
71
  static sort(aggregates) {