diff --git a/package.json b/package.json index 6f65c1638..d5ec8ffe0 100644 --- a/package.json +++ b/package.json @@ -3,17 +3,21 @@ "version": "0.17.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": "index.js", "scripts": { "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})\"", "build": "tsc && node --experimental-strip-types --no-warnings=ExperimentalWarning scripts/post-build.ts", "typecheck": "tsc --noEmit", - "format": "eslint --cache --fix . && prettier --write --cache .", + "format": "prettier --write --cache . && eslint --cache --fix .", "check-format": "eslint --cache . && prettier --check --cache .;", "docs": "npm run build && npm run docs:generate && npm run format", "docs:generate": "node --experimental-strip-types scripts/generate-docs.ts", + "cli:generate": "node --experimental-strip-types scripts/generate-cli.ts && npm run format && npm run build", "start": "npm run build && node build/src/index.js", "start-debug": "DEBUG=mcp:* DEBUG_COLORS=false npm run build && node build/src/index.js", "test": "npm run build && node scripts/test.mjs", diff --git a/scripts/generate-cli.ts b/scripts/generate-cli.ts new file mode 100644 index 000000000..29e300bf0 --- /dev/null +++ b/scripts/generate-cli.ts @@ -0,0 +1,153 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; + +import {Client} from '@modelcontextprotocol/sdk/client/index.js'; +import {StdioClientTransport} from '@modelcontextprotocol/sdk/client/stdio.js'; + +const OUTPUT_PATH = path.join( + path.dirname(fileURLToPath(import.meta.url)), + '../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( + path.dirname(fileURLToPath(import.meta.url)), + '../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); + let type = prop.type || 'string'; + if (Array.isArray(type)) { + type = type[0]; + } + const description = prop.description || ''; + + return { + name, + type: type as string, + 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 commands: Record< + string, + {description: 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; + } + commands[tool.name] = { + description: tool.description || '', + 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; + 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..89459b79d --- /dev/null +++ b/src/bin/chrome-devtools.ts @@ -0,0 +1,171 @@ +#!/usr/bin/env node + +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {createConnection} from 'node:net'; +import process from 'node:process'; + +import yargs, {type Options, type PositionalOptions} from 'yargs'; +import {hideBin} from 'yargs/helpers'; + +import {commands} from './cliDefinitions.js'; +import { + getSocketPath, + isDaemonRunning, + startDaemon, + stopDaemon, +} from './daemonClient.js'; + +async function sendToDaemon( + request: unknown, +): Promise<{success: boolean; result: unknown; error: unknown}> { + const socketPath = await getSocketPath(); + return new Promise((resolve, reject) => { + const client = createConnection({path: socketPath}, () => { + client.write(JSON.stringify(request) + '\0'); + }); + + let buffer = ''; + client.on('data', data => { + buffer += data.toString(); + if (buffer.endsWith('\0')) { + try { + const response = JSON.parse(buffer.slice(0, -1)); + client.end(); + resolve(response); + } catch (e) { + reject(e); + } + } + }); + + client.on('error', err => { + reject(err); + }); + }); +} + +const y = yargs(hideBin(process.argv)) + .scriptName('chrome-devtools') + .help() + .showHelpOnFail(true) + .demandCommand() + .strict(); + +y.command( + 'start', + 'Starts or restarts the daemon process', + y => y.help(false), // Disable help for start command to avoid parsing issues with passed args + async () => { + if (await 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); + }, +); + +y.command('status', 'Checks if the MCP server process is running', async () => { + if (await isDaemonRunning()) { + console.log('Daemon is running'); + } else { + console.log('Daemon is not running'); + } +}); + +y.command('stop', 'Stop the running MCP server 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 (!(await 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 sendToDaemon({ + 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..7061ce3f1 --- /dev/null +++ b/src/bin/cliDefinitions.ts @@ -0,0 +1,649 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +export interface ArgDef { + name: string; + type: string; + description: string; + required: boolean; + default?: string | number | boolean; + enum?: ReadonlyArray; +} +export type Commands = Record< + string, + { + description: string; + args: Record; + } +>; +export const commands: Commands = { + click: { + description: 'Clicks on the provided element', + 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.', + 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', + 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.', + args: { + networkConditions: { + name: 'networkConditions', + type: 'string', + description: + 'Throttle network. Set to "No emulation" to disable. If omitted, conditions remain unchanged.', + required: false, + enum: [ + 'No emulation', + 'Offline', + 'Slow 3G', + 'Fast 3G', + 'Slow 4G', + 'Fast 4G', + ], + }, + cpuThrottlingRate: { + name: 'cpuThrottlingRate', + type: 'number', + description: + 'Represents the CPU slowdown factor. Set the rate to 1 to disable throttling. If omitted, throttling remains unchanged.', + required: false, + }, + geolocation: { + name: 'geolocation', + type: 'string', + description: + 'Geolocation to emulate. Set to null to clear the geolocation override.', + required: false, + }, + userAgent: { + name: 'userAgent', + type: 'string', + description: + 'User agent to emulate. Set to null 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: + 'Viewport to emulate. Set to null to reset to the default viewport.', + 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.', + 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