@nimbus21.ai/chrome-devtools-mcp 0.17.5 → 0.17.7

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,21 @@ 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
+ static #INTERNAL_URL_PREFIXES = [
76
+ 'about:',
77
+ 'chrome://',
78
+ 'chrome-extension://',
79
+ 'chrome-untrusted://',
80
+ 'devtools://',
81
+ ];
82
+ #pageLastActivity = new Map();
83
+ #idleReaperTimer;
84
+ #onIdleBrowserEmpty;
85
+ // Tracks when we first noticed only internal pages remaining.
86
+ #onlyInternalPagesSince;
71
87
  #uniqueBackendNodeIdToMcpId = new Map();
72
88
  constructor(browser, logger, options, locatorClass) {
73
89
  this.browser = browser;
@@ -97,14 +113,143 @@ export class McpContext {
97
113
  await this.#devtoolsUniverseManager.init(pages);
98
114
  }
99
115
  dispose() {
116
+ this.stopIdlePageReaper();
100
117
  this.#networkCollector.dispose();
101
118
  this.#consoleCollector.dispose();
102
119
  this.#devtoolsUniverseManager.dispose();
103
120
  }
121
+ /**
122
+ * Record activity on a page, resetting its idle timer.
123
+ */
124
+ touchPage(page) {
125
+ this.#pageLastActivity.set(page, Date.now());
126
+ // A user is interacting with a page — reset the internal-only timer.
127
+ this.#onlyInternalPagesSince = undefined;
128
+ }
129
+ /**
130
+ * Record activity on all currently tracked pages.
131
+ */
132
+ touchAllPages() {
133
+ const now = Date.now();
134
+ for (const page of this.#pages) {
135
+ this.#pageLastActivity.set(page, now);
136
+ }
137
+ }
138
+ /**
139
+ * Start the periodic idle page reaper.
140
+ * @param onBrowserEmpty - called when all pages have been closed by the reaper.
141
+ */
142
+ startIdlePageReaper(onBrowserEmpty) {
143
+ this.#onIdleBrowserEmpty = onBrowserEmpty;
144
+ // Initialize activity timestamps for all existing pages.
145
+ const now = Date.now();
146
+ for (const page of this.#pages) {
147
+ if (!this.#pageLastActivity.has(page)) {
148
+ this.#pageLastActivity.set(page, now);
149
+ }
150
+ }
151
+ this.#idleReaperTimer = setInterval(() => {
152
+ void this.#reapIdlePages();
153
+ }, _a.IDLE_CHECK_INTERVAL_MS);
154
+ // Don't keep the process alive just for the reaper.
155
+ this.#idleReaperTimer.unref();
156
+ }
157
+ stopIdlePageReaper() {
158
+ if (this.#idleReaperTimer) {
159
+ clearInterval(this.#idleReaperTimer);
160
+ this.#idleReaperTimer = undefined;
161
+ }
162
+ }
163
+ static #isInternalPage(page) {
164
+ const url = page.url();
165
+ return _a.#INTERNAL_URL_PREFIXES.some(prefix => url.startsWith(prefix));
166
+ }
167
+ async #reapIdlePages() {
168
+ const now = Date.now();
169
+ const idleTimeout = _a.PAGE_IDLE_TIMEOUT_MS;
170
+ // Refresh page list from browser to get accurate state.
171
+ let pages;
172
+ try {
173
+ pages = await this.createPagesSnapshot();
174
+ }
175
+ catch {
176
+ return; // Browser may be disconnected.
177
+ }
178
+ // Also get ALL pages from the browser (including those filtered by
179
+ // createPagesSnapshot) so we can detect internal-only state.
180
+ let allBrowserPages;
181
+ try {
182
+ allBrowserPages = await this.browser.pages(true);
183
+ }
184
+ catch {
185
+ return;
186
+ }
187
+ const pagesToClose = [];
188
+ for (const page of pages) {
189
+ // Skip internal pages — they can't be closed and will respawn.
190
+ if (_a.#isInternalPage(page)) {
191
+ continue;
192
+ }
193
+ const lastActivity = this.#pageLastActivity.get(page);
194
+ // Pages without a recorded timestamp get one now (first seen).
195
+ if (lastActivity === undefined) {
196
+ this.#pageLastActivity.set(page, now);
197
+ continue;
198
+ }
199
+ if (now - lastActivity > idleTimeout) {
200
+ pagesToClose.push(page);
201
+ }
202
+ }
203
+ // Close idle user pages.
204
+ for (const page of pagesToClose) {
205
+ try {
206
+ this.logger(`Closing idle page ${this.#pageIdMap.get(page)}: ${page.url()} (idle ${Math.round((now - (this.#pageLastActivity.get(page) ?? now)) / 1000)}s)`);
207
+ this.#pageLastActivity.delete(page);
208
+ await page.close({ runBeforeUnload: false });
209
+ }
210
+ catch {
211
+ // Page may already be closed.
212
+ }
213
+ }
214
+ // Re-check all browser pages to determine if only internal pages remain.
215
+ try {
216
+ allBrowserPages = await this.browser.pages(true);
217
+ }
218
+ catch {
219
+ allBrowserPages = [];
220
+ }
221
+ const hasUserPages = allBrowserPages.some(p => !p.isClosed() && !_a.#isInternalPage(p));
222
+ if (hasUserPages) {
223
+ // User pages still exist — reset the internal-only timer.
224
+ this.#onlyInternalPagesSince = undefined;
225
+ return;
226
+ }
227
+ // Only internal pages (or no pages) remain.
228
+ if (!this.#onIdleBrowserEmpty) {
229
+ return;
230
+ }
231
+ if (allBrowserPages.length === 0) {
232
+ // Truly empty — kill immediately.
233
+ this.logger('All pages closed by idle reaper, triggering browser cleanup');
234
+ this.#onIdleBrowserEmpty();
235
+ return;
236
+ }
237
+ // Internal pages only — start or check the grace timer.
238
+ if (this.#onlyInternalPagesSince === undefined) {
239
+ this.#onlyInternalPagesSince = now;
240
+ this.logger(`Only internal pages remain (${allBrowserPages.length}), starting ${idleTimeout / 1000}s grace period`);
241
+ return;
242
+ }
243
+ if (now - this.#onlyInternalPagesSince > idleTimeout) {
244
+ this.logger(`Only internal pages for ${Math.round((now - this.#onlyInternalPagesSince) / 1000)}s, triggering browser cleanup`);
245
+ this.#onlyInternalPagesSince = undefined;
246
+ this.#onIdleBrowserEmpty();
247
+ }
248
+ }
104
249
  static async from(browser, logger, opts,
105
250
  /* Let tests use unbundled Locator class to avoid overly strict checks within puppeteer that fail when mixing bundled and unbundled class instances */
106
251
  locatorClass = Locator) {
107
- const context = new McpContext(browser, logger, opts, locatorClass);
252
+ const context = new _a(browser, logger, opts, locatorClass);
108
253
  await context.#init();
109
254
  return context;
110
255
  }
