Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions packages/core/src/tools/mcp-tool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -721,6 +721,89 @@ describe('DiscoveredMCPTool', () => {
);
});

it('should correct the MIME type of a mismatched image content block', async () => {
const params = { param: 'imageMismatch' };
const webpData = Buffer.from([
0x52, 0x49, 0x46, 0x46,
0x00, 0x00, 0x00, 0x00,
0x57, 0x45, 0x42, 0x50,
0x00, 0x00,
]).toString('base64');

mockCallTool.mockResolvedValue(
createSdkResponse(serverToolName, {
content: [
{
type: 'image',
data: webpData,
mimeType: 'image/png',
},
],
}),
);

const invocation = tool.build(params);
const toolResult = await invocation.execute({
abortSignal: new AbortController().signal,
});

expect(toolResult.llmContent).toEqual([
{
text: `[Tool '${serverToolName}' provided the following image data with mime-type: image/webp]`,
},
{
inlineData: {
mimeType: 'image/webp',
data: webpData,
},
},
]);
expect(toolResult.returnDisplay).toBe('[Image: image/webp]');
});

it('should correct the MIME type of a mismatched resource block blob', async () => {
const params = { param: 'resourceMismatch' };
const webpData = Buffer.from([
0x52, 0x49, 0x46, 0x46,
0x00, 0x00, 0x00, 0x00,
0x57, 0x45, 0x42, 0x50,
0x00, 0x00,
]).toString('base64');

mockCallTool.mockResolvedValue(
createSdkResponse(serverToolName, {
content: [
{
type: 'resource',
resource: {
uri: 'file:///path/to/image.png',
blob: webpData,
mimeType: 'image/png',
},
},
],
}),
);

const invocation = tool.build(params);
const toolResult = await invocation.execute({
abortSignal: new AbortController().signal,
});

expect(toolResult.llmContent).toEqual([
{
text: `[Tool '${serverToolName}' provided the following embedded resource with mime-type: image/webp]`,
},
{
inlineData: {
mimeType: 'image/webp',
data: webpData,
},
},
]);
expect(toolResult.returnDisplay).toBe('[Embedded Resource: image/webp]');
});

