diff --git a/package-lock.json b/package-lock.json index 0d9f76b78..d6725f214 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.18.1", "license": "Apache-2.0", "bin": { + "chrome-devtools": "build/src/bin/chrome-devtools.js", "chrome-devtools-mcp": "build/src/index.js" }, "devDependencies": { diff --git a/package.json b/package.json index d9bc43929..ea98c7824 100644 --- a/package.json +++ b/package.json @@ -3,9 +3,13 @@ "version": "0.18.1", "description": "MCP server for Chrome DevTools", "type": "module", - "bin": "./build/src/index.js", + "bin": { + "chrome-devtools-mcp": "./build/src/index.js", + "chrome-devtools": "./build/src/bin/chrome-devtools.js" + }, "main": "./build/src/server.js", "scripts": { + "cli:generate": "node --experimental-strip-types scripts/generate-cli.ts", "clean": "node -e \"require('fs').rmSync('build', {recursive: true, force: true})\"", "bundle": "npm run clean && npm run build && rollup -c rollup.config.mjs && node -e \"require('fs').rmSync('build/node_modules', {recursive: true, force: true})\" && node --experimental-strip-types scripts/append-lighthouse-notices.ts", "build": "tsc && node --experimental-strip-types --no-warnings=ExperimentalWarning scripts/post-build.ts", diff --git a/scripts/generate-cli.ts b/scripts/generate-cli.ts new file mode 100644 index 000000000..a8539ed7c --- /dev/null +++ b/scripts/generate-cli.ts @@ -0,0 +1,168 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'node:fs'; +import path from 'node:path'; + +import {Client} from '@modelcontextprotocol/sdk/client/index.js'; +import {StdioClientTransport} from '@modelcontextprotocol/sdk/client/stdio.js'; + +import {parseArguments} from '../build/src/cli.js'; +import {labels} from '../build/src/tools/categories.js'; +import {createTools} from '../build/src/tools/tools.js'; + +const OUTPUT_PATH = path.join( + import.meta.dirname, + '../src/bin/cliDefinitions.ts', +); + +async function fetchTools() { + console.log('Connecting to chrome-devtools-mcp to fetch tools...'); + // Use the local build of the server + const serverPath = path.join(import.meta.dirname, '../build/src/index.js'); + + const transport = new StdioClientTransport({ + command: 'node', + args: [serverPath], + env: {...process.env, CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS: 'true'}, + }); + + const client = new Client( + { + name: 'chrome-devtools-cli-generator', + version: '0.1.0', + }, + { + capabilities: {}, + }, + ); + + await client.connect(transport); + try { + const toolsResponse = await client.listTools(); + const tools = toolsResponse.tools || []; + console.log(`Fetched ${tools.length} tools`); + return tools; + } finally { + await client.close(); + } +} + +interface CliOption { + name: string; + type: string; + description: string; + required: boolean; + default?: unknown; + enum?: unknown[]; +} + +interface JsonSchema { + type?: string | string[]; + description?: string; + properties?: Record; + required?: string[]; + default?: unknown; + enum?: unknown[]; +} + +function schemaToCLIOptions(schema: JsonSchema): CliOption[] { + if (!schema || !schema.properties) { + return []; + } + const required = schema.required || []; + const properties = schema.properties; + return Object.entries(properties).map(([name, prop]) => { + const isRequired = required.includes(name); + const description = prop.description || ''; + if (typeof prop.type !== 'string') { + throw new Error( + `Property ${name} has a complex type not supported by CLI.`, + ); + } + return { + name, + type: prop.type, + description, + required: isRequired, + default: prop.default, + enum: prop.enum, + }; + }); +} + +async function generateCli() { + const tools = await fetchTools(); + // Sort tools by name + const sortedTools = tools.sort((a, b) => a.name.localeCompare(b.name)); + + const staticTools = createTools(parseArguments()); + const toolNameToCategory = new Map(); + for (const tool of staticTools) { + toolNameToCategory.set( + tool.name, + labels[tool.annotations.category as keyof typeof labels], + ); + } + + const commands: Record< + string, + {description: string; category: string; args: Record} + > = {}; + + for (const tool of sortedTools) { + const options = schemaToCLIOptions(tool.inputSchema); + const args: Record = {}; + for (const opt of options) { + args[opt.name] = opt; + } + const category = toolNameToCategory.get(tool.name); + if (!category) { + throw new Error(`Tool ${tool.name} has no category.`); + } + commands[tool.name] = { + description: tool.description || '', + category, + args, + }; + } + + const lines: string[] = []; + lines.push(`/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// NOTE: do not edit manually. Auto-generated by 'npm run cli:generate'. + +export interface ArgDef { + name: string; + type: string; + description: string; + required: boolean; + default?: string | number | boolean; + enum?: ReadonlyArray; +} +export type Commands = Record< + string, + { + description: string; + category: string; + args: Record + } +>; +export const commands: Commands = ${JSON.stringify(commands, null, 2)} as const; +`); + + fs.writeFileSync(OUTPUT_PATH, lines.join('')); + console.log(`Generated CLI at ${OUTPUT_PATH}`); +} + +generateCli().catch(err => { + console.error('Error during generation:', err); + process.exit(1); +}); diff --git a/src/bin/chrome-devtools.ts b/src/bin/chrome-devtools.ts new file mode 100644 index 000000000..eb967a418 --- /dev/null +++ b/src/bin/chrome-devtools.ts @@ -0,0 +1,148 @@ +#!/usr/bin/env node + +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import process from 'node:process'; + +import yargs, {type Options, type PositionalOptions} from 'yargs'; +import {hideBin} from 'yargs/helpers'; + +import {startDaemon, stopDaemon, sendCommand} from '../daemon/client.js'; +import {isDaemonRunning} from '../daemon/utils.js'; +import {VERSION} from '../version.js'; + +import {commands} from './cliDefinitions.js'; +import {generateCustomHelp} from './customHelp.js'; + +const argv = hideBin(process.argv); + +if (argv.length === 0 || argv[0] === '--custom-help') { + console.log(generateCustomHelp(VERSION, commands)); + process.exit(0); +} + +const y = yargs(argv) + .scriptName('chrome-devtools') + .showHelpOnFail(true) + .demandCommand() + .version(VERSION) + .strict() + .help(true); + +y.command( + 'start', + 'Start or restart chrome-devtools-mcp', + y => y.help(false), // Disable help for start command to avoid parsing issues with passed args + async () => { + if (isDaemonRunning()) { + await stopDaemon(); + } + // Extract args after 'start' + const startIndex = process.argv.indexOf('start'); + const args = startIndex !== -1 ? process.argv.slice(startIndex + 1) : []; + await startDaemon([...args, '--via-cli']); + }, +); + +y.command('status', 'Checks if chrome-devtools-mcp is running', async () => { + if (isDaemonRunning()) { + console.log('chrome-devtools-mcp daemon is running.'); + } else { + console.log('chrome-devtools-mcp daemon is not running'); + } +}); + +y.command('stop', 'Stop chrome-devtools-mcp if any', async () => { + await stopDaemon(); +}); + +for (const [commandName, commandDef] of Object.entries(commands)) { + const args = commandDef.args; + const requiredArgNames = Object.keys(args).filter( + name => args[name].required, + ); + + let commandStr = commandName; + for (const arg of requiredArgNames) { + commandStr += ` <${arg}>`; + } + + y.command( + commandStr, + commandDef.description, + y => { + for (const [argName, opt] of Object.entries(args)) { + const type = + opt.type === 'integer' || opt.type === 'number' + ? 'number' + : opt.type === 'boolean' + ? 'boolean' + : opt.type === 'array' + ? 'array' + : 'string'; + + if (opt.required) { + const options: PositionalOptions = { + describe: opt.description, + type: type as PositionalOptions['type'], + }; + if (opt.default !== undefined) { + options.default = opt.default; + } + if (opt.enum) { + options.choices = opt.enum as Array; + } + y.positional(argName, options); + } else { + const options: Options = { + describe: opt.description, + type: type as Options['type'], + }; + if (opt.default !== undefined) { + options.default = opt.default; + } + if (opt.enum) { + options.choices = opt.enum as Array; + } + y.option(argName, options); + } + } + }, + async argv => { + try { + if (!isDaemonRunning()) { + await startDaemon(['--via-cli']); + } + + const commandArgs: Record = {}; + for (const argName of Object.keys(args)) { + if (argName in argv) { + commandArgs[argName] = argv[argName]; + } + } + + const response = await sendCommand({ + method: 'invoke_tool', + tool: commandName, + args: commandArgs, + }); + + if (response.success) { + console.log(response.result); + } else { + console.error('Error:', response.error); + process.exit(1); + } + } catch (error) { + console.error('Failed to execute command:', error); + process.exit(1); + } + }, + ); +} + +await y.parse(); diff --git a/src/bin/cliDefinitions.ts b/src/bin/cliDefinitions.ts new file mode 100644 index 000000000..82783bbde --- /dev/null +++ b/src/bin/cliDefinitions.ts @@ -0,0 +1,741 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// NOTE: do not edit manually. Auto-generated by 'npm run cli:generate'. + +export interface ArgDef { + name: string; + type: string; + description: string; + required: boolean; + default?: string | number | boolean; + enum?: ReadonlyArray; +} +export type Commands = Record< + string, + { + description: string; + category: string; + args: Record; + } +>; +export const commands: Commands = { + click: { + description: 'Clicks on the provided element', + category: 'Input automation', + args: { + uid: { + name: 'uid', + type: 'string', + description: + 'The uid of an element on the page from the page content snapshot', + required: true, + }, + dblClick: { + name: 'dblClick', + type: 'boolean', + description: 'Set to true for double clicks. Default is false.', + required: false, + }, + includeSnapshot: { + name: 'includeSnapshot', + type: 'boolean', + description: + 'Whether to include a snapshot in the response. Default is false.', + required: false, + }, + }, + }, + close_page: { + description: + 'Closes the page by its index. The last open page cannot be closed.', + category: 'Navigation automation', + args: { + pageId: { + name: 'pageId', + type: 'number', + description: + 'The ID of the page to close. Call list_pages to list pages.', + required: true, + }, + }, + }, + drag: { + description: 'Drag an element onto another element', + category: 'Input automation', + args: { + from_uid: { + name: 'from_uid', + type: 'string', + description: 'The uid of the element to drag', + required: true, + }, + to_uid: { + name: 'to_uid', + type: 'string', + description: 'The uid of the element to drop into', + required: true, + }, + includeSnapshot: { + name: 'includeSnapshot', + type: 'boolean', + description: + 'Whether to include a snapshot in the response. Default is false.', + required: false, + }, + }, + }, + emulate: { + description: 'Emulates various features on the selected page.', + category: 'Emulation', + args: { + networkConditions: { + name: 'networkConditions', + type: 'string', + description: 'Throttle network. Omit to disable throttling.', + required: false, + enum: ['Offline', 'Slow 3G', 'Fast 3G', 'Slow 4G', 'Fast 4G'], + }, + cpuThrottlingRate: { + name: 'cpuThrottlingRate', + type: 'number', + description: + 'Represents the CPU slowdown factor. Omit or set the rate to 1 to disable throttling', + required: false, + }, + geolocation: { + name: 'geolocation', + type: 'string', + description: + 'Geolocation (`x`) to emulate. Latitude between -90 and 90. Longitude between -180 and 180. Omit clear the geolocation override.', + required: false, + }, + userAgent: { + name: 'userAgent', + type: 'string', + description: + 'User agent to emulate. Set to empty string to clear the user agent override.', + required: false, + }, + colorScheme: { + name: 'colorScheme', + type: 'string', + description: + 'Emulate the dark or the light mode. Set to "auto" to reset to the default.', + required: false, + enum: ['dark', 'light', 'auto'], + }, + viewport: { + name: 'viewport', + type: 'string', + description: + "Emulate device viewports 'xx[,mobile][,touch][,landscape]'. 'touch' and 'mobile' to emulate mobile devices. 'landscape' to emulate landscape mode.", + required: false, + }, + }, + }, + evaluate_script: { + description: + 'Evaluate a JavaScript function inside the currently selected page. Returns the response as JSON,\nso returned values have to be JSON-serializable.', + category: 'Debugging', + args: { + function: { + name: 'function', + type: 'string', + description: + 'A JavaScript function declaration to be executed by the tool in the currently selected page.\nExample without arguments: `() => {\n return document.title\n}` or `async () => {\n return await fetch("example.com")\n}`.\nExample with arguments: `(el) => {\n return el.innerText;\n}`\n', + required: true, + }, + args: { + name: 'args', + type: 'array', + description: 'An optional list of arguments to pass to the function.', + required: false, + }, + }, + }, + fill: { + description: + 'Type text into a input, text area or select an option from a