chrome-devtools-mcp 1.0.1 → 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.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![npm chrome-devtools-mcp package](https://img.shields.io/npm/v/chrome-devtools-mcp.svg)](https://npmjs.org/package/chrome-devtools-mcp)
4
4
 
5
- Chrome DevTools for agents (`chrome-devtools-mcp`) lets your coding agent (such as Gemini, Claude, Cursor or Copilot)
5
+ Chrome DevTools for agents (`chrome-devtools-mcp`) lets your coding agent (such as Antigravity, Claude, Cursor or Copilot)
6
6
  control and inspect a live Chrome browser. It acts as a Model-Context-Protocol
7
7
  (MCP) server, giving your AI coding assistant access to the full power of
8
8
  Chrome DevTools for reliable automation, in-depth debugging, and performance analysis.
@@ -249,7 +249,7 @@ and the expert guidance it needs to use them effectively.
249
249
 
250
250
  1. Open the **Command Palette** (`Cmd+Shift+P` on macOS or `Ctrl+Shift+P` on Windows/Linux).
251
251
  2. Search for and run the **Chat: Install Plugin From Source** command.
252
- 3. Paste in our repository URL: `https://github.com/ChromeDevTools/chrome-devtools-mcp`
252
+ 3. Paste in our repository name: `ChromeDevTools/chrome-devtools-mcp`.
253
253
 
254
254
  That's it! Your agent is now supercharged with Chrome DevTools capabilities.
255
255
 
@@ -515,11 +515,11 @@ If you run into any issues, checkout our [troubleshooting guide](./docs/troubles
515
515
  - [`screencast_start`](docs/tool-reference.md#screencast_start)
516
516
  - [`screencast_stop`](docs/tool-reference.md#screencast_stop)
517
517
  - **Memory** (5 tools)
518
- - [`take_memory_snapshot`](docs/tool-reference.md#take_memory_snapshot)
519
- - [`get_memory_snapshot_details`](docs/tool-reference.md#get_memory_snapshot_details)
520
- - [`get_node_retainers`](docs/tool-reference.md#get_node_retainers)
521
- - [`get_nodes_by_class`](docs/tool-reference.md#get_nodes_by_class)
522
- - [`load_memory_snapshot`](docs/tool-reference.md#load_memory_snapshot)
518
+ - [`take_heapsnapshot`](docs/tool-reference.md#take_heapsnapshot)
519
+ - [`get_heapsnapshot_class_nodes`](docs/tool-reference.md#get_heapsnapshot_class_nodes)
520
+ - [`get_heapsnapshot_details`](docs/tool-reference.md#get_heapsnapshot_details)
521
+ - [`get_heapsnapshot_retainers`](docs/tool-reference.md#get_heapsnapshot_retainers)
522
+ - [`get_heapsnapshot_summary`](docs/tool-reference.md#get_heapsnapshot_summary)
523
523
  - **Extensions** (5 tools)
524
524
  - [`install_extension`](docs/tool-reference.md#install_extension)
525
525
  - [`list_extensions`](docs/tool-reference.md#list_extensions)
@@ -109,7 +109,7 @@ const DEFAULT_FACTORY = async (page) => {
109
109
  targetManager.observeModels(DevTools.NetworkManager.NetworkManager, DISABLE_NETWORK);
110
110
  const target = targetManager.createTarget('main', '', 'frame', // eslint-disable-line @typescript-eslint/no-explicit-any
111
111
  /* parentTarget */ null, session.id(), undefined, connection);
112
- return { target, universe };
112
+ return { target, universe, session };
113
113
  };
114
114
  // We don't want to pause any DevTools universe session ever on the MCP side.
115
115
  //
@@ -20,8 +20,8 @@ export class HeapSnapshotManager {
20
20
  this.#snapshots.set(absolutePath, {
21
21
  snapshot,
22
22
  worker,
23
- uidToClassKey: new Map(),
24
- classKeyToUid: new Map(),
23
+ idToClassKey: new Map(),
24
+ classKeyToId: new Map(),
25
25
  idGenerator: createIdGenerator(),
26
26
  });
27
27
  return snapshot;
@@ -31,10 +31,10 @@ export class HeapSnapshotManager {
31
31
  const filter = new DevTools.HeapSnapshotModel.HeapSnapshotModel.NodeFilter();
32
32
  const aggregates = await snapshot.aggregatesWithFilter(filter);
33
33
  for (const key of Object.keys(aggregates)) {
34
- const uid = await this.getOrCreateUidForClassKey(filePath, key);
34
+ const id = await this.getOrCreateIdForClassKey(filePath, key);
35
35
  const aggregate = aggregates[key];
36
36
  if (aggregate) {
37
- aggregate[stableIdSymbol] = uid;
37
+ aggregate[stableIdSymbol] = id;
38
38
  }
39
39
  }
40
40
  return aggregates;
@@ -47,22 +47,22 @@ export class HeapSnapshotManager {
47
47
  const snapshot = await this.getSnapshot(filePath);
48
48
  return snapshot.staticData;
49
49
  }
50
- async getOrCreateUidForClassKey(filePath, classKey) {
50
+ async getOrCreateIdForClassKey(filePath, classKey) {
51
51
  const cached = this.#getCachedSnapshot(filePath);
52
- let uid = cached.classKeyToUid.get(classKey);
53
- if (!uid) {
54
- uid = cached.idGenerator();
55
- cached.classKeyToUid.set(classKey, uid);
56
- cached.uidToClassKey.set(uid, classKey);
52
+ let id = cached.classKeyToId.get(classKey);
53
+ if (!id) {
54
+ id = cached.idGenerator();
55
+ cached.classKeyToId.set(classKey, id);
56
+ cached.idToClassKey.set(id, classKey);
57
57
  }
58
- return uid;
58
+ return id;
59
59
  }
60
- async getNodesByUid(filePath, uid) {
60
+ async getNodesById(filePath, id) {
61
61
  const snapshot = await this.getSnapshot(filePath);
62
62
  const filter = new DevTools.HeapSnapshotModel.HeapSnapshotModel.NodeFilter();
63
- const className = await this.resolveClassKeyFromUid(filePath, uid);
63
+ const className = await this.resolveClassKeyFromId(filePath, id);
64
64
  if (!className) {
65
- throw new Error(`Class with UID ${uid} not found in heap snapshot`);
65
+ throw new Error(`Class with ID ${id} not found in heap snapshot`);
66
66
  }
67
67
  const provider = snapshot.createNodesProviderForClass(className, filter);
68
68
  return await provider.serializeItemsRange(0, Infinity);
@@ -99,9 +99,9 @@ export class HeapSnapshotManager {
99
99
  }
100
100
  return cached;
101
101
  }
102
- async resolveClassKeyFromUid(filePath, uid) {
102
+ async resolveClassKeyFromId(filePath, id) {
103
103
  const cached = this.#getCachedSnapshot(filePath);
104
- return cached.uidToClassKey.get(uid);
104
+ return cached.idToClassKey.get(id);
105
105
  }
106
106
  async #loadSnapshot(absolutePath) {
107
107
  const workerProxy = new DevTools.HeapSnapshotModel.HeapSnapshotProxy.HeapSnapshotWorkerProxy(() => {
@@ -4,6 +4,7 @@
4
4
  * SPDX-License-Identifier: Apache-2.0
5
5
  */
6
6
  import fs from 'node:fs/promises';
7
+ import fsPromises from 'node:fs/promises';
7
8
  import os from 'node:os';
8
9
  import path from 'node:path';
9
10
  import { fileURLToPath, pathToFileURL } from 'node:url';
@@ -14,7 +15,7 @@ import { NetworkCollector, ConsoleCollector, } from './PageCollector.js';
14
15
  import { Locator, PredefinedNetworkConditions, } from './third_party/index.js';
15
16
  import { listPages } from './tools/pages.js';
16
17
  import { CLOSE_PAGE_ERROR } from './tools/ToolDefinition.js';
17
- import { ensureExtension, getTempFilePath } from './utils/files.js';
18
+ import { ensureExtension, getTempFilePath, resolveCanonicalPath, } from './utils/files.js';
18
19
  import { getNetworkMultiplierFromString } from './WaitForHelper.js';
19
20
  const DEFAULT_TIMEOUT = 5_000;
20
21
  const NAVIGATION_TIMEOUT = 10_000;
@@ -106,7 +107,7 @@ export class McpContext {
106
107
  setRoots(roots) {
107
108
  this.#roots = roots;
108
109
  }
109
- validatePath(filePath) {
110
+ async validatePath(filePath) {
110
111
  if (filePath === undefined) {
111
112
  return;
112
113
  }
@@ -114,15 +115,36 @@ export class McpContext {
114
115
  if (roots === undefined) {
115
116
  return;
116
117
  }
117
- const absolutePath = path.resolve(filePath);
118
+ let canonicalPath;
119
+ try {
120
+ canonicalPath = await resolveCanonicalPath(filePath);
121
+ }
122
+ catch (err) {
123
+ const errMsg = err instanceof Error ? err.message : String(err);
124
+ console.error(`[MCP Context] Error resolving real path for ${filePath}: ${errMsg}`);
125
+ throw new Error(`Access denied: Cannot resolve base path for ${filePath}.`);
126
+ }
127
+ let allowed = false;
118
128
  for (const root of roots) {
119
- const rootPath = path.resolve(fileURLToPath(root.uri));
120
- if (absolutePath === rootPath ||
121
- absolutePath.startsWith(rootPath + path.sep)) {
122
- return;
129
+ try {
130
+ const rootPathUri = root.uri;
131
+ const rootPath = path.resolve(fileURLToPath(rootPathUri));
132
+ const canonicalRoot = await fsPromises.realpath(rootPath);
133
+ if (canonicalPath === canonicalRoot ||
134
+ canonicalPath.startsWith(canonicalRoot + path.sep)) {
135
+ allowed = true;
136
+ break;
137
+ }
138
+ }
139
+ catch (rootErr) {
140
+ const errMsg = rootErr instanceof Error ? rootErr.message : String(rootErr);
141
+ console.warn(`[MCP Context] Could not resolve configured root ${root.uri}: ${errMsg}`);
142
+ // Skip this root if it cannot be resolved.
123
143
  }
124
144
  }
125
- throw new Error(`Access denied: path ${filePath} is not within any of the workspace roots ${JSON.stringify(roots)}.`);
145
+ if (!allowed) {
146
+ throw new Error(`Access denied: path ${filePath} (canonical: ${canonicalPath}) is not within any of the configured workspace roots.`);
147
+ }
126
148
  }
127
149
  resolveCdpRequestId(page, cdpRequestId) {
128
150
  if (!cdpRequestId) {
@@ -213,12 +235,23 @@ export class McpContext {
213
235
  await page.emulateNetworkConditions(networkCondition);
214
236
  newSettings.networkConditions = options.networkConditions;
215
237
  }
238
+ const secondarySession = this.getDevToolsUniverse(mcpPage)?.session;
216
239
  if (!options.cpuThrottlingRate) {
217
240
  await page.emulateCPUThrottling(1);
241
+ if (secondarySession) {
242
+ await secondarySession.send('Emulation.setCPUThrottlingRate', {
243
+ rate: 1,
244
+ });
245
+ }
218
246
  delete newSettings.cpuThrottlingRate;
219
247
  }
220
248
  else {
221
249
  await page.emulateCPUThrottling(options.cpuThrottlingRate);
250
+ if (secondarySession) {
251
+ await secondarySession.send('Emulation.setCPUThrottlingRate', {
252
+ rate: options.cpuThrottlingRate,
253
+ });
254
+ }
222
255
  newSettings.cpuThrottlingRate = options.cpuThrottlingRate;
223
256
  }
224
257
  if (!options.geolocation) {
@@ -250,7 +283,6 @@ export class McpContext {
250
283
  newSettings.colorScheme = options.colorScheme;
251
284
  }
252
285
  if (!options.viewport) {
253
- await page.setViewport(null);
254
286
  delete newSettings.viewport;
255
287
  }
256
288
  else {
@@ -260,14 +292,22 @@ export class McpContext {
260
292
  hasTouch: false,
261
293
  isLandscape: false,
262
294
  };
263
- const viewport = { ...defaults, ...options.viewport };
264
- await page.setViewport(viewport);
265
- newSettings.viewport = viewport;
295
+ newSettings.viewport = { ...defaults, ...options.viewport };
296
+ }
297
+ if (options.extraHttpHeaders !== undefined) {
298
+ await page.setExtraHTTPHeaders(options.extraHttpHeaders);
299
+ newSettings.extraHttpHeaders = options.extraHttpHeaders;
300
+ if (Object.keys(options.extraHttpHeaders).length === 0) {
301
+ delete newSettings.extraHttpHeaders;
302
+ }
266
303
  }
267
304
  mcpPage.emulationSettings = Object.keys(newSettings).length
268
305
  ? newSettings
269
306
  : {};
270
307
  this.#updateSelectedPageTimeouts();
308
+ // This should happen after updating the page timeouts.
309
+ // Setting the viewport can trigger a reload which we don't want to timeout.
310
+ await page.setViewport(newSettings.viewport ?? null);
271
311
  }
272
312
  setIsRunningPerformanceTrace(x) {
273
313
  this.#isRunningTrace = x;
@@ -333,9 +373,9 @@ export class McpContext {
333
373
  page.pptrPage.setDefaultTimeout(DEFAULT_TIMEOUT * cpuMultiplier);
334
374
  // 10sec should be enough for the load event to be emitted during
335
375
  // navigations.
336
- // Increased in case we throttle the network requests
376
+ // Increased in case we throttle the network requests or the CPU
337
377
  const networkMultiplier = getNetworkMultiplierFromString(page.networkConditions);
338
- page.pptrPage.setDefaultNavigationTimeout(NAVIGATION_TIMEOUT * networkMultiplier);
378
+ page.pptrPage.setDefaultNavigationTimeout(NAVIGATION_TIMEOUT * networkMultiplier * cpuMultiplier);
339
379
  }
340
380
  // Linear scan over per-page snapshots. The page count is small (typically
341
381
  // 2-10) so a reverse index isn't worthwhile given the uid-reuse lifecycle
@@ -499,7 +539,7 @@ export class McpContext {
499
539
  }
500
540
  async saveTemporaryFile(data, filename) {
501
541
  const filepath = await getTempFilePath(filename);
502
- this.validatePath(filepath);
542
+ await this.validatePath(filepath);
503
543
  try {
504
544
  await fs.writeFile(filepath, data);
505
545
  }
@@ -509,7 +549,7 @@ export class McpContext {
509
549
  return { filepath };
510
550
  }
511
551
  async saveFile(data, clientProvidedFilePath, extension) {
512
- this.validatePath(clientProvidedFilePath);
552
+ await this.validatePath(clientProvidedFilePath);
513
553
  try {
514
554
  const filePath = ensureExtension(path.resolve(clientProvidedFilePath), extension);
515
555
  await fs.mkdir(path.dirname(filePath), { recursive: true });
@@ -562,7 +602,6 @@ export class McpContext {
562
602
  await this.#networkCollector.init(pages);
563
603
  }
564
604
  async installExtension(extensionPath) {
565
- this.validatePath(extensionPath);
566
605
  const id = await this.browser.installExtension(extensionPath);
567
606
  return id;
568
607
  }
@@ -586,20 +625,16 @@ export class McpContext {
586
625
  return pptrExtensions.get(id);
587
626
  }
588
627
  async getHeapSnapshotAggregates(filePath) {
589
- this.validatePath(filePath);
590
628
  return await this.#heapSnapshotManager.getAggregates(filePath);
591
629
  }
592
630
  async getHeapSnapshotStats(filePath) {
593
- this.validatePath(filePath);
594
631
  return await this.#heapSnapshotManager.getStats(filePath);
595
632
  }
596
633
  async getHeapSnapshotStaticData(filePath) {
597
- this.validatePath(filePath);
598
634
  return await this.#heapSnapshotManager.getStaticData(filePath);
599
635
  }
600
- async getHeapSnapshotNodesByUid(filePath, uid) {
601
- this.validatePath(filePath);
602
- return await this.#heapSnapshotManager.getNodesByUid(filePath, uid);
636
+ async getHeapSnapshotNodesById(filePath, id) {
637
+ return await this.#heapSnapshotManager.getNodesById(filePath, id);
603
638
  }
604
639
  async getHeapSnapshotRetainers(filePath, nodeId) {
605
640
  return await this.#heapSnapshotManager.getRetainers(filePath, nodeId);
@@ -505,7 +505,7 @@ export class McpResponse {
505
505
  }
506
506
  const geolocation = this.#page?.geolocation;
507
507
  if (geolocation) {
508
- response.push(`Emulating geolocation: latitude=${geolocation.latitude}, longtitude=${geolocation.longitude}`);
508
+ response.push(`Emulating geolocation: latitude=${geolocation.latitude}, longitude=${geolocation.longitude}`);
509
509
  structuredContent.geolocation = geolocation;
510
510
  }
511
511
  const viewport = this.#page?.viewport;
@@ -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: {
@@ -240,8 +246,8 @@ export const commands = {
240
246
  },
241
247
  },
242
248
  },
243
- get_memory_snapshot_details: {
244
- 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)',
245
251
  category: 'Memory',
246
252
  args: {
247
253
  filePath: {
@@ -250,45 +256,51 @@ export const commands = {
250
256
  description: 'A path to a .heapsnapshot file to read.',
251
257
  required: true,
252
258
  },
259
+ id: {
260
+ name: 'id',
261
+ type: 'number',
262
+ description: 'The ID for the class, obtained from details.',
263
+ required: true,
264
+ },
253
265
  pageIdx: {
254
266
  name: 'pageIdx',
255
267
  type: 'number',
256
- description: 'The page index for pagination of aggregates.',
268
+ description: 'The page index for pagination.',
257
269
  required: false,
258
270
  },
259
271
  pageSize: {
260
272
  name: 'pageSize',
261
273
  type: 'number',
262
- description: 'The page size for pagination of aggregates.',
274
+ description: 'The page size for pagination.',
263
275
  required: false,
264
276
  },
265
277
  },
266
278
  },
267
- get_network_request: {
268
- description: 'Gets a network request by an optional reqid, if omitted returns the currently selected request in the DevTools Network panel.',
269
- 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',
270
282
  args: {
271
- reqid: {
272
- name: 'reqid',
273
- type: 'number',
274
- description: 'The reqid of the network request. If omitted returns the currently selected request in the DevTools Network panel.',
275
- required: false,
276
- },
277
- requestFilePath: {
278
- name: 'requestFilePath',
283
+ filePath: {
284
+ name: 'filePath',
279
285
  type: 'string',
280
- 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.',
281
293
  required: false,
282
294
  },
283
- responseFilePath: {
284
- name: 'responseFilePath',
285
- type: 'string',
286
- 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.',
287
299
  required: false,
288
300
  },
289
301
  },
290
302
  },
291
- get_node_retainers: {
303
+ get_heapsnapshot_retainers: {
292
304
  description: 'Loads a memory heapsnapshot and returns retainers for a specific node ID. (requires flag: --experimentalMemory=true)',
293
305
  category: 'Memory',
294
306
  args: {
@@ -301,7 +313,7 @@ export const commands = {
301
313
  nodeId: {
302
314
  name: 'nodeId',
303
315
  type: 'number',
304
- description: 'The stable node ID to get retainers for.',
316
+ description: 'The node ID to get retainers for.',
305
317
  required: true,
306
318
  },
307
319
  pageIdx: {
@@ -318,8 +330,8 @@ export const commands = {
318
330
  },
319
331
  },
320
332
  },
321
- get_nodes_by_class: {
322
- description: 'Loads a memory heapsnapshot and returns instances of a specific class with their stable IDs. (requires flag: --experimentalMemory=true)',
333
+ get_heapsnapshot_summary: {
334
+ description: 'Loads a memory heapsnapshot and returns snapshot summary stats. (requires flag: --experimentalMemory=true)',
323
335
  category: 'Memory',
324
336
  args: {
325
337
  filePath: {
@@ -328,22 +340,28 @@ export const commands = {
328
340
  description: 'A path to a .heapsnapshot file to read.',
329
341
  required: true,
330
342
  },
331
- uid: {
332
- name: 'uid',
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',
333
351
  type: 'number',
334
- description: 'The unique UID for the class, obtained from aggregates listing.',
335
- required: true,
352
+ description: 'The reqid of the network request. If omitted returns the currently selected request in the DevTools Network panel.',
353
+ required: false,
336
354
  },
337
- pageIdx: {
338
- name: 'pageIdx',
339
- type: 'number',
340
- description: 'The page index for pagination.',
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.',
341
359
  required: false,
342
360
  },
343
- pageSize: {
344
- name: 'pageSize',
345
- type: 'number',
346
- description: 'The page size for pagination.',
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.',
347
365
  required: false,
348
366
  },
349
367
  },
@@ -507,18 +525,6 @@ export const commands = {
507
525
  category: 'WebMCP',
508
526
  args: {},
509
527
  },
510
- load_memory_snapshot: {
511
- description: 'Loads a memory heapsnapshot and returns snapshot summary stats. (requires flag: --experimentalMemory=true)',
512
- category: 'Memory',
513
- args: {
514
- filePath: {
515
- name: 'filePath',
516
- type: 'string',
517
- description: 'A path to a .heapsnapshot file to read.',
518
- required: true,
519
- },
520
- },
521
- },
522
528
  navigate_page: {
523
529
  description: 'Go to a URL, or back, forward, or reload. Use project URL if not specified otherwise.',
524
530
  category: 'Navigation automation',
@@ -732,7 +738,7 @@ export const commands = {
732
738
  },
733
739
  },
734
740
  },
735
- take_memory_snapshot: {
741
+ take_heapsnapshot: {
736
742
  description: 'Capture a heap snapshot of the currently selected page. Use to analyze the memory distribution of JavaScript objects and debug memory leaks.',
737
743
  category: 'Memory',
738
744
  args: {
@@ -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