describe('AbortSignal support', () => {
const MOCK_TOOL_DELAY = 1000;
const ABORT_DELAY = 50;
Expand Down
39 changes: 28 additions & 11 deletions packages/core/src/tools/mcp-tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import type { MessageBus } from '../confirmation-bus/message-bus.js';
import type { McpContext } from './mcp-client.js';

import { wrapUntrusted } from '../utils/textUtils.js';
import { validateAndCorrectMimeType } from '../utils/imageMimeDetection.js';

/**
* The separator used to qualify MCP tool names with their server prefix.
Expand Down Expand Up @@ -457,15 +458,19 @@ function transformImageAudioBlock(
block: McpMediaBlock,
toolName: string,
): Part[] {
const correctedMimeType =
block.type === 'image'
? validateAndCorrectMimeType(block.mimeType, block.data)
: block.mimeType;
return [
{
text: `[Tool '${toolName}' provided the following ${
block.type
} data with mime-type: ${block.mimeType}]`,
} data with mime-type: ${correctedMimeType}]`,
},
{
inlineData: {
mimeType: block.mimeType,
mimeType: correctedMimeType,
data: block.data,
},
},
Expand All @@ -481,14 +486,18 @@ function transformResourceBlock(
return { text: wrapUntrusted(resource.text) };
}
if (resource?.blob) {
const mimeType = resource.mimeType || 'application/octet-stream';
const declaredMimeType = resource.mimeType || 'application/octet-stream';
const correctedMimeType = validateAndCorrectMimeType(
declaredMimeType,
resource.blob,
);
return [
{
text: `[Tool '${toolName}' provided the following embedded resource with mime-type: ${mimeType}]`,
text: `[Tool '${toolName}' provided the following embedded resource with mime-type: ${correctedMimeType}]`,
},
{
inlineData: {
mimeType,
mimeType: correctedMimeType,
data: resource.blob,
},
},
Expand Down Expand Up @@ -562,19 +571,27 @@ function getStringifiedResultForDisplay(rawResponse: Part[]): string {
switch (block.type) {
case 'text':
return block.text;
case 'image':
return `[Image: ${block.mimeType}]`;
case 'image': {
const correctedMimeType = validateAndCorrectMimeType(
block.mimeType,
block.data,
);
return `[Image: ${correctedMimeType}]`;
}
case 'audio':
return `[Audio: ${block.mimeType}]`;
case 'resource_link':
return `[Link to ${block.title || block.name}: ${block.uri}]`;
case 'resource':
case 'resource': {
if (block.resource?.text) {
return block.resource.text;
}
return `[Embedded Resource: ${
block.resource?.mimeType || 'unknown type'
}]`;
const declaredMimeType = block.resource?.mimeType || 'unknown type';
const correctedMimeType = block.resource?.blob
? validateAndCorrectMimeType(declaredMimeType, block.resource.blob)
: declaredMimeType;
return `[Embedded Resource: ${correctedMimeType}]`;
}
default:
return `[Unknown content type: ${(block as { type: string }).type}]`;
}
Expand Down
101 changes: 101 additions & 0 deletions packages/core/src/utils/imageMimeDetection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
detectImageMimeType,
validateAndCorrectMimeType,
} from './imageMimeDetection.js';
import { debugLogger } from './debugLogger.js';

describe('imageMimeDetection', () => {
// Base64 helper strings representing magic bytes of various formats
const pngBase64 = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00]).toString('base64');
const webpBase64 = Buffer.from([
0x52, 0x49, 0x46, 0x46, // RIFF
0x00, 0x00, 0x00, 0x00, // Size
0x57, 0x45, 0x42, 0x50, // WEBP
0x00, 0x00
]).toString('base64');
const jpegBase64 = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x00]).toString('base64');
const gifBase64 = Buffer.from([0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x00, 0x00]).toString('base64');
const txtBase64 = Buffer.from('Hello world!').toString('base64');

describe('detectImageMimeType', () => {
it('should correctly detect PNG files', () => {
expect(detectImageMimeType(pngBase64)).toBe('image/png');
});

it('should correctly detect WebP files', () => {
expect(detectImageMimeType(webpBase64)).toBe('image/webp');
});

it('should correctly detect JPEG files', () => {
expect(detectImageMimeType(jpegBase64)).toBe('image/jpeg');
});

it('should correctly detect GIF files', () => {
expect(detectImageMimeType(gifBase64)).toBe('image/gif');
});

it('should return null for non-image / unknown files', () => {
expect(detectImageMimeType(txtBase64)).toBeNull();
});

it('should return null for empty or invalid base64 data', () => {
expect(detectImageMimeType('')).toBeNull();
expect(detectImageMimeType('abc')).toBeNull(); // too short to decode 4 bytes
});

it('should handle whitespace/newlines in base64 data', () => {
const formattedPng = pngBase64.match(/.{1,4}/g)?.join('\n') || pngBase64;
expect(detectImageMimeType(formattedPng)).toBe('image/png');
});

it('should handle leading whitespace/newlines in base64 data', () => {
expect(detectImageMimeType(' \n\r ' + pngBase64)).toBe('image/png');
});
});

describe('validateAndCorrectMimeType', () => {
let warnSpy: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
warnSpy = vi.spyOn(debugLogger, 'warn').mockImplementation(() => {});
});

afterEach(() => {
vi.restoreAllMocks();
});

it('should return original mime type if there is no mismatch', () => {
expect(validateAndCorrectMimeType('image/png', pngBase64)).toBe('image/png');
expect(warnSpy).not.toHaveBeenCalled();
});

it('should return corrected mime type and log a warning if there is a mismatch', () => {
// e.g. Figma WebP labeled as PNG
expect(validateAndCorrectMimeType('image/png', webpBase64)).toBe('image/webp');
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(warnSpy.mock.calls[0][0]).toContain(
'Image MIME type mismatch: declared as image/png but detected as image/webp'
);
});

it('should fallback to declared mime type if image format is not detected', () => {
expect(validateAndCorrectMimeType('application/octet-stream', txtBase64)).toBe(
'application/octet-stream'
);
expect(warnSpy).not.toHaveBeenCalled();
});

it('should NOT correct mime type if the declared type is not an image or generic', () => {
const textStartingWithGif = Buffer.from('GIF is an image format, not a text file').toString('base64');
expect(validateAndCorrectMimeType('text/plain', textStartingWithGif)).toBe('text/plain');
expect(warnSpy).not.toHaveBeenCalled();
});
});
});
108 changes: 108 additions & 0 deletions packages/core/src/utils/imageMimeDetection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import { debugLogger } from './debugLogger.js';

/**
* Detects the actual image MIME type by inspecting the magic bytes (file signature)
* of base64 encoded data. Supports PNG, WebP, JPEG, and GIF.
*
* @param base64Data The base64 encoded string of the image.
* @returns The detected MIME type (e.g. 'image/webp') or null if not detected/unsupported.
*/
export function detectImageMimeType(base64Data: string): string | null {
if (!base64Data) {
return null;
}
try {
// Take a small prefix of the base64 data and strip whitespace.
// 48 characters of base64 yields up to 36 bytes of binary data.
const cleanPrefix = base64Data.trimStart().slice(0, 48).replace(/\s+/g, '');
const buffer = Buffer.from(cleanPrefix, 'base64');
if (buffer.length < 4) {
return null;
}

// PNG: 89 50 4E 47
if (
buffer[0] === 0x89 &&
buffer[1] === 0x50 &&
buffer[2] === 0x4e &&
buffer[3] === 0x47
) {
return 'image/png';
}

// WebP: RIFF (bytes 0-3) and WEBP (bytes 8-11)
if (
buffer.length >= 12 &&
buffer[0] === 0x52 && // R
buffer[1] === 0x49 && // I
buffer[2] === 0x46 && // F
buffer[3] === 0x46 && // F
buffer[8] === 0x57 && // W
buffer[9] === 0x45 && // E
buffer[10] === 0x42 && // B
buffer[11] === 0x50 // P
) {
return 'image/webp';
}

// JPEG: FF D8 FF
if (
buffer[0] === 0xff &&
buffer[1] === 0xd8 &&
buffer[2] === 0xff
) {
return 'image/jpeg';
}

// GIF: GIF (47 49 46)
if (
buffer[0] === 0x47 && // G
buffer[1] === 0x49 && // I
buffer[2] === 0x46 // F
) {
return 'image/gif';
}
} catch {
// Return null on any decode errors
}
return null;
}

/**
* Validates the declared MIME type against the actual format detected from the base64 data.
* If a mismatch is detected, it logs a warning and returns the corrected MIME type.
* Otherwise, it returns the declared MIME type.
*
* @param declaredMimeType The declared MIME type.
* @param base64Data The base64 encoded string of the image.
* @returns The corrected or original MIME type.
*/
export function validateAndCorrectMimeType(
declaredMimeType: string,
base64Data: string,
): string {
const lowerDeclared = declaredMimeType.toLowerCase();
const isImageOrGeneric =
lowerDeclared.startsWith('image/') ||
lowerDeclared === 'application/octet-stream' ||
lowerDeclared === 'unknown type';

if (!isImageOrGeneric) {
return declaredMimeType;
}

const detectedType = detectImageMimeType(base64Data);
if (detectedType && detectedType !== declaredMimeType) {
debugLogger.warn(
`Image MIME type mismatch: declared as ${declaredMimeType} but detected as ${detectedType}`,
);
return detectedType;
}
return declaredMimeType;
}
Comment thread
Dasoam marked this conversation as resolved.
Loading