diff --git a/.npmrc b/.npmrc new file mode 100644 index 000000000..cafe685a1 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +package-lock=true diff --git a/README.md b/README.md index 438024c3e..edbb171db 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,12 @@ Google handles this data in accordance with the [Google Privacy Policy](https:// Google's collection of usage statistics for Chrome DevTools MCP is independent from the Chrome browser's usage statistics. Opting out of Chrome metrics does not automatically opt you out of this tool, and vice-versa. -Collection is disabled if CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS or CI env variables are set. +Collection is disabled if `CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS` or `CI` env variables are set. + +## Update checks + +By default, the server periodically checks the npm registry for updates and logs a notification when a newer version is available. +You can disable these update checks by setting the `CHROME_DEVTOOLS_MCP_NO_UPDATE_CHECKS` environment variable. ## Requirements @@ -74,7 +79,7 @@ Add the following config to your MCP client: } ``` -> [!NOTE] +> [!NOTE] > Using `chrome-devtools-mcp@latest` ensures that your MCP client will always use the latest version of the Chrome DevTools MCP server. If you are interested in doing only basic browser tasks, use the `--slim` mode: @@ -143,7 +148,7 @@ claude mcp add chrome-devtools --scope user npx chrome-devtools-mcp@latest **Install as a Plugin (MCP + Skills)** -> [!NOTE] +> [!NOTE] > If you already had Chrome DevTools MCP installed previously for Claude Code, make sure to remove it first from your installation and configuration files. To install Chrome DevTools MCP with skills, add the marketplace registry in Claude Code: @@ -200,7 +205,7 @@ startup_timeout_ms = 20_000
Command Code - + Use the Command Code CLI to add the Chrome DevTools MCP server (MCP guide): ```bash @@ -402,10 +407,11 @@ qodercli mcp add -s user chrome-devtools -- npx chrome-devtools-mcp@latest
Visual Studio - - **Click the button to install:** - - [Install in Visual Studio](https://vs-open.link/mcp-install?%7B%22name%22%3A%22chrome-devtools%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22chrome-devtools-mcp%40latest%22%5D%7D) + +**Click the button to install:** + +[Install in Visual Studio](https://vs-open.link/mcp-install?%7B%22name%22%3A%22chrome-devtools%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22chrome-devtools-mcp%40latest%22%5D%7D) +
@@ -431,7 +437,7 @@ Check the performance of https://developers.chrome.com Your MCP client should open the browser and record a performance trace. -> [!NOTE] +> [!NOTE] > The MCP server will start the browser automatically once the MCP client uses a tool that requires a running browser instance. Connecting to the Chrome DevTools MCP server on its own will not automatically start the browser. ## Tools @@ -572,7 +578,7 @@ The Chrome DevTools MCP server supports the following configuration option: - **Default:** `true` - **`--usageStatistics`/ `--usage-statistics`** - Set to false to opt-out of usage statistics collection. Google collects usage data to improve the tool, handled under the Google Privacy Policy (https://policies.google.com/privacy). This is independent from Chrome browser metrics. Disabled if CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS or CI env variables are set. + Set to false to opt-out of usage statistics collection. Google collects usage data to improve the tool, handled under the Google Privacy Policy (https://policies.google.com/privacy). This is independent from Chrome browser metrics. Disabled if `CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS` or `CI` env variables are set. - **Type:** boolean - **Default:** `true` @@ -686,7 +692,7 @@ Make sure your browser is running. Open gemini-cli and run the following prompt: Check the performance of https://developers.chrome.com ``` -> [!NOTE] +> [!NOTE] > The autoConnect option requires the user to start Chrome. If the user has multiple active profiles, the MCP server will connect to the default profile (as determined by Chrome). The MCP server has access to all open windows for the selected profile. The Chrome DevTools MCP server will try to connect to your running Chrome @@ -722,7 +728,7 @@ Add the `--browser-url` option to your MCP client configuration. The value of th **Step 2: Start the Chrome browser** -> [!WARNING] +> [!WARNING] > Enabling the remote debugging port opens up a debugging port on the running browser instance. Any application on your machine can connect to this port and control the browser. Make sure that you are not browsing any sensitive websites while the debugging port is open. Start the Chrome browser with the remote debugging port enabled. Make sure to close any running Chrome instances before starting a new one with the debugging port enabled. The port number you choose must be the same as the one you specified in the `--browser-url` option in your MCP client configuration. diff --git a/src/bin/check-latest-version.ts b/src/bin/check-latest-version.ts new file mode 100644 index 000000000..eb45674df --- /dev/null +++ b/src/bin/check-latest-version.ts @@ -0,0 +1,32 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'node:fs/promises'; +import path from 'node:path'; +import process from 'node:process'; + +const cachePath = process.argv[2]; + +if (cachePath) { + try { + const response = await fetch( + 'https://registry.npmjs.org/chrome-devtools-mcp/latest', + ); + const data = response.ok ? await response.json() : null; + + if ( + data && + typeof data === 'object' && + 'version' in data && + typeof data.version === 'string' + ) { + await fs.mkdir(path.dirname(cachePath), {recursive: true}); + await fs.writeFile(cachePath, JSON.stringify({version: data.version})); + } + } catch { + // Ignore errors. + } +} diff --git a/src/bin/chrome-devtools-mcp-cli-options.ts b/src/bin/chrome-devtools-mcp-cli-options.ts index 80046b115..8fedf2ae7 100644 --- a/src/bin/chrome-devtools-mcp-cli-options.ts +++ b/src/bin/chrome-devtools-mcp-cli-options.ts @@ -232,7 +232,7 @@ export const cliOptions = { type: 'boolean', default: true, describe: - 'Set to false to opt-out of usage statistics collection. Google collects usage data to improve the tool, handled under the Google Privacy Policy (https://policies.google.com/privacy). This is independent from Chrome browser metrics. Disabled if CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS or CI env variables are set.', + 'Set to false to opt-out of usage statistics collection. Google collects usage data to improve the tool, handled under the Google Privacy Policy (https://policies.google.com/privacy). This is independent from Chrome browser metrics. Disabled if `CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS` or `CI` env variables are set.', }, clearcutEndpoint: { type: 'string', diff --git a/src/bin/chrome-devtools-mcp-main.ts b/src/bin/chrome-devtools-mcp-main.ts index bfb6bb38e..46100ed94 100644 --- a/src/bin/chrome-devtools-mcp-main.ts +++ b/src/bin/chrome-devtools-mcp-main.ts @@ -12,10 +12,15 @@ import {createMcpServer, logDisclaimers} from '../index.js'; import {logger, saveLogsToFile} from '../logger.js'; import {computeFlagUsage} from '../telemetry/flagUtils.js'; import {StdioServerTransport} from '../third_party/index.js'; +import {checkForUpdates} from '../utils/check-for-updates.js'; import {VERSION} from '../version.js'; import {cliOptions, parseArguments} from './chrome-devtools-mcp-cli-options.js'; +await checkForUpdates( + 'Run `npm install chrome-devtools-mcp@latest` to update.', +); + export const args = parseArguments(VERSION); const logFile = args.logFile ? saveLogsToFile(args.logFile) : undefined; diff --git a/src/bin/chrome-devtools.ts b/src/bin/chrome-devtools.ts index 53a8eba54..43de5c6b6 100644 --- a/src/bin/chrome-devtools.ts +++ b/src/bin/chrome-devtools.ts @@ -19,11 +19,16 @@ import { import {isDaemonRunning, serializeArgs} from '../daemon/utils.js'; import {logDisclaimers} from '../index.js'; import {hideBin, yargs, type CallToolResult} from '../third_party/index.js'; +import {checkForUpdates} from '../utils/check-for-updates.js'; import {VERSION} from '../version.js'; import {commands} from './chrome-devtools-cli-options.js'; import {cliOptions, parseArguments} from './chrome-devtools-mcp-cli-options.js'; +await checkForUpdates( + 'Run `npm install -g chrome-devtools-mcp@latest` and `chrome-devtools start` to update and restart the daemon.', +); + async function start(args: string[]) { const combinedArgs = [...args, ...defaultArgs]; await startDaemon(combinedArgs); diff --git a/src/utils/check-for-updates.ts b/src/utils/check-for-updates.ts new file mode 100644 index 000000000..36c5b13b8 --- /dev/null +++ b/src/utils/check-for-updates.ts @@ -0,0 +1,96 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import child_process from 'node:child_process'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import process from 'node:process'; + +import {VERSION} from '../version.js'; + +/** + * Notifies the user if an update is available. + * @param message The message to display in the update notification. + */ +let isChecking = false; + +/** @internal Reset flag for tests only. */ +export function resetUpdateCheckFlagForTesting() { + isChecking = false; +} + +export async function checkForUpdates(message: string) { + if (isChecking || process.env['CHROME_DEVTOOLS_MCP_NO_UPDATE_CHECKS']) { + return; + } + isChecking = true; + + const cachePath = path.join( + os.homedir(), + '.cache', + 'chrome-devtools-mcp', + 'latest.json', + ); + + let cachedVersion: string | undefined; + let stats: {mtimeMs: number} | undefined; + try { + stats = await fs.stat(cachePath); + const data = await fs.readFile(cachePath, 'utf8'); + cachedVersion = JSON.parse(data).version; + } catch { + // Ignore errors reading cache. + } + + if (cachedVersion && cachedVersion !== VERSION) { + console.warn( + `\nUpdate available: ${VERSION} -> ${cachedVersion}\n${message}\n`, + ); + } + + const now = Date.now(); + if (stats && now - stats.mtimeMs < 24 * 60 * 60 * 1000) { + return; + } + + // Update mtime immediately to prevent multiple subprocesses. + try { + const parentDir = path.dirname(cachePath); + await fs.mkdir(parentDir, {recursive: true}); + const nowTime = new Date(); + if (stats) { + await fs.utimes(cachePath, nowTime, nowTime); + } else { + await fs.writeFile(cachePath, JSON.stringify({version: VERSION})); + } + } catch { + // Ignore errors. + } + + // In a separate process, check the latest available version number + // and update the local snapshot accordingly. + const scriptPath = path.join( + import.meta.dirname, + '..', + 'bin', + 'check-latest-version.js', + ); + + try { + const child = child_process.spawn( + process.execPath, + [scriptPath, cachePath], + { + detached: true, + stdio: 'ignore', + }, + ); + child.unref(); + } catch { + // Fail silently in case of any errors. + } +} diff --git a/tests/check-for-updates.test.ts b/tests/check-for-updates.test.ts new file mode 100644 index 000000000..82413c493 --- /dev/null +++ b/tests/check-for-updates.test.ts @@ -0,0 +1,148 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'node:assert'; +import child_process from 'node:child_process'; +import type {Stats} from 'node:fs'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import {afterEach, beforeEach, describe, it} from 'node:test'; + +import sinon from 'sinon'; + +import { + checkForUpdates, + resetUpdateCheckFlagForTesting, +} from '../src/utils/check-for-updates.js'; +import {VERSION} from '../src/version.js'; + +describe('checkForUpdates', () => { + beforeEach(() => { + sinon.stub(fs, 'mkdir').resolves(); + sinon.stub(fs, 'utimes').resolves(); + sinon.stub(fs, 'writeFile').resolves(); + }); + + afterEach(() => { + sinon.restore(); + resetUpdateCheckFlagForTesting(); + }); + + it('does nothing if CHROME_DEVTOOLS_MCP_NO_UPDATE_CHECKS is set', async () => { + process.env['CHROME_DEVTOOLS_MCP_NO_UPDATE_CHECKS'] = 'true'; + + const warnStub = sinon.stub(console, 'warn'); + const spawnStub = sinon.stub(child_process, 'spawn'); + const readFileStub = sinon.stub(fs, 'readFile'); + const statStub = sinon.stub(fs, 'stat'); + + await checkForUpdates('Run `npm update` to update.'); + + assert.ok(warnStub.notCalled); + assert.ok(spawnStub.notCalled); + assert.ok(readFileStub.notCalled); + assert.ok(statStub.notCalled); + + delete process.env['CHROME_DEVTOOLS_MCP_NO_UPDATE_CHECKS']; + }); + + it('notifies if cache exists and version is different', async () => { + sinon.stub(os, 'homedir').returns('/home/user'); + sinon.stub(fs, 'stat').resolves({mtimeMs: Date.now()} as unknown as Stats); + sinon.stub(fs, 'readFile').callsFake(async filePath => { + if (filePath.toString().includes('latest.json')) { + return JSON.stringify({ + version: '99.9.9', + }); + } + throw new Error(`File not found: ${filePath}`); + }); + const warnStub = sinon.stub(console, 'warn'); + const spawnStub = sinon.stub(child_process, 'spawn'); + + await checkForUpdates('Run `npm update` to update.'); + + assert.ok( + warnStub.calledWith( + sinon.match('Update available: ' + VERSION + ' -> 99.9.9'), + ), + ); + assert.ok(spawnStub.notCalled); + }); + + it('does not spawn fetch process if cache is fresh', async () => { + sinon.stub(os, 'homedir').returns('/home/user'); + sinon.stub(fs, 'stat').resolves({mtimeMs: Date.now()} as unknown as Stats); + sinon.stub(fs, 'readFile').callsFake(async filePath => { + if (filePath.toString().includes('latest.json')) { + return JSON.stringify({ + version: VERSION, + }); + } + throw new Error(`File not found: ${filePath}`); + }); + const spawnStub = sinon.stub(child_process, 'spawn'); + + await checkForUpdates('Run `npm update` to update.'); + + assert.ok(spawnStub.notCalled); + }); + + it('spawns detached process if cache is stale', async () => { + sinon.stub(os, 'homedir').returns('/home/user'); + sinon.stub(fs, 'stat').resolves({ + mtimeMs: Date.now() - 25 * 60 * 60 * 1000, // 25 hours ago + } as unknown as Stats); + sinon.stub(fs, 'readFile').callsFake(async filePath => { + if (filePath.toString().includes('latest.json')) { + return JSON.stringify({ + version: VERSION, + }); + } + throw new Error(`File not found: ${filePath}`); + }); + + const unrefSpy = sinon.spy(); + const spawnStub = sinon.stub(child_process, 'spawn').returns({ + unref: unrefSpy, + } as unknown as child_process.ChildProcess); + + await checkForUpdates('Run `npm update` to update.'); + + assert.ok(spawnStub.calledOnce); + assert.strictEqual(spawnStub.firstCall.args[0], process.execPath); + assert.ok( + spawnStub.firstCall.args[1][0]?.includes('check-latest-version.js'), + ); + assert.ok(spawnStub.firstCall.args[1][1]?.includes('latest.json')); + assert.strictEqual(spawnStub.firstCall.args[2]?.detached, true); + assert.ok(unrefSpy.calledOnce); + }); + + it('spawns detached process if cache is missing', async () => { + sinon.stub(os, 'homedir').returns('/home/user'); + sinon.stub(fs, 'stat').rejects(new Error('File not found')); + sinon.stub(fs, 'readFile').callsFake(async filePath => { + throw new Error(`File not found: ${filePath}`); + }); + + const unrefSpy = sinon.spy(); + const spawnStub = sinon.stub(child_process, 'spawn').returns({ + unref: unrefSpy, + } as unknown as child_process.ChildProcess); + + await checkForUpdates('Run `npm update` to update.'); + + assert.ok(spawnStub.calledOnce); + assert.strictEqual(spawnStub.firstCall.args[0], process.execPath); + assert.ok( + spawnStub.firstCall.args[1][0]?.includes('check-latest-version.js'), + ); + assert.ok(spawnStub.firstCall.args[1][1]?.includes('latest.json')); + assert.strictEqual(spawnStub.firstCall.args[2]?.detached, true); + assert.ok(unrefSpy.calledOnce); + }); +});