Skip to content

Commit a21fa91

Browse files
committed
feat: cli
1 parent 5e5b746 commit a21fa91

7 files changed

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

0 commit comments

Comments
 (0)