diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index bc7c4d60e..16f7daef7 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -20,6 +20,7 @@ jobs: - windows-latest - macos-latest node: + - 22.12.0 - 22 - 23 - 24 diff --git a/README.md b/README.md index 4b4ed3ee8..1f1ed0509 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ MCP clients. ## Requirements -- [Node.js 22](https://nodejs.org/) or newer. +- [Node.js 22.12.0](https://nodejs.org/) or newer. - [Chrome](https://www.google.com/chrome/) current stable version or newer. - [npm](https://www.npmjs.com/). diff --git a/package.json b/package.json index 8aef40555..e33396c3f 100644 --- a/package.json +++ b/package.json @@ -58,5 +58,8 @@ "sinon": "^21.0.0", "typescript": "^5.9.2", "typescript-eslint": "^8.43.0" + }, + "engines": { + "node": ">=22.12.0" } } diff --git a/scripts/generate-docs.ts b/scripts/generate-docs.ts index d42db530e..a4fc5b2a5 100644 --- a/scripts/generate-docs.ts +++ b/scripts/generate-docs.ts @@ -8,7 +8,7 @@ import {Client} from '@modelcontextprotocol/sdk/client/index.js'; import {StdioClientTransport} from '@modelcontextprotocol/sdk/client/stdio.js'; import type {Tool} from '@modelcontextprotocol/sdk/types.js'; import {ToolCategories} from '../build/src/tools/categories.js'; -import {cliOptions} from '../build/src/index.js'; +import {cliOptions} from '../build/src/main.js'; import fs from 'fs'; const MCP_SERVER_PATH = 'build/src/index.js'; diff --git a/src/index.ts b/src/index.ts index ed374861d..dc27f76fd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,241 +1,18 @@ #!/usr/bin/env node + /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ -import {McpServer} from '@modelcontextprotocol/sdk/server/mcp.js'; -import {StdioServerTransport} from '@modelcontextprotocol/sdk/server/stdio.js'; -import { - CallToolResult, - SetLevelRequestSchema, -} from '@modelcontextprotocol/sdk/types.js'; -import yargs from 'yargs'; -import {hideBin} from 'yargs/helpers'; - -import {McpResponse} from './McpResponse.js'; -import {McpContext} from './McpContext.js'; - -import {ToolDefinition} from './tools/ToolDefinition.js'; -import {logger, saveLogsToFile} from './logger.js'; -import {Channel, resolveBrowser} from './browser.js'; -import * as emulationTools from './tools/emulation.js'; -import * as consoleTools from './tools/console.js'; -import * as inputTools from './tools/input.js'; -import * as networkTools from './tools/network.js'; -import * as pagesTools from './tools/pages.js'; -import * as performanceTools from './tools/performance.js'; -import * as screenshotTools from './tools/screenshot.js'; -import * as scriptTools from './tools/script.js'; -import * as snapshotTools from './tools/snapshot.js'; - -import path from 'node:path'; -import fs from 'node:fs'; -import assert from 'node:assert'; -import {Mutex} from './Mutex.js'; - -export const cliOptions = { - browserUrl: { - type: 'string' as const, - description: - 'Connect to a running Chrome instance using port forwarding. For more details see: https://developer.chrome.com/docs/devtools/remote-debugging/local-server.', - alias: 'u', - coerce: (url: string) => { - new URL(url); - return url; - }, - }, - headless: { - type: 'boolean' as const, - description: 'Whether to run in headless (no UI) mode.', - default: false, - }, - executablePath: { - type: 'string' as const, - description: 'Path to custom Chrome executable.', - conflicts: 'browserUrl', - alias: 'e', - }, - isolated: { - type: 'boolean' as const, - description: - 'If specified, creates a temporary user-data-dir that is automatically cleaned up after the browser is closed.', - default: false, - }, - customDevtools: { - type: 'string' as const, - description: 'Path to custom DevTools.', - hidden: true, - conflicts: 'browserUrl', - alias: 'd', - }, - channel: { - type: 'string' as const, - description: - 'Specify a different Chrome channel that should be used. The default is the stable channel version.', - choices: ['stable', 'canary', 'beta', 'dev'] as const, - conflicts: ['browserUrl', 'executablePath'], - }, - logFile: { - type: 'string' as const, - describe: 'Save the logs to file.', - hidden: true, - }, -}; - -function readPackageJson(): {version?: string} { - const currentDir = import.meta.dirname; - const packageJsonPath = path.join(currentDir, '..', '..', 'package.json'); - if (!fs.existsSync(packageJsonPath)) { - return {}; - } - try { - const json = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')); - assert.strict(json['name'], 'chrome-devtools-mcp'); - return json; - } catch { - return {}; - } -} - -const version = readPackageJson().version ?? 'unknown'; - -const yargsInstance = yargs(hideBin(process.argv)) - .scriptName('npx chrome-devtools-mcp@latest') - .options(cliOptions) - .check(args => { - // We can't set default in the options else - // Yargs will complain - if (!args.channel && !args.browserUrl) { - args.channel = 'stable'; - } - return true; - }) - .example([ - [ - '$0 --browserUrl http://127.0.0.1:9222', - 'Connect to an existing browser instance', - ], - ['$0 --channel beta', 'Use Chrome Beta installed on this system'], - ['$0 --channel canary', 'Use Chrome Canary installed on this system'], - ['$0 --channel dev', 'Use Chrome Dev installed on this system'], - ['$0 --channel stable', 'Use stable Chrome installed on this system'], - ['$0 --logFile /tmp/log.txt', 'Save logs to a file'], - ['$0 --help', 'Print CLI options'], - ]); - -export const args = yargsInstance - .wrap(Math.min(120, yargsInstance.terminalWidth())) - .help() - .version(version) - .parseSync(); +const [major, minor] = process.version.substring(1).split('.').map(Number); -const logFile = args.logFile ? saveLogsToFile(args.logFile) : undefined; - -logger(`Starting Chrome DevTools MCP Server v${version}`); -const server = new McpServer( - { - name: 'chrome_devtools', - title: 'Chrome DevTools MCP server', - version, - }, - {capabilities: {logging: {}}}, -); -server.server.setRequestHandler(SetLevelRequestSchema, () => { - return {}; -}); - -let context: McpContext; -async function getContext(): Promise { - const browser = await resolveBrowser({ - browserUrl: args.browserUrl, - headless: args.headless, - executablePath: args.executablePath, - customDevTools: args.customDevtools, - channel: args.channel as Channel, - isolated: args.isolated, - logFile, - }); - if (context?.browser !== browser) { - context = await McpContext.from(browser, logger); - } - return context; -} - -const logDisclaimers = () => { +if (major < 22 || (major === 22 && minor < 12)) { console.error( - `chrome-devtools-mcp exposes content of the browser instance to the MCP clients allowing them to inspect, -debug, and modify any data in the browser or DevTools. -Avoid sharing sensitive or personal information that you do want to share with MCP clients.`, - ); -}; - -const toolMutex = new Mutex(); - -function registerTool(tool: ToolDefinition): void { - server.registerTool( - tool.name, - { - description: tool.description, - inputSchema: tool.schema, - annotations: tool.annotations, - }, - async (params): Promise => { - const guard = await toolMutex.acquire(); - try { - logger(`${tool.name} request: ${JSON.stringify(params, null, ' ')}`); - const context = await getContext(); - const response = new McpResponse(); - await tool.handler( - { - params, - }, - response, - context, - ); - try { - const content = await response.handle(tool.name, context); - return { - content, - }; - } catch (error) { - const errorText = - error instanceof Error ? error.message : String(error); - - return { - content: [ - { - type: 'text', - text: errorText, - }, - ], - isError: true, - }; - } - } finally { - guard.dispose(); - } - }, + `ERROR: \`chrome-devtools-mcp\` does not support Node ${process.version}. Please upgrade to Node 22+.`, ); + process.exit(1); } -const tools = [ - ...Object.values(consoleTools), - ...Object.values(emulationTools), - ...Object.values(inputTools), - ...Object.values(networkTools), - ...Object.values(pagesTools), - ...Object.values(performanceTools), - ...Object.values(screenshotTools), - ...Object.values(scriptTools), - ...Object.values(snapshotTools), -]; -for (const tool of tools) { - registerTool(tool as unknown as ToolDefinition); -} - -const transport = new StdioServerTransport(); -await server.connect(transport); -logger('Chrome DevTools MCP Server connected'); -logDisclaimers(); +await import('./main.js'); diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 000000000..ea570f766 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,240 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {McpServer} from '@modelcontextprotocol/sdk/server/mcp.js'; +import {StdioServerTransport} from '@modelcontextprotocol/sdk/server/stdio.js'; +import { + CallToolResult, + SetLevelRequestSchema, +} from '@modelcontextprotocol/sdk/types.js'; +import yargs from 'yargs'; +import {hideBin} from 'yargs/helpers'; + +import {McpResponse} from './McpResponse.js'; +import {McpContext} from './McpContext.js'; + +import {ToolDefinition} from './tools/ToolDefinition.js'; +import {logger, saveLogsToFile} from './logger.js'; +import {Channel, resolveBrowser} from './browser.js'; +import * as emulationTools from './tools/emulation.js'; +import * as consoleTools from './tools/console.js'; +import * as inputTools from './tools/input.js'; +import * as networkTools from './tools/network.js'; +import * as pagesTools from './tools/pages.js'; +import * as performanceTools from './tools/performance.js'; +import * as screenshotTools from './tools/screenshot.js'; +import * as scriptTools from './tools/script.js'; +import * as snapshotTools from './tools/snapshot.js'; + +import path from 'node:path'; +import fs from 'node:fs'; +import assert from 'node:assert'; +import {Mutex} from './Mutex.js'; + +export const cliOptions = { + browserUrl: { + type: 'string' as const, + description: + 'Connect to a running Chrome instance using port forwarding. For more details see: https://developer.chrome.com/docs/devtools/remote-debugging/local-server.', + alias: 'u', + coerce: (url: string) => { + new URL(url); + return url; + }, + }, + headless: { + type: 'boolean' as const, + description: 'Whether to run in headless (no UI) mode.', + default: false, + }, + executablePath: { + type: 'string' as const, + description: 'Path to custom Chrome executable.', + conflicts: 'browserUrl', + alias: 'e', + }, + isolated: { + type: 'boolean' as const, + description: + 'If specified, creates a temporary user-data-dir that is automatically cleaned up after the browser is closed.', + default: false, + }, + customDevtools: { + type: 'string' as const, + description: 'Path to custom DevTools.', + hidden: true, + conflicts: 'browserUrl', + alias: 'd', + }, + channel: { + type: 'string' as const, + description: + 'Specify a different Chrome channel that should be used. The default is the stable channel version.', + choices: ['stable', 'canary', 'beta', 'dev'] as const, + conflicts: ['browserUrl', 'executablePath'], + }, + logFile: { + type: 'string' as const, + describe: 'Save the logs to file.', + hidden: true, + }, +}; + +function readPackageJson(): {version?: string} { + const currentDir = import.meta.dirname; + const packageJsonPath = path.join(currentDir, '..', '..', 'package.json'); + if (!fs.existsSync(packageJsonPath)) { + return {}; + } + try { + const json = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')); + assert.strict(json['name'], 'chrome-devtools-mcp'); + return json; + } catch { + return {}; + } +} + +const version = readPackageJson().version ?? 'unknown'; + +const yargsInstance = yargs(hideBin(process.argv)) + .scriptName('npx chrome-devtools-mcp@latest') + .options(cliOptions) + .check(args => { + // We can't set default in the options else + // Yargs will complain + if (!args.channel && !args.browserUrl) { + args.channel = 'stable'; + } + return true; + }) + .example([ + [ + '$0 --browserUrl http://127.0.0.1:9222', + 'Connect to an existing browser instance', + ], + ['$0 --channel beta', 'Use Chrome Beta installed on this system'], + ['$0 --channel canary', 'Use Chrome Canary installed on this system'], + ['$0 --channel dev', 'Use Chrome Dev installed on this system'], + ['$0 --channel stable', 'Use stable Chrome installed on this system'], + ['$0 --logFile /tmp/log.txt', 'Save logs to a file'], + ['$0 --help', 'Print CLI options'], + ]); + +export const args = yargsInstance + .wrap(Math.min(120, yargsInstance.terminalWidth())) + .help() + .version(version) + .parseSync(); + +const logFile = args.logFile ? saveLogsToFile(args.logFile) : undefined; + +logger(`Starting Chrome DevTools MCP Server v${version}`); +const server = new McpServer( + { + name: 'chrome_devtools', + title: 'Chrome DevTools MCP server', + version, + }, + {capabilities: {logging: {}}}, +); +server.server.setRequestHandler(SetLevelRequestSchema, () => { + return {}; +}); + +let context: McpContext; +async function getContext(): Promise { + const browser = await resolveBrowser({ + browserUrl: args.browserUrl, + headless: args.headless, + executablePath: args.executablePath, + customDevTools: args.customDevtools, + channel: args.channel as Channel, + isolated: args.isolated, + logFile, + }); + if (context?.browser !== browser) { + context = await McpContext.from(browser, logger); + } + return context; +} + +const logDisclaimers = () => { + console.error( + `chrome-devtools-mcp exposes content of the browser instance to the MCP clients allowing them to inspect, +debug, and modify any data in the browser or DevTools. +Avoid sharing sensitive or personal information that you do want to share with MCP clients.`, + ); +}; + +const toolMutex = new Mutex(); + +function registerTool(tool: ToolDefinition): void { + server.registerTool( + tool.name, + { + description: tool.description, + inputSchema: tool.schema, + annotations: tool.annotations, + }, + async (params): Promise => { + const guard = await toolMutex.acquire(); + try { + logger(`${tool.name} request: ${JSON.stringify(params, null, ' ')}`); + const context = await getContext(); + const response = new McpResponse(); + await tool.handler( + { + params, + }, + response, + context, + ); + try { + const content = await response.handle(tool.name, context); + return { + content, + }; + } catch (error) { + const errorText = + error instanceof Error ? error.message : String(error); + + return { + content: [ + { + type: 'text', + text: errorText, + }, + ], + isError: true, + }; + } + } finally { + guard.dispose(); + } + }, + ); +} + +const tools = [ + ...Object.values(consoleTools), + ...Object.values(emulationTools), + ...Object.values(inputTools), + ...Object.values(networkTools), + ...Object.values(pagesTools), + ...Object.values(performanceTools), + ...Object.values(screenshotTools), + ...Object.values(scriptTools), + ...Object.values(snapshotTools), +]; +for (const tool of tools) { + registerTool(tool as unknown as ToolDefinition); +} + +const transport = new StdioServerTransport(); +await server.connect(transport); +logger('Chrome DevTools MCP Server connected'); +logDisclaimers(); diff --git a/tests/setup.ts b/tests/setup.ts index b9690a15d..423f4cc52 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -6,6 +6,13 @@ import {it} from 'node:test'; import path from 'node:path'; +if (!it.snapshot) { + it.snapshot = { + setResolveSnapshotPath: () => {}, + setDefaultSnapshotSerializers: () => {}, + }; +} + // This is run by Node when we execute the tests via the --require flag. it.snapshot.setResolveSnapshotPath(testPath => { // By default the snapshots go into the build directory, but we want them diff --git a/tests/tools/performance.test.ts b/tests/tools/performance.test.ts index 09846cefa..2ec3a06f7 100644 --- a/tests/tools/performance.test.ts +++ b/tests/tools/performance.test.ts @@ -164,7 +164,7 @@ describe('performance', () => { context, ); - t.assert.snapshot(response.responseLines.join('\n')); + t.assert.snapshot?.(response.responseLines.join('\n')); }); }); @@ -250,7 +250,7 @@ describe('performance', () => { .stub(selectedPage.tracing, 'stop') .returns(Promise.resolve(undefined)); await stopTrace.handler({params: {}}, response, context); - t.assert.snapshot(response.responseLines.join('\n')); + t.assert.snapshot?.(response.responseLines.join('\n')); }); }); @@ -263,7 +263,7 @@ describe('performance', () => { return rawData; }); await stopTrace.handler({params: {}}, response, context); - t.assert.snapshot(response.responseLines.join('\n')); + t.assert.snapshot?.(response.responseLines.join('\n')); }); }); }); diff --git a/tests/trace-processing/parse.test.ts b/tests/trace-processing/parse.test.ts index 01b83eade..acb4dd83d 100644 --- a/tests/trace-processing/parse.test.ts +++ b/tests/trace-processing/parse.test.ts @@ -32,7 +32,7 @@ describe('Trace parsing', async () => { assert.ok(result?.insights); const output = getTraceSummary(result); - t.assert.snapshot(output); + t.assert.snapshot?.(output); }); it('will return a message if there is an error', async () => {