Skip to content

Commit 3e5be36

Browse files
committed
feat: cli
1 parent 5e5b746 commit 3e5be36

9 files changed

Lines changed: 1677 additions & 2 deletions

File tree

package.json

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,21 @@
33
"version": "0.17.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": "index.js",
811
"scripts": {
912
"clean": "node -e \"require('fs').rmSync('build', {recursive: true, force: true})\"",
1013
"bundle": "npm run clean && npm run build && rollup -c rollup.config.mjs && node -e \"require('fs').rmSync('build/node_modules', {recursive: true, force: true})\"",
1114
"build": "tsc && node --experimental-strip-types --no-warnings=ExperimentalWarning scripts/post-build.ts",
1215
"typecheck": "tsc --noEmit",
13-
"format": "eslint --cache --fix . && prettier --write --cache .",
16+
"format": "prettier --write --cache . && eslint --cache --fix .",
1417
"check-format": "eslint --cache . && prettier --check --cache .;",
1518
"docs": "npm run build && npm run docs:generate && npm run format",
1619
"docs:generate": "node --experimental-strip-types scripts/generate-docs.ts",
20+
"cli:generate": "node --experimental-strip-types scripts/generate-cli.ts && npm run format && npm run build",
1721
"start": "npm run build && node build/src/index.js",
1822
"start-debug": "DEBUG=mcp:* DEBUG_COLORS=false npm run build && node build/src/index.js",
1923
"test": "npm run build && node scripts/test.mjs",

scripts/generate-cli.ts

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
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+
import {fileURLToPath} from 'node:url';
10+
11+
import {Client} from '@modelcontextprotocol/sdk/client/index.js';
12+
import {StdioClientTransport} from '@modelcontextprotocol/sdk/client/stdio.js';
13+
14+
const OUTPUT_PATH = path.join(
15+
path.dirname(fileURLToPath(import.meta.url)),
16+
'../src/bin/cliDefinitions.ts',
17+
);
18+
19+
async function fetchTools() {
20+
console.log('Connecting to chrome-devtools-mcp to fetch tools...');
21+
// Use the local build of the server
22+
const serverPath = path.join(
23+
path.dirname(fileURLToPath(import.meta.url)),
24+
'../build/src/index.js',
25+
);
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 commands: Record<
103+
string,
104+
{description: string; args: Record<string, CliOption>}
105+
> = {};
106+
107+
for (const tool of sortedTools) {
108+
const options = schemaToCLIOptions(tool.inputSchema);
109+
const args: Record<string, CliOption> = {};
110+
for (const opt of options) {
111+
args[opt.name] = opt;
112+
}
113+
commands[tool.name] = {
114+
description: tool.description || '',
115+
args,
116+
};
117+
}
118+
119+
const lines: string[] = [];
120+
lines.push(`/**
121+
* @license
122+
* Copyright 2026 Google LLC
123+
* SPDX-License-Identifier: Apache-2.0
124+
*/
125+
126+
// NOTE: do not edit manually. Auto-generated by 'npm run cli:generate'.
127+
128+
export interface ArgDef {
129+
name: string;
130+
type: string;
131+
description: string;
132+
required: boolean;
133+
default?: string | number | boolean;
134+
enum?: ReadonlyArray<string | number>;
135+
}
136+
export type Commands = Record<
137+
string,
138+
{
139+
description: string;
140+
args: Record<string, ArgDef>
141+
}
142+
>
143+
export const commands: Commands = ${JSON.stringify(commands, null, 2)} as const;
144+
`);
145+
146+
fs.writeFileSync(OUTPUT_PATH, lines.join(''));
147+
console.log(`Generated CLI at ${OUTPUT_PATH}`);
148+
}
149+
150+
generateCli().catch(err => {
151+
console.error('Error during generation:', err);
152+
process.exit(1);
153+
});

src/bin/chrome-devtools.ts

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
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 {createConnection} from 'node:net';
10+
import process from 'node:process';
11+
12+
import yargs, {type Options, type PositionalOptions} from 'yargs';
13+
import {hideBin} from 'yargs/helpers';
14+
15+
import {commands} from './cliDefinitions.js';
16+
import {
17+
getSocketPath,
18+
isDaemonRunning,
19+
startDaemon,
20+
stopDaemon,
21+
} from './daemonClient.js';
22+
23+
async function sendToDaemon(
24+
request: unknown,
25+
): Promise<{success: boolean; result: unknown; error: unknown}> {
26+
const socketPath = await getSocketPath();
27+
return new Promise((resolve, reject) => {
28+
const client = createConnection({path: socketPath}, () => {
29+
client.write(JSON.stringify(request) + '\0');
30+
});
31+
32+
let buffer = '';
33+
client.on('data', data => {
34+
buffer += data.toString();
35+
if (buffer.endsWith('\0')) {
36+
try {
37+
const response = JSON.parse(buffer.slice(0, -1));
38+
client.end();
39+
resolve(response);
40+
} catch (e) {
41+
reject(e);
42+
}
43+
}
44+
});
45+
46+
client.on('error', err => {
47+
reject(err);
48+
});
49+
});
50+
}
51+
52+
const y = yargs(hideBin(process.argv))
53+
.scriptName('chrome-devtools')
54+
.help()
55+
.showHelpOnFail(true)
56+
.demandCommand()
57+
.strict();
58+
59+
y.command(
60+
'start',
61+
'Starts or restarts the daemon process',
62+
y => y.help(false), // Disable help for start command to avoid parsing issues with passed args
63+
async () => {
64+
if (await isDaemonRunning()) {
65+
await stopDaemon();
66+
}
67+
// Extract args after 'start'
68+
const startIndex = process.argv.indexOf('start');
69+
const args = startIndex !== -1 ? process.argv.slice(startIndex + 1) : [];
70+
await startDaemon(args);
71+
},
72+
);
73+
74+
y.command('status', 'Checks if the MCP server process is running', async () => {
75+
if (await isDaemonRunning()) {
76+
console.log('Daemon is running');
77+
} else {
78+
console.log('Daemon is not running');
79+
}
80+
});
81+
82+
y.command('stop', 'Stop the running MCP server if any', async () => {
83+
await stopDaemon();
84+
});
85+
86+
for (const [commandName, commandDef] of Object.entries(commands)) {
87+
const args = commandDef.args;
88+
const requiredArgNames = Object.keys(args).filter(
89+
name => args[name].required,
90+
);
91+
92+
let commandStr = commandName;
93+
for (const arg of requiredArgNames) {
94+
commandStr += ` <${arg}>`;
95+
}
96+
97+
y.command(
98+
commandStr,
99+
commandDef.description,
100+
y => {
101+
for (const [argName, opt] of Object.entries(args)) {
102+
const type =
103+
opt.type === 'integer' || opt.type === 'number'
104+
? 'number'
105+
: opt.type === 'boolean'
106+
? 'boolean'
107+
: opt.type === 'array'
108+
? 'array'
109+
: 'string';
110+
111+
if (opt.required) {
112+
const options: PositionalOptions = {
113+
describe: opt.description,
114+
type: type as PositionalOptions['type'],
115+
};
116+
if (opt.default !== undefined) {
117+
options.default = opt.default;
118+
}
119+
if (opt.enum) {
120+
options.choices = opt.enum as Array<string | number>;
121+
}
122+
y.positional(argName, options);
123+
} else {
124+
const options: Options = {
125+
describe: opt.description,
126+
type: type as Options['type'],
127+
};
128+
if (opt.default !== undefined) {
129+
options.default = opt.default;
130+
}
131+
if (opt.enum) {
132+
options.choices = opt.enum as Array<string | number>;
133+
}
134+
y.option(argName, options);
135+
}
136+
}
137+
},
138+
async argv => {
139+
try {
140+
if (!(await isDaemonRunning())) {
141+
await startDaemon(['--via-cli']);
142+
}
143+
144+
const commandArgs: Record<string, unknown> = {};
145+
for (const argName of Object.keys(args)) {
146+
if (argName in argv) {
147+
commandArgs[argName] = argv[argName];
148+
}
149+
}
150+
151+
const response = await sendToDaemon({
152+
method: 'invoke_tool',
153+
tool: commandName,
154+
args: commandArgs,
155+
});
156+
157+
if (response.success) {
158+
console.log(response.result);
159+
} else {
160+
console.error('Error:', response.error);
161+
process.exit(1);
162+
}
163+
} catch (error) {
164+
console.error('Failed to execute command:', error);
165+
process.exit(1);
166+
}
167+
},
168+
);
169+
}
170+
171+
await y.parse();

0 commit comments

Comments
 (0)