@nimbus21.ai/chrome-devtools-mcp 0.17.4 → 0.17.6

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.
@@ -3,6 +3,7 @@
3
3
  * Copyright 2025 Google LLC
4
4
  * SPDX-License-Identifier: Apache-2.0
5
5
  */
6
+ var _a;
6
7
  import fs from 'node:fs/promises';
7
8
  import os from 'node:os';
8
9
  import path from 'node:path';
@@ -68,6 +69,12 @@ export class McpContext {
68
69
  #traceResults = [];
69
70
  #locatorClass;
70
71
  #options;
72
+ // Idle page reaper: tracks last activity time per page.
73
+ static PAGE_IDLE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
74
+ static IDLE_CHECK_INTERVAL_MS = 60 * 1000; // 60 seconds
75
+ #pageLastActivity = new Map();
76
+ #idleReaperTimer;
77
+ #onIdleBrowserEmpty;
71
78
  #uniqueBackendNodeIdToMcpId = new Map();
72
79
  constructor(browser, logger, options, locatorClass) {
73
80
  this.browser = browser;
@@ -97,14 +104,104 @@ export class McpContext {
97
104
  await this.#devtoolsUniverseManager.init(pages);
98
105
  }
99
106
  dispose() {
107
+ this.stopIdlePageReaper();
100
108
  this.#networkCollector.dispose();
101
109
  this.#consoleCollector.dispose();
102
110
  this.#devtoolsUniverseManager.dispose();
103
111
  }
112
+ /**
113
+ * Record activity on a page, resetting its idle timer.
114
+ */
115
+ touchPage(page) {
116
+ this.#pageLastActivity.set(page, Date.now());
117
+ }
118
+ /**
119
+ * Record activity on all currently tracked pages.
120
+ */
121
+ touchAllPages() {
122
+ const now = Date.now();
123
+ for (const page of this.#pages) {
124
+ this.#pageLastActivity.set(page, now);
125
+ }
126
+ }
127
+ /**
128
+ * Start the periodic idle page reaper.
129
+ * @param onBrowserEmpty - called when all pages have been closed by the reaper.
130
+ */
131
+ startIdlePageReaper(onBrowserEmpty) {
132
+ this.#onIdleBrowserEmpty = onBrowserEmpty;
133
+ // Initialize activity timestamps for all existing pages.
134
+ const now = Date.now();
135
+ for (const page of this.#pages) {
136
+ if (!this.#pageLastActivity.has(page)) {
137
+ this.#pageLastActivity.set(page, now);
138
+ }
139
+ }
140
+ this.#idleReaperTimer = setInterval(() => {
141
+ void this.#reapIdlePages();
142
+ }, _a.IDLE_CHECK_INTERVAL_MS);
143
+ // Don't keep the process alive just for the reaper.
144
+ this.#idleReaperTimer.unref();
145
+ }
146
+ stopIdlePageReaper() {
147
+ if (this.#idleReaperTimer) {
148
+ clearInterval(this.#idleReaperTimer);
149
+ this.#idleReaperTimer = undefined;
150
+ }
151
+ }
152
+ async #reapIdlePages() {
153
+ const now = Date.now();
154
+ const idleTimeout = _a.PAGE_IDLE_TIMEOUT_MS;
155
+ // Refresh page list from browser to get accurate state.
156
+ let pages;
157
+ try {
158
+ pages = await this.createPagesSnapshot();
159
+ }
160
+ catch {
161
+ return; // Browser may be disconnected.
162
+ }
163
+ const pagesToClose = [];
164
+ for (const page of pages) {
165
+ const lastActivity = this.#pageLastActivity.get(page);
166
+ // Pages without a recorded timestamp get one now (first seen).
167
+ if (lastActivity === undefined) {
168
+ this.#pageLastActivity.set(page, now);
169
+ continue;
170
+ }
171
+ if (now - lastActivity > idleTimeout) {
172
+ pagesToClose.push(page);
173
+ }
174
+ }
175
+ if (pagesToClose.length === 0) {
176
+ return;
177
+ }
178
+ // Close idle pages. If ALL pages are idle, close them all.
179
+ for (const page of pagesToClose) {
180
+ try {
181
+ this.logger(`Closing idle page ${this.#pageIdMap.get(page)}: ${page.url()} (idle ${Math.round((now - (this.#pageLastActivity.get(page) ?? now)) / 1000)}s)`);
182
+ this.#pageLastActivity.delete(page);
183
+ await page.close({ runBeforeUnload: false });
184
+ }
185
+ catch {
186
+ // Page may already be closed.
187
+ }
188
+ }
189
+ // Refresh after closing.
190
+ try {
191
+ pages = await this.createPagesSnapshot();
192
+ }
193
+ catch {
194
+ pages = [];
195
+ }
196
+ if (pages.length === 0 && this.#onIdleBrowserEmpty) {
197
+ this.logger('All pages closed by idle reaper, triggering browser cleanup');
198
+ this.#onIdleBrowserEmpty();
199
+ }
200
+ }
104
201
  static async from(browser, logger, opts,
105
202
  /* Let tests use unbundled Locator class to avoid overly strict checks within puppeteer that fail when mixing bundled and unbundled class instances */
106
203
  locatorClass = Locator) {
107
- const context = new McpContext(browser, logger, opts, locatorClass);
204
+ const context = new _a(browser, logger, opts, locatorClass);
108
205
  await context.#init();
109
206
  return context;
110
207
  }
