Skip to content

Commit ac80204

Browse files
authored
Merge branch 'main' into orkon/forward-disclaimers
2 parents ad9a3c8 + 8e74254 commit ac80204

5 files changed

Lines changed: 234 additions & 30 deletions

File tree

docs/cli.md

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
# Chrome DevTools CLI
2+
3+
The `chrome-devtools-mcp` package includes a CLI interface that allows you to interact with the browser directly from your terminal. This is particularly useful for debugging or when you want an agent to generate scripts that automate browser actions.
4+
5+
## Getting started
6+
7+
Install the package globally to make the `chrome-devtools` command available:
8+
9+
```sh
10+
npm i chrome-devtools-mcp@latest -g
11+
chrome-devtools status # check if install worked.
12+
```
13+
14+
## How it works
15+
16+
The CLI acts as a client to a background `chrome-devtools-mcp` daemon.
17+
18+
- **Automatic Start**: The first time you call a tool (e.g., `list_pages`), the CLI automatically starts the MCP server and the browser in the background if they aren't already running.
19+
- **Persistence**: The same background instance is reused for subsequent commands, preserving the browser state (open pages, cookies, etc.).
20+
- **Manual Control**: You can explicitly manage the background process using `start`, `stop`, and `status`. The `start` command forwards all subsequent arguments to the underlying MCP server (e.g., `--headless`, `--userDataDir`).
21+
22+
```sh
23+
# Check if the daemon is running
24+
chrome-devtools status
25+
26+
# Navigate the current page to a URL
27+
chrome-devtools navigate_page "https://google.com"
28+
29+
# Take a screenshot and save it to a file
30+
chrome-devtools take_screenshot --filePath screenshot.png
31+
32+
# Stop the background daemon when finished
33+
chrome-devtools stop
34+
```
35+
36+
## Command Usage
37+
38+
The CLI supports all tools available in the [Tool reference](./tool-reference.md).
39+
40+
```sh
41+
chrome-devtools <tool> [arguments] [flags]
42+
```
43+
44+
- **Required Arguments**: Passed as positional arguments.
45+
- **Optional Arguments**: Passed as flags (e.g., `--filePath`, `--fullPage`).
46+
47+
### Examples
48+
49+
**New Page and Navigation:**
50+
51+
```sh
52+
chrome-devtools new_page "https://example.com"
53+
chrome-devtools navigate_page "https://web.dev" --type url
54+
```
55+
56+
**Interaction:**
57+
58+
```sh
59+
# Click an element by its UID from a snapshot
60+
chrome-devtools click "element-uid-123"
61+
62+
# Fill a form field
63+
chrome-devtools fill "input-uid-456" "search query"
64+
```
65+
66+
**Analysis:**
67+
68+
```sh
69+
# Run a Lighthouse audit (defaults to navigation mode)
70+
chrome-devtools lighthouse_audit --mode snapshot
71+
```
72+
73+
## Output format
74+
75+
By default, the CLI outputs a human-readable summary of the tool's result. For programmatic use, you can request raw JSON:
76+
77+
```sh
78+
chrome-devtools list_pages --format=json
79+
```
80+
81+
## Troubleshooting
82+
83+
If the CLI hangs or fails to connect, try stopping the background process:
84+
85+
```sh
86+
chrome-devtools stop
87+
```
88+
89+
For more verbose logs, set the `DEBUG` environment variable:
90+
91+
```sh
92+
DEBUG=* chrome-devtools list_pages
93+
```

src/bin/chrome-devtools.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,14 @@ import process from 'node:process';
1111
import yargs, {type Options, type PositionalOptions} from 'yargs';
1212
import {hideBin} from 'yargs/helpers';
1313

14-
import {startDaemon, stopDaemon, sendCommand} from '../daemon/client.js';
14+
import {
15+
startDaemon,
16+
stopDaemon,
17+
sendCommand,
18+
handleResponse,
19+
} from '../daemon/client.js';
1520
import {isDaemonRunning} from '../daemon/utils.js';
21+
import type {CallToolResult} from '../third_party/index.js';
1622
import {VERSION} from '../version.js';
1723

1824
import {commands} from './cliDefinitions.js';
@@ -25,6 +31,8 @@ if (argv.length === 0 || argv[0] === '--custom-help') {
2531
process.exit(0);
2632
}
2733

