diff --git a/src/McpContext.ts b/src/McpContext.ts index 1e702dfd7..ff0aa1bfe 100644 --- a/src/McpContext.ts +++ b/src/McpContext.ts @@ -5,7 +5,6 @@ */ import fs from 'node:fs/promises'; -import os from 'node:os'; import path from 'node:path'; import type {TargetUniverse} from './DevtoolsUtils.js'; @@ -47,6 +46,7 @@ import { ExtensionRegistry, type InstalledExtension, } from './utils/ExtensionRegistry.js'; +import {saveTemporaryFile} from './utils/files.js'; import {WaitForHelper} from './WaitForHelper.js'; export type { @@ -799,18 +799,7 @@ export class McpContext implements Context { data: Uint8Array, filename: string, ): Promise<{filepath: string}> { - try { - const dir = await fs.mkdtemp( - path.join(os.tmpdir(), 'chrome-devtools-mcp-'), - ); - - const filepath = path.join(dir, filename); - await fs.writeFile(filepath, data); - return {filepath}; - } catch (err) { - this.logger(err); - throw new Error('Could not save a file', {cause: err}); - } + return await saveTemporaryFile(data, filename); } async saveFile( data: Uint8Array, diff --git a/src/bin/chrome-devtools.ts b/src/bin/chrome-devtools.ts index f951928ae..a5c10b043 100644 --- a/src/bin/chrome-devtools.ts +++ b/src/bin/chrome-devtools.ts @@ -109,7 +109,7 @@ for (const [commandName, commandDef] of Object.entries(commands)) { commandStr, commandDef.description, y => { - y.option('format', { + y.option('output-format', { choices: ['md', 'json'], default: 'md', }); @@ -171,9 +171,9 @@ for (const [commandName, commandDef] of Object.entries(commands)) { if (response.success) { console.log( - handleResponse( + await handleResponse( JSON.parse(response.result) as unknown as CallToolResult, - argv['format'] as 'json' | 'md', + argv['output-format'] as 'json' | 'md', ), ); } else { diff --git a/src/daemon/client.ts b/src/daemon/client.ts index 40e50c5f1..4bb87824e 100644 --- a/src/daemon/client.ts +++ b/src/daemon/client.ts @@ -11,6 +11,7 @@ import net from 'node:net'; import {logger} from '../logger.js'; import type {CallToolResult} from '../third_party/index.js'; import {PipeTransport} from '../third_party/index.js'; +import {saveTemporaryFile} from '../utils/files.js'; import type {DaemonMessage, DaemonResponse} from './types.js'; import { @@ -144,10 +145,10 @@ export async function stopDaemon() { await waitForFile(pidFilePath, /*removed=*/ true); } -export function handleResponse( +export async function handleResponse( response: CallToolResult, format: 'json' | 'md', -): string { +): Promise { if (response.isError) { return JSON.stringify(response.content); } @@ -161,9 +162,26 @@ export function handleResponse( for (const content of response.content) { if (content.type === 'text') { chunks.push(content.text); + } else if (content.type === 'image') { + const imageData = content.data; + const mimeType = content.mimeType; + let extension = '.png'; + switch (mimeType) { + case 'image/jpg': + case 'image/jpeg': + extension = '.jpeg'; + break; + case 'webp': + extension = '.webp'; + break; + } + const data = Buffer.from(imageData, 'base64'); + const name = crypto.randomUUID(); + const {filepath} = await saveTemporaryFile(data, `${name}${extension}`); + chunks.push(`Saved to ${filepath}.`); } else { throw new Error('Not supported response content type'); } } - return format === 'md' ? chunks.join('') : JSON.stringify(chunks); + return format === 'md' ? chunks.join(' ') : JSON.stringify(chunks); } diff --git a/src/utils/files.ts b/src/utils/files.ts new file mode 100644 index 000000000..03ddfc45f --- /dev/null +++ b/src/utils/files.ts @@ -0,0 +1,26 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +export async function saveTemporaryFile( + data: Uint8Array, + filename: string, +): Promise<{filepath: string}> { + try { + const dir = await fs.mkdtemp( + path.join(os.tmpdir(), 'chrome-devtools-mcp-'), + ); + + const filepath = path.join(dir, filename); + await fs.writeFile(filepath, data); + return {filepath}; + } catch (err) { + throw new Error('Could not save a file', {cause: err}); + } +} diff --git a/tests/daemon/client.test.ts b/tests/daemon/client.test.ts index 97e302e87..dede15875 100644 --- a/tests/daemon/client.test.ts +++ b/tests/daemon/client.test.ts @@ -55,7 +55,7 @@ describe('daemon client', () => { describe('parsing', () => { it('handles MCP response with text format', async () => { const textResponse = {content: [{type: 'text' as const, text: 'test'}]}; - assert.strictEqual(handleResponse(textResponse, 'md'), 'test'); + assert.strictEqual(await handleResponse(textResponse, 'md'), 'test'); }); it('handles JSON response', async () => { @@ -67,7 +67,7 @@ describe('daemon client', () => { }, }; assert.strictEqual( - handleResponse(jsonResponse, 'json'), + await handleResponse(jsonResponse, 'json'), JSON.stringify(jsonResponse.structuredContent), ); }); @@ -78,7 +78,7 @@ describe('daemon client', () => { content: [{type: 'text' as const, text: 'Something went wrong'}], }; assert.strictEqual( - handleResponse(errorResponse, 'md'), + await handleResponse(errorResponse, 'md'), JSON.stringify(errorResponse.content), ); }); @@ -88,29 +88,24 @@ describe('daemon client', () => { content: [{type: 'text' as const, text: 'Fall through text'}], }; assert.deepStrictEqual( - handleResponse(textResponse, 'json'), + await handleResponse(textResponse, 'json'), JSON.stringify(['Fall through text']), ); }); - it('throws error for unsupported content type', async () => { + it('supports images', async () => { const unsupportedContentResponse = { content: [ { - type: 'resource' as const, - resource: { - uri: 'data:image/png;base64,base64data', - blob: 'base64data', - mimeType: 'image/png', - }, + type: 'image' as const, + data: 'base64data', + mimeType: 'image/png', }, ], structuredContent: {}, }; - assert.throws( - () => handleResponse(unsupportedContentResponse, 'md'), - new Error('Not supported response content type'), - ); + const response = await handleResponse(unsupportedContentResponse, 'md'); + assert.ok(response.includes('.png')); }); }); }); diff --git a/tests/e2e/chrome-devtools.test.ts b/tests/e2e/chrome-devtools.test.ts index 70f7874fd..5a0ae423b 100644 --- a/tests/e2e/chrome-devtools.test.ts +++ b/tests/e2e/chrome-devtools.test.ts @@ -108,6 +108,26 @@ describe('chrome-devtools', () => { ); }); + it('can take screenshot', async () => { + const startResult = spawnSync('node', [CLI_PATH, 'start', ...START_ARGS]); + assert.strictEqual( + startResult.status, + 0, + `start command failed: ${startResult.stderr.toString()}`, + ); + + const result = spawnSync('node', [CLI_PATH, 'take_screenshot']); + assert.strictEqual( + result.status, + 0, + `take_screenshot command failed: ${result.stderr.toString()}`, + ); + assert( + result.stdout.toString().includes('.png'), + 'take_screenshot output is unexpected', + ); + }); + it('forwards disclaimers to stderr on start', () => { const result = spawnSync('node', [CLI_PATH, 'start', ...START_ARGS]); assert.strictEqual(