@@ -166,7 +263,7 @@ export class McpContext {
166
263
  async newPage(background) {
167
264
  const page = await this.browser.newPage({ background });
168
265
  await this.createPagesSnapshot();
169
- this.selectPage(page);
266
+ this.selectPage(page); // Also touches the page via selectPage.
170
267
  this.#networkCollector.addPage(page);
171
268
  this.#consoleCollector.addPage(page);
172
269
  return page;
@@ -306,6 +403,7 @@ export class McpContext {
306
403
  });
307
404
  }
308
405
  this.#selectedPage = newPage;
406
+ this.touchPage(newPage);
309
407
  newPage.on('dialog', this.#dialogHandler);
310
408
  this.#updateSelectedPageTimeouts();
311
409
  void newPage.emulateFocusedPage(true).catch(error => {
@@ -601,3 +699,4 @@ export class McpContext {
601
699
  return this.#extensionRegistry.getById(id);
602
700
  }
603
701
  }
702
+ _a = McpContext;
@@ -13,6 +13,36 @@ import { puppeteer } from './third_party/index.js';
13
13
  // Add stealth plugin
14
14
  puppeteerExtra.use(StealthPlugin());
15
15
  let browser;
16
+ /**
17
+ * Close the browser instance, killing the Chrome process if we launched it.
18
+ * Safe to call multiple times or when no browser exists.
19
+ */
20
+ export async function closeBrowser() {
21
+ if (!browser) {
22
+ return;
23
+ }
24
+ const b = browser;
25
+ browser = undefined;
26
+ try {
27
+ if (b.connected) {
28
+ // browser.close() sends a Browser.close CDP command and kills the process
29
+ // if it was launched by puppeteer. For connected browsers it just disconnects.
30
+ await b.close();
31
+ }
32
+ }
33
+ catch {
34
+ // Best-effort: if close fails, try to kill the process directly
35
+ try {
36
+ const proc = b.process();
37
+ if (proc && !proc.killed) {
38
+ proc.kill('SIGKILL');
39
+ }
40
+ }
41
+ catch {
42
+ // Nothing more we can do
43
+ }
44
+ }
45
+ }
16
46
  function makeTargetFilter() {
17
47
  const ignoredPrefixes = new Set([
18
48
  'chrome://',
package/build/src/main.js CHANGED
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import './polyfill.js';
7
7
  import process from 'node:process';
8
- import { ensureBrowserConnected, ensureBrowserLaunched } from './browser.js';
8
+ import { closeBrowser, ensureBrowserConnected, ensureBrowserLaunched } from './browser.js';
9
9
  import { cliOptions, parseArguments } from './cli.js';
10
10
  import { loadIssueDescriptions } from './issue-descriptions.js';
11
11
  import { logger, saveLogsToFile } from './logger.js';
@@ -88,11 +88,20 @@ async function getContext() {
88
88
  enableExtensions: args.categoryExtensions,
89
89
  });
90
90
  if (context?.browser !== browser) {
91
+ // Stop the old reaper before creating a new context.
92
+ context?.stopIdlePageReaper();
91
93
  context = await McpContext.from(browser, logger, {
92
94
  experimentalDevToolsDebugging: devtools,
93
95
  experimentalIncludeAllPages: args.experimentalIncludeAllPages,
94
96
  performanceCrux: args.performanceCrux,
95
97
  });
98
+ // Start idle page reaper: closes pages idle for 5+ minutes.
99
+ // When all pages are closed, kill the browser entirely.
100
+ // On the next tool call, getContext() will relaunch transparently.
101
+ context.startIdlePageReaper(() => {
102
+ logger('Idle reaper: all pages closed, shutting down browser');
103
+ void closeBrowser();
104
+ });
96
105
  }
97
106
  return context;
98
107
  }
@@ -147,6 +156,13 @@ function registerTool(tool) {
147
156
  logger(`${tool.name} request: ${JSON.stringify(params, null, ' ')}`);
148
157
  const context = await getContext();
149
158
  logger(`${tool.name} context: resolved`);
159
+ // Mark the selected page as active to prevent idle reaper from closing it.
160
+ try {
161
+ context.touchPage(context.getSelectedPage());
162
+ }
163
+ catch {
164
+ // No page selected yet — that's fine.
165
+ }
150
166
  await context.detectOpenDevToolsWindows();
151
167
  const response = new McpResponse();
152
168
  await tool.handler({
@@ -195,6 +211,43 @@ await loadIssueDescriptions();
195
211
  const transport = new StdioServerTransport();
196
212
  await server.connect(transport);
197
213
  logger('Chrome DevTools MCP Server connected');
214
+ // Graceful shutdown: kill Chrome when the MCP server exits for any reason.
215
+ let shuttingDown = false;
216
+ async function gracefulShutdown(reason) {
217
+ if (shuttingDown) {
218
+ return;
219
+ }
220
+ shuttingDown = true;
221
+ logger(`Shutting down: ${reason}`);
222
+ try {
223
+ context?.dispose();
224
+ }
225
+ catch {
226
+ // best-effort
227
+ }
228
+ try {
229
+ await closeBrowser();
230
+ }
231
+ catch {
232
+ // best-effort
233
+ }
234
+ logger('Shutdown complete');
235
+ process.exit(0);
236
+ }
237
+ // Handle OS signals (container stop, systemd, Ctrl+C)
238
+ for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP']) {
239
+ process.on(signal, () => {
240
+ void gracefulShutdown(`received ${signal}`);
241
+ });
242
+ }
243
+ // Handle MCP client disconnect (transport/server close)
244
+ server.server.onclose = () => {
245
+ void gracefulShutdown('MCP client disconnected');
246
+ };
247
+ // Handle stdin closing (parent process died)
248
+ process.stdin.on('close', () => {
249
+ void gracefulShutdown('stdin closed');
250
+ });
198
251
  logDisclaimers();
199
252
  void clearcutLogger?.logDailyActiveIfNeeded();
200
253
  void clearcutLogger?.logServerStart(computeFlagUsage(args, cliOptions));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nimbus21.ai/chrome-devtools-mcp",
3
- "version": "0.17.4",
3
+ "version": "0.17.6",
4
4
  "description": "MCP server for Chrome DevTools with stealth mode support",
5
5
  "type": "module",
6
6
  "bin": "./build/src/index.js",