Skip to content

Commit bad5a16

Browse files
authored
chore: handle images in the CLI (#1107)
- save base64 data as an image to a tmp file. - change `--format` to `--output-format` to avoid conflicts.
1 parent bdbbc84 commit bad5a16

6 files changed

Lines changed: 82 additions & 34 deletions

File tree

src/McpContext.ts

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
*/
66

77
import fs from 'node:fs/promises';
8-
import os from 'node:os';
98
import path from 'node:path';
109

1110
import type {TargetUniverse} from './DevtoolsUtils.js';
@@ -47,6 +46,7 @@ import {
4746
ExtensionRegistry,
4847
type InstalledExtension,
4948
} from './utils/ExtensionRegistry.js';
49+
import {saveTemporaryFile} from './utils/files.js';
5050
import {WaitForHelper} from './WaitForHelper.js';
5151

5252
export type {
@@ -799,18 +799,7 @@ export class McpContext implements Context {
799799
data: Uint8Array<ArrayBufferLike>,
800800
filename: string,
801801
): Promise<{filepath: string}> {
802-
try {
803-
const dir = await fs.mkdtemp(
804-
path.join(os.tmpdir(), 'chrome-devtools-mcp-'),
805-
);
806-
807-
const filepath = path.join(dir, filename);
808-
await fs.writeFile(filepath, data);
809-
return {filepath};
810-
} catch (err) {
811-
this.logger(err);
812-
throw new Error('Could not save a file', {cause: err});
813-
}
802+
return await saveTemporaryFile(data, filename);
814803
}
815804
async saveFile(
816805
data: Uint8Array<ArrayBufferLike>,

src/bin/chrome-devtools.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ for (const [commandName, commandDef] of Object.entries(commands)) {
109109
commandStr,
110110
commandDef.description,
111111
y => {
112-
y.option('format', {
112+
y.option('output-format', {
113113
choices: ['md', 'json'],
114114
default: 'md',
115115
});
@@ -171,9 +171,9 @@ for (const [commandName, commandDef] of Object.entries(commands)) {
171171

172172
if (response.success) {
173173
console.log(
174-
handleResponse(
174+
await handleResponse(
175175
JSON.parse(response.result) as unknown as CallToolResult,
176-
argv['format'] as 'json' | 'md',
176+
argv['output-format'] as 'json' | 'md',
177177
),
178178
);
179179
} else {

src/daemon/client.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import net from 'node:net';
1111
import {logger} from '../logger.js';
1212
import type {CallToolResult} from '../third_party/index.js';
1313
import {PipeTransport} from '../third_party/index.js';
14+
import {saveTemporaryFile} from '../utils/files.js';
1415

1516
import type {DaemonMessage, DaemonResponse} from './types.js';
1617
import {
@@ -144,10 +145,10 @@ export async function stopDaemon() {
144145
await waitForFile(pidFilePath, /*removed=*/ true);
145146
}
146147

147-
export function handleResponse(
148+
export async function handleResponse(
148149
response: CallToolResult,
149150
format: 'json' | 'md',
150-
): string {
151+
): Promise<string> {
151152
if (response.isError) {
152153
return JSON.stringify(response.content);
153154
}
@@ -161,9 +162,26 @@ export function handleResponse(
161162
for (const content of response.content) {
162163
if (content.type === 'text') {
163164
chunks.push(content.text);
165+
} else if (content.type === 'image') {
166+
const imageData = content.data;
167+
const mimeType = content.mimeType;
168+
let extension = '.png';
169+
switch (mimeType) {
170+
case 'image/jpg':
171+
case 'image/jpeg':
172+
extension = '.jpeg';
173+
break;
174+
case 'webp':
175+
extension = '.webp';
176+
break;
177+
}
178+
const data = Buffer.from(imageData, 'base64');
179+
const name = crypto.randomUUID();
180+
const {filepath} = await saveTemporaryFile(data, `${name}${extension}`);
181+
chunks.push(`Saved to ${filepath}.`);
164182
} else {
165183
throw new Error('Not supported response content type');
166184
}
167185
}
168-
return format === 'md' ? chunks.join('') : JSON.stringify(chunks);
186+
return format === 'md' ? chunks.join(' ') : JSON.stringify(chunks);
169187
}

src/utils/files.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import fs from 'node:fs/promises';
8+
import os from 'node:os';
9+
import path from 'node:path';
10+
11+
export async function saveTemporaryFile(
12+
data: Uint8Array<ArrayBufferLike>,
13+
filename: string,
14+
): Promise<{filepath: string}> {
15+
try {
16+
const dir = await fs.mkdtemp(
17+
path.join(os.tmpdir(), 'chrome-devtools-mcp-'),
18+
);
19+
20+
const filepath = path.join(dir, filename);
21+
await fs.writeFile(filepath, data);
22+
return {filepath};
23+
} catch (err) {
24+
throw new Error('Could not save a file', {cause: err});
25+
}
26+
}

tests/daemon/client.test.ts

Lines changed: 10 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ describe('daemon client', () => {
5555
describe('parsing', () => {
5656
it('handles MCP response with text format', async () => {
5757
const textResponse = {content: [{type: 'text' as const, text: 'test'}]};
58-
assert.strictEqual(handleResponse(textResponse, 'md'), 'test');
58+
assert.strictEqual(await handleResponse(textResponse, 'md'), 'test');
5959
});
6060

6161
it('handles JSON response', async () => {
@@ -67,7 +67,7 @@ describe('daemon client', () => {
6767
},
6868
};
6969
assert.strictEqual(
70-
handleResponse(jsonResponse, 'json'),
70+
await handleResponse(jsonResponse, 'json'),
7171
JSON.stringify(jsonResponse.structuredContent),
7272
);
7373
});
@@ -78,7 +78,7 @@ describe('daemon client', () => {
7878
content: [{type: 'text' as const, text: 'Something went wrong'}],
7979
};
8080
assert.strictEqual(
81-
handleResponse(errorResponse, 'md'),
81+
await handleResponse(errorResponse, 'md'),
8282
JSON.stringify(errorResponse.content),
8383
);
8484
});
@@ -88,29 +88,24 @@ describe('daemon client', () => {
8888
content: [{type: 'text' as const, text: 'Fall through text'}],
8989
};
9090
assert.deepStrictEqual(
91-
handleResponse(textResponse, 'json'),
91+
await handleResponse(textResponse, 'json'),
9292
JSON.stringify(['Fall through text']),
9393
);
9494
});
9595

96-
it('throws error for unsupported content type', async () => {
96+
it('supports images', async () => {
9797
const unsupportedContentResponse = {
9898
content: [
9999
{
100-
type: 'resource' as const,
101-
resource: {
102-
uri: 'data:image/png;base64,base64data',
103-
blob: 'base64data',
104-
mimeType: 'image/png',
105-
},
100+
type: 'image' as const,
101+
data: 'base64data',
102+
mimeType: 'image/png',
106103
},
107104
],
108105
structuredContent: {},
109106
};
110-
assert.throws(
111-
() => handleResponse(unsupportedContentResponse, 'md'),
112-
new Error('Not supported response content type'),
113-
);
107+
const response = await handleResponse(unsupportedContentResponse, 'md');
108+
assert.ok(response.includes('.png'));
114109
});
115110
});
116111
});

tests/e2e/chrome-devtools.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,26 @@ describe('chrome-devtools', () => {
108108
);
109109
});
110110

111+
it('can take screenshot', async () => {
112+
const startResult = spawnSync('node', [CLI_PATH, 'start', ...START_ARGS]);
113+
assert.strictEqual(
114+
startResult.status,
115+
0,
116+
`start command failed: ${startResult.stderr.toString()}`,
117+
);
118+
119+
const result = spawnSync('node', [CLI_PATH, 'take_screenshot']);
120+
assert.strictEqual(
121+
result.status,
122+
0,
123+
`take_screenshot command failed: ${result.stderr.toString()}`,
124+
);
125+
assert(
126+
result.stdout.toString().includes('.png'),
127+
'take_screenshot output is unexpected',
128+
);
129+
});
130+
111131
it('forwards disclaimers to stderr on start', () => {
112132
const result = spawnSync('node', [CLI_PATH, 'start', ...START_ARGS]);
113133
assert.strictEqual(

0 commit comments

Comments
 (0)