Skip to content

Commit 82f9e65

Browse files
authored
chore: handle CLI responses and formats (#1080)
Initial handling for CLI responses. Note that image responses are not supported yet.
1 parent bcb852d commit 82f9e65

4 files changed

Lines changed: 141 additions & 30 deletions

File tree

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
@@ -9,6 +9,7 @@ import fs from 'node:fs';
99
import net from 'node:net';
1010

1111
import {logger} from '../logger.js';
12+
import type {CallToolResult} from '../third_party/index.js';
1213
import {PipeTransport} from '../third_party/index.js';
1314

1415
import type {DaemonMessage, DaemonResponse} from './types.js';
@@ -124,3 +125,27 @@ export async function stopDaemon() {
124125

125126
await sendCommand({method: 'stop'});
126127
}
128+
129+
export function handleResponse(
130+
response: CallToolResult,
131+
format: 'json' | 'text',
132+
): string {
133+
if (response.isError) {
134+
return JSON.stringify(response.content);
135+
}
136+
if (format === 'json') {
137+
if (response.structuredContent) {
138+
return JSON.stringify(response.structuredContent);
139+
}
140+
// Fall-through to text for backward compatibility.
141+
}
142+
const chunks = [];
143+
for (const content of response.content) {
144+
if (content.type === 'text') {
145+
chunks.push(content.text);
146+
} else {
147+
throw new Error('Not supported response content type');
148+
}
149+
}
150+
return format === 'text' ? chunks.join('') : JSON.stringify(chunks);
151+
}

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)