34+
const defaultArgs = ['--viaCli', '--experimentalStructuredContent'];
35+
2836
const y = yargs(argv)
2937
.scriptName('chrome-devtools')
3038
.showHelpOnFail(true)
@@ -44,7 +52,7 @@ y.command(
4452
// Extract args after 'start'
4553
const startIndex = process.argv.indexOf('start');
4654
const args = startIndex !== -1 ? process.argv.slice(startIndex + 1) : [];
47-
await startDaemon([...args, '--via-cli']);
55+
await startDaemon([...args, ...defaultArgs]);
4856
},
4957
);
5058

@@ -75,6 +83,10 @@ for (const [commandName, commandDef] of Object.entries(commands)) {
7583
commandStr,
7684
commandDef.description,
7785
y => {
86+
y.option('format', {
87+
choices: ['text', 'json'],
88+
default: 'text',
89+
});
7890
for (const [argName, opt] of Object.entries(args)) {
7991
const type =
8092
opt.type === 'integer' || opt.type === 'number'
@@ -115,7 +127,7 @@ for (const [commandName, commandDef] of Object.entries(commands)) {
115127
async argv => {
116128
try {
117129
if (!isDaemonRunning()) {
118-
await startDaemon(['--via-cli']);
130+
await startDaemon(defaultArgs);
119131
}
120132

121133
const commandArgs: Record<string, unknown> = {};
@@ -132,7 +144,12 @@ for (const [commandName, commandDef] of Object.entries(commands)) {
132144
});
133145

134146
if (response.success) {
135-
console.log(response.result);
147+
console.log(
148+
handleResponse(
149+
JSON.parse(response.result) as unknown as CallToolResult,
150+
argv['format'] as 'json' | 'text',
151+
),
152+
);
136153
} else {
137154
console.error('Error:', response.error);
138155
process.exit(1);

src/daemon/client.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import net from 'node:net';
1010

1111
import {logger} from '../logger.js';
1212
import {START_INDICATOR} from '../server.js';
13+
import type {CallToolResult} from '../third_party/index.js';
1314
import {PipeTransport} from '../third_party/index.js';
1415

1516
import type {DaemonMessage, DaemonResponse} from './types.js';
@@ -136,3 +137,27 @@ export async function stopDaemon() {
136137

137138
await sendCommand({method: 'stop'});
138139
}
140+
141+
export function handleResponse(
142+
response: CallToolResult,
143+
format: 'json' | 'text',
144+
): string {
145+
if (response.isError) {
146+
return JSON.stringify(response.content);
147+
}
148+
if (format === 'json') {
149+
if (response.structuredContent) {
150+
return JSON.stringify(response.structuredContent);
151+
}
152+
// Fall-through to text for backward compatibility.
153+
}
154+
const chunks = [];
155+
for (const content of response.content) {
156+
if (content.type === 'text') {
157+
chunks.push(content.text);
158+
} else {
159+
throw new Error('Not supported response content type');
160+
}
161+
}
162+
return format === 'text' ? chunks.join('') : JSON.stringify(chunks);
163+
}

src/daemon/types.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export type DaemonMessage =
1616

1717
export interface DaemonResponse {
1818
success: boolean;
19-
result: unknown;
19+
// Stringified CallToolResult.
20+
result: string;
2021
error: unknown;
2122
}

tests/daemon/client.test.ts

Lines changed: 93 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -7,43 +7,111 @@
77
import assert from 'node:assert';
88
import {describe, it, afterEach} from 'node:test';
99

10-
import {startDaemon, stopDaemon} from '../../src/daemon/client.js';
10+
import {
11+
handleResponse,
12+
startDaemon,
13+
stopDaemon,
14+
} from '../../src/daemon/client.js';
1115
import {isDaemonRunning} from '../../src/daemon/utils.js';
1216

1317
describe('daemon client', () => {
14-
afterEach(async () => {
15-
if (isDaemonRunning()) {
18+
describe('start/stop', () => {
19+
afterEach(async () => {
20+
if (isDaemonRunning()) {
21+
await stopDaemon();
22+
// Wait a bit for the daemon to fully terminate and clean up its files.
23+
await new Promise(resolve => setTimeout(resolve, 1000));
24+
}
25+
});
26+
27+
it('should start and stop daemon', async () => {
28+
assert.ok(!isDaemonRunning(), 'Daemon should not be running initially');
29+
30+
await startDaemon();
31+
assert.ok(isDaemonRunning(), 'Daemon should be running after start');
32+
1633
await stopDaemon();
17-
// Wait a bit for the daemon to fully terminate and clean up its files.
1834
await new Promise(resolve => setTimeout(resolve, 1000));
19-
}
20-
});
35+
assert.ok(!isDaemonRunning(), 'Daemon should not be running after stop');
36+
});
2137

22-
it('should start and stop daemon', async () => {
23-
assert.ok(!isDaemonRunning(), 'Daemon should not be running initially');
38+
it('should handle starting daemon when already running', async () => {
39+
await startDaemon();
40+
assert.ok(isDaemonRunning(), 'Daemon should be running');
2441

25-
await startDaemon();
26-
assert.ok(isDaemonRunning(), 'Daemon should be running after start');
42+
// Starting again should be a no-op
43+
await startDaemon();
44+
assert.ok(isDaemonRunning(), 'Daemon should still be running');
45+
});
2746

28-
await stopDaemon();
29-
await new Promise(resolve => setTimeout(resolve, 1000));
30-
assert.ok(!isDaemonRunning(), 'Daemon should not be running after stop');
47+
it('should handle stopping daemon when not running', async () => {
48+
assert.ok(!isDaemonRunning(), 'Daemon should not be running initially');
49+
50+
// Stopping when not running should be a no-op
51+
await stopDaemon();
52+
assert.ok(!isDaemonRunning(), 'Daemon should still not be running');
53+
});
3154
});
3255

33-
it('should handle starting daemon when already running', async () => {
34-
await startDaemon();
35-
assert.ok(isDaemonRunning(), 'Daemon should be running');
56+
describe('parsing', () => {
57+
it('handles MCP response with text format', () => {
58+
const textResponse = {content: [{type: 'text' as const, text: 'test'}]};
59+
assert.strictEqual(handleResponse(textResponse, 'text'), 'test');
60+
});
3661

37-
// Starting again should be a no-op
38-
await startDaemon();
39-
assert.ok(isDaemonRunning(), 'Daemon should still be running');
40-
});
62+
it('handles JSON response', () => {
63+
const jsonResponse = {
64+
content: [],
65+
structuredContent: {
66+
test: 'data',
67+
number: 123,
68+
},
69+
};
70+
assert.strictEqual(
71+
handleResponse(jsonResponse, 'json'),
72+
JSON.stringify(jsonResponse.structuredContent),
73+
);
74+
});
75+
76+
it('handles error response when isError is true', () => {
77+
const errorResponse = {
78+
isError: true,
79+
content: [{type: 'text' as const, text: 'Something went wrong'}],
80+
};
81+
assert.strictEqual(
82+
handleResponse(errorResponse, 'text'),
83+
JSON.stringify(errorResponse.content),
84+
);
85+
});
4186

42-
it('should handle stopping daemon when not running', async () => {
43-
assert.ok(!isDaemonRunning(), 'Daemon should not be running initially');
87+
it('handles text response when json format is requested but no structured content', () => {
88+
const textResponse = {
89+
content: [{type: 'text' as const, text: 'Fall through text'}],
90+
};
91+
assert.deepStrictEqual(
92+
handleResponse(textResponse, 'json'),
93+
JSON.stringify(['Fall through text']),
94+
);
95+
});
4496

45-
// Stopping when not running should be a no-op
46-
await stopDaemon();
47-
assert.ok(!isDaemonRunning(), 'Daemon should still not be running');
97+
it('throws error for unsupported content type', () => {
98+
const unsupportedContentResponse = {
99+
content: [
100+
{
101+
type: 'resource' as const,
102+
resource: {
103+
uri: 'data:image/png;base64,base64data',
104+
blob: 'base64data',
105+
mimeType: 'image/png',
106+
},
107+
},
108+
],
109+
structuredContent: {},
110+
};
111+
assert.throws(
112+
() => handleResponse(unsupportedContentResponse, 'text'),
113+
new Error('Not supported response content type'),
114+
);
115+
});
48116
});
49117
});

0 commit comments

Comments
 (0)