@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.
- package/build/src/McpContext.js +101 -2
- package/build/src/browser.js +30 -0
- package/build/src/main.js +54 -1
- package/package.json +1 -1
package/build/src/McpContext.js
CHANGED
|
@@ -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
|
|
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;
|
package/build/src/browser.js
CHANGED
|
@@ -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));
|