Skip to content

Commit fae942c

Browse files
committed
chore: cli
1 parent 505089c commit fae942c

7 files changed

Lines changed: 1187 additions & 3 deletions

File tree

package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,13 @@
33
"version": "0.18.1",
44
"description": "MCP server for Chrome DevTools",
55
"type": "module",
6-
"bin": "./build/src/index.js",
6+
"bin": {
7+
"chrome-devtools-mcp": "./build/src/index.js",
8+
"chrome-devtools": "./build/src/bin/chrome-devtools.js"
9+
},
710
"main": "./build/src/server.js",
811
"scripts": {
12+
"cli:generate": "node --experimental-strip-types scripts/generate-cli.ts && npm run format",
913
"clean": "node -e \"require('fs').rmSync('build', {recursive: true, force: true})\"",
1014
"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",
1115
"build": "tsc && node --experimental-strip-types --no-warnings=ExperimentalWarning scripts/post-build.ts",

scripts/generate-cli.ts

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import fs from 'node:fs';
8+
import path from 'node:path';
9+
10+
import {Client} from '@modelcontextprotocol/sdk/client/index.js';
11+
import {StdioClientTransport} from '@modelcontextprotocol/sdk/client/stdio.js';
12+
13+
import {parseArguments} from '../build/src/cli.js';
14+
import {labels} from '../build/src/tools/categories.js';
15+
import {createTools} from '../build/src/tools/tools.js';
16+
17+
const OUTPUT_PATH = path.join(
18+
import.meta.dirname,
19+
'../src/bin/cliDefinitions.ts',
20+
);
21+
22+
async function fetchTools() {
23+
console.log('Connecting to chrome-devtools-mcp to fetch tools...');
24+
// Use the local build of the server
25+
const serverPath = path.join(import.meta.dirname, '../build/src/index.js');
26+
27+
const transport = new StdioClientTransport({
28+
command: 'node',
29+
args: [serverPath],
30+
env: {...process.env, CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS: 'true'},
31+
});
32+
33+
const client = new Client(
34+
{
35+
name: 'chrome-devtools-cli-generator',
36+
version: '0.1.0',
37+
},
38+
{
39+
capabilities: {},
40+
},
41+
);
42+
43+
await client.connect(transport);
44+
try {
45+
const toolsResponse = await client.listTools();
46+
const tools = toolsResponse.tools || [];
47+
console.log(`Fetched ${tools.length} tools`);
48+
return tools;
49+
} finally {
50+
await client.close();
51+
}
52+
}
53+
54+
interface CliOption {
55+
name: string;
56+
type: string;
57+
description: string;
58+
required: boolean;
59+
default?: unknown;
60+
enum?: unknown[];
61+
}
62+
63+
interface JsonSchema {
64+
type?: string | string[];
65+
description?: string;
66+
properties?: Record<string, JsonSchema>;
67+
required?: string[];
68+
default?: unknown;
69+
enum?: unknown[];
70+
}
71+
72+
function schemaToCLIOptions(schema: JsonSchema): CliOption[] {
73+
if (!schema || !schema.properties) {
74+
return [];
75+
}
76+
const required = schema.required || [];
77+
const properties = schema.properties;
78+
return Object.entries(properties).map(([name, prop]) => {
79+
const isRequired = required.includes(name);
80+
let type = prop.type || 'string';
81+
if (Array.isArray(type)) {
82+
type = type[0];
83+
}
84+
const description = prop.description || '';
85+
86+
return {
87+
name,
88+
type: type as string,
89+
description,
90+
required: isRequired,
91+
default: prop.default,
92+
enum: prop.enum,
93+
};
94+
});
95+
}
96+
97+
async function generateCli() {
98+
const tools = await fetchTools();
99+
// Sort tools by name
100+
const sortedTools = tools.sort((a, b) => a.name.localeCompare(b.name));
101+
102+
const staticTools = createTools(parseArguments());
103+
const toolNameToCategory = new Map<string, string>();
104+
for (const tool of staticTools) {
105+
toolNameToCategory.set(
106+
tool.name,
107+
labels[tool.annotations.category as keyof typeof labels],
108+
);
109+
}
110+
111+
const commands: Record<
112+
string,
113+
{description: string; category: string; args: Record<string, CliOption>}
114+
> = {};
115+
116+
for (const tool of sortedTools) {
117+
const options = schemaToCLIOptions(tool.inputSchema);
118+
const args: Record<string, CliOption> = {};
119+
for (const opt of options) {
120+
args[opt.name] = opt;
121+
}
122+
commands[tool.name] = {
123+
description: tool.description || '',
124+
category: toolNameToCategory.get(tool.name) ?? 'Uncategorized',
125+
args,
126+
};
127+
}
128+
129+
const lines: string[] = [];
130+
lines.push(`/**
131+
* @license
132+
* Copyright 2026 Google LLC
133+
* SPDX-License-Identifier: Apache-2.0
134+
*/
135+
136+
// NOTE: do not edit manually. Auto-generated by 'npm run cli:generate'.
137+
138+
export interface ArgDef {
139+
name: string;
140+
type: string;
141+
description: string;
142+
required: boolean;
143+
default?: string | number | boolean;
144+
enum?: ReadonlyArray<string | number>;
145+
}
146+
export type Commands = Record<
147+
string,
148+
{
149+
description: string;
150+
category: string;
151+
args: Record<string, ArgDef>
152+
}
153+
>;
154+
export const commands: Commands = ${JSON.stringify(commands, null, 2)} as const;
155+
`);
156+
157+
fs.writeFileSync(OUTPUT_PATH, lines.join(''));
158+
console.log(`Generated CLI at ${OUTPUT_PATH}`);
159+
}
160+
161+
generateCli().catch(err => {
162+
console.error('Error during generation:', err);
163+
process.exit(1);
164+
});

src/bin/chrome-devtools.ts

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
#!/usr/bin/env node
2+
3+
/**
4+
* @license
5+
* Copyright 2026 Google LLC
6+
* SPDX-License-Identifier: Apache-2.0
7+
*/
8+
9+
import process from 'node:process';
10+
11+
import yargs, {type Options, type PositionalOptions} from 'yargs';
12+
import {hideBin} from 'yargs/helpers';
13+
14+
import {startDaemon, stopDaemon, sendCommand} from '../daemon/client.js';
15+
import {isDaemonRunning} from '../daemon/utils.js';
16+
import {VERSION} from '../version.js';
17+
18+
import {commands} from './cliDefinitions.js';
19+
import {generateCustomHelp} from './customHelp.js';
20+
21+
const argv = hideBin(process.argv);
22+
23+
if (argv.length === 0 || argv[0] === '--custom-help') {
24+
console.log(generateCustomHelp(VERSION, commands));
25+
process.exit(0);
26+
}
27+
28+
const y = yargs(argv)
29+
.scriptName('chrome-devtools')
30+
.showHelpOnFail(true)
31+
.demandCommand()
32+
.version(VERSION)
33+
.strict()
34+
.help(true);
35+
36+
y.command(
37+
'start',
38+
'Start or restart chrome-devtools-mcp',
39+
y => y.help(false), // Disable help for start command to avoid parsing issues with passed args
40+
async () => {
41+
if (isDaemonRunning()) {
42+
await stopDaemon();
43+
}
44+
// Extract args after 'start'
45+
const startIndex = process.argv.indexOf('start');
46+
const args = startIndex !== -1 ? process.argv.slice(startIndex + 1) : [];
47+
await startDaemon([...args, '--via-cli']);
48+
},
49+
);
50+
51+
y.command('status', 'Checks if chrome-devtools-mcp is running', async () => {
52+
if (isDaemonRunning()) {
53+
console.log('chrome-devtools-mcp daemon is running.');
54+
} else {
55+
console.log('chrome-devtools-mcp daemon is not running');
56+
}
57+
});
58+
59+
y.command('stop', 'Stop chrome-devtools-mcp if any', async () => {
60+
await stopDaemon();
61+
});
62+
63+
for (const [commandName, commandDef] of Object.entries(commands)) {
64+
const args = commandDef.args;
65+
const requiredArgNames = Object.keys(args).filter(
66+
name => args[name].required,
67+
);
68+
69+
let commandStr = commandName;
70+
for (const arg of requiredArgNames) {
71+
commandStr += ` <${arg}>`;
72+
}
73+
74+
y.command(
75+
commandStr,
76+
commandDef.description,
77+
y => {
78+
for (const [argName, opt] of Object.entries(args)) {
79+
const type =
80+
opt.type === 'integer' || opt.type === 'number'
81+
? 'number'
82+
: opt.type === 'boolean'
83+
? 'boolean'
84+
: opt.type === 'array'
85+
? 'array'
86+
: 'string';
87+
88+
if (opt.required) {
89+
const options: PositionalOptions = {
90+
describe: opt.description,
91+
type: type as PositionalOptions['type'],
92+
};
93+
if (opt.default !== undefined) {
94+
options.default = opt.default;
95+
}
96+
if (opt.enum) {
97+
options.choices = opt.enum as Array<string | number>;
98+
}
99+
y.positional(argName, options);
100+
} else {
101+
const options: Options = {
102+
describe: opt.description,
103+
type: type as Options['type'],
104+
};
105+
if (opt.default !== undefined) {
106+
options.default = opt.default;
107+
}
108+
if (opt.enum) {
109+
options.choices = opt.enum as Array<string | number>;
110+
}
111+
y.option(argName, options);
112+
}
113+
}
114+
},
115+
async argv => {
116+
try {
117+
if (!isDaemonRunning()) {
118+
await startDaemon(['--via-cli']);
119+
}
120+
121+
const commandArgs: Record<string, unknown> = {};
122+
for (const argName of Object.keys(args)) {
123+
if (argName in argv) {
124+
commandArgs[argName] = argv[argName];
125+
}
126+
}
127+
128+
const response = await sendCommand({
129+
method: 'invoke_tool',
130+
tool: commandName,
131+
args: commandArgs,
132+
});
133+
134+
if (response.success) {
135+
console.log(response.result);
136+
} else {
137+
console.error('Error:', response.error);
138+
process.exit(1);
139+
}
140+
} catch (error) {
141+
console.error('Failed to execute command:', error);
142+
process.exit(1);
143+
}
144+
},
145+
);
146+
}
147+
148+
await y.parse();

0 commit comments

Comments
 (0)