@@ -166,7 +311,7 @@ export class McpContext {
166
311
  async newPage(background) {
167
312
  const page = await this.browser.newPage({ background });
168
313
  await this.createPagesSnapshot();
169
- this.selectPage(page);
314
+ this.selectPage(page); // Also touches the page via selectPage.
170
315
  this.#networkCollector.addPage(page);
171
316
  this.#consoleCollector.addPage(page);
172
317
  return page;
@@ -306,6 +451,7 @@ export class McpContext {
306
451
  });
307
452
  }
308
453
  this.#selectedPage = newPage;
454
+ this.touchPage(newPage);
309
455
  newPage.on('dialog', this.#dialogHandler);
310
456
  this.#updateSelectedPageTimeouts();
311
457
  void newPage.emulateFocusedPage(true).catch(error => {
@@ -601,3 +747,4 @@ export class McpContext {
601
747
  return this.#extensionRegistry.getById(id);
602
748
  }
603
749
  }
750
+ _a = McpContext;
package/build/src/main.js CHANGED
@@ -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({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nimbus21.ai/chrome-devtools-mcp",
3
- "version": "0.17.5",
3
+ "version": "0.17.7",
4
4
  "description": "MCP server for Chrome DevTools with stealth mode support",
5
5
  "type": "module",
6
6
  "bin": "./build/src/index.js",