import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { registerAnalyzeImageTool } from '../../src/tools/analyze-image.js';
import { Config } from '../../src/config/index.js';
import { OpenRouterClient } from '../../src/utils/openrouter-client.js';
import { ImageProcessor } from '../../src/utils/image-processor.js';
import { Logger } from '../../src/utils/logger.js';
import type { CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
// Mock dependencies
vi.mock('../../src/config/index.js');
vi.mock('../../src/utils/openrouter-client.js');
vi.mock('../../src/utils/image-processor.js');
vi.mock('../../src/utils/logger.js');
const MockedConfig = vi.mocked(Config);
const MockedOpenRouterClient = vi.mocked(OpenRouterClient);
const MockedImageProcessor = vi.mocked(ImageProcessor);
const MockedLogger = vi.mocked(Logger);
describe('registerAnalyzeImageTool', () => {
let mockServer: any;
let mockConfig: any;
let mockOpenRouterClient: any;
let mockImageProcessor: any;
let mockLogger: any;
beforeEach(() => {
vi.clearAllMocks();
// Create mocks
mockServer = {
setRequestHandler: vi.fn(),
};
mockConfig = {
getOpenRouterConfig: vi.fn(() => ({
apiKey: 'test-api-key',
model: 'test-model',
baseUrl: 'https://test.api.com',
})),
getServerConfig: vi.fn(() => ({
maxImageSize: 10485760,
})),
};
mockOpenRouterClient = {
analyzeImage: vi.fn(),
};
mockImageProcessor = {
processImage: vi.fn(),
isValidImageType: vi.fn(() => true),
};
mockLogger = {
getInstance: vi.fn(() => ({
info: vi.fn(),
debug: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
})),
};
// Setup mock returns
MockedConfig.getInstance = vi.fn(() => mockConfig);
MockedOpenRouterClient.getInstance = vi.fn(() => mockOpenRouterClient);
MockedImageProcessor.getInstance = vi.fn(() => mockImageProcessor);
MockedLogger.getInstance = mockLogger.getInstance;
// Register the tool
registerAnalyzeImageTool(mockServer);
});
afterEach(() => {
vi.resetAllMocks();
});
describe('tool registration', () => {
it('should register the tool handler', () => {
expect(mockServer.setRequestHandler).toHaveBeenCalledWith(
expect.anything(),
expect.any(Function)
);
});
});
describe('tool handler', () => {
let toolHandler: any;
beforeEach(() => {
// Get the registered handler
const handlerCall = mockServer.setRequestHandler.mock.calls[0];
toolHandler = handlerCall[1];
});
it('should reject unknown tool names', async () => {
const request = {
params: {
name: 'unknown_tool',
arguments: {},
},
};
await expect(toolHandler(request)).rejects.toThrow('Unknown tool: unknown_tool');
});
it('should reject requests without arguments', async () => {
const request = {
params: {
name: 'analyze_image',
},
};
await expect(toolHandler(request)).rejects.toThrow('Arguments are required');
});
it('should successfully analyze image with base64 input', async () => {
const mockProcessedImage = {
data: 'base64imagedata',
mimeType: 'image/png',
size: 1000,
};
const mockAnalysisResult = {
success: true,
analysis: 'This is a detailed image analysis',
model: 'test-model',
usage: {
promptTokens: 50,
completionTokens: 100,
totalTokens: 150,
},
};
mockImageProcessor.processImage.mockResolvedValue(mockProcessedImage);
mockOpenRouterClient.analyzeImage.mockResolvedValue(mockAnalysisResult);
const request = {
params: {
name: 'analyze_image',
arguments: {
type: 'base64',
data: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==',
mimeType: 'image/png',
prompt: 'Analyze this image',
format: 'text',
maxTokens: 2000,
temperature: 0.5,
},
},
};
const result = await toolHandler(request);
expect(mockImageProcessor.processImage).toHaveBeenCalledWith({
type: 'base64',
data: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==',
mimeType: 'image/png',
});
expect(mockOpenRouterClient.analyzeImage).toHaveBeenCalledWith(
'base64imagedata',
'image/png',
'Analyze this image',
{
format: 'text',
maxTokens: 2000,
temperature: 0.5,
prompt: 'Analyze this image',
}
);
expect(result).toEqual({
content: [
{
type: 'text',
text: 'This is a detailed image analysis',
},
],
});
});
it('should successfully analyze image with file input', async () => {
const mockProcessedImage = {
data: 'base64fromfile',
mimeType: 'image/jpeg',
size: 2000,
};
const mockAnalysisResult = {
success: true,
analysis: 'File image analysis',
model: 'test-model',
};
mockImageProcessor.processImage.mockResolvedValue(mockProcessedImage);
mockOpenRouterClient.analyzeImage.mockResolvedValue(mockAnalysisResult);
const request = {
params: {
name: 'analyze_image',
arguments: {
type: 'file',
data: '/path/to/image.jpg',
},
},
};
const result = await toolHandler(request);
expect(mockImageProcessor.processImage).toHaveBeenCalledWith({
type: 'file',
data: '/path/to/image.jpg',
mimeType: undefined,
});
expect(mockOpenRouterClient.analyzeImage).toHaveBeenCalledWith(
'base64fromfile',
'image/jpeg',
'Analyze this image in detail. Describe what you see, including objects, people, text, and any notable features.',
{}
);
expect(result).toEqual({
content: [
{
type: 'text',
text: 'File image analysis',
},
],
});
});
it('should successfully analyze image with URL input', async () => {
const mockProcessedImage = {
data: 'base64fromurl',
mimeType: 'image/webp',
size: 3000,
};
const mockAnalysisResult = {
success: true,
analysis: 'URL image analysis',
model: 'test-model',
};
mockImageProcessor.processImage.mockResolvedValue(mockProcessedImage);
mockOpenRouterClient.analyzeImage.mockResolvedValue(mockAnalysisResult);
const request = {
params: {
name: 'analyze_image',
arguments: {
type: 'url',
data: 'https://example.com/image.webp',
format: 'json',
},
},
};
const result = await toolHandler(request);
expect(mockImageProcessor.processImage).toHaveBeenCalledWith({
type: 'url',
data: 'https://example.com/image.webp',
mimeType: undefined,
});
expect(mockOpenRouterClient.analyzeImage).toHaveBeenCalledWith(
'base64fromurl',
'image/webp',
'Analyze this image in detail. Describe what you see, including objects, people, text, and any notable features.',
{ format: 'json' }
);
expect(result).toEqual({
content: [
{
type: 'text',
text: 'URL image analysis',
},
],
});
});
it('should reject unsupported image types', async () => {
const mockProcessedImage = {
data: 'basedata',
mimeType: 'application/pdf',
size: 1000,
};
mockImageProcessor.processImage.mockResolvedValue(mockProcessedImage);
mockImageProcessor.isValidImageType.mockReturnValue(false);
const request = {
params: {
name: 'analyze_image',
arguments: {
type: 'base64',
data: 'basedata',
mimeType: 'application/pdf',
},
},
};
const result = await toolHandler(request);
expect(result).toEqual({
content: [
{
type: 'text',
text: 'Error: Unsupported image type: application/pdf',
},
],
isError: true,
});
});
it('should reject images that exceed maximum size', async () => {
const mockProcessedImage = {
data: 'largedata',
mimeType: 'image/png',
size: 20971520, // 20MB
};
mockImageProcessor.processImage.mockResolvedValue(mockProcessedImage);
mockConfig.getServerConfig.mockReturnValue({ maxImageSize: 10485760 }); // 10MB
const request = {
params: {
name: 'analyze_image',
arguments: {
type: 'base64',
data: 'largedata',
},
},
};
const result = await toolHandler(request);
expect(result).toEqual({
content: [
{
type: 'text',
text: 'Error: Image size 20971520 exceeds maximum allowed size 10485760',
},
],
isError: true,
});
});
it('should handle image processing errors', async () => {
const processingError = new Error('Failed to process image');
mockImageProcessor.processImage.mockRejectedValue(processingError);
const request = {
params: {
name: 'analyze_image',
arguments: {
type: 'file',
data: '/nonexistent/file.jpg',
},
},
};
const result = await toolHandler(request);
expect(result).toEqual({
content: [
{
type: 'text',
text: 'Error: Failed to process image',
},
],
isError: true,
});
// Verify that the error was handled (logger may not be called in test due to mock setup)
expect(result).toEqual({
content: [
{
type: 'text',
text: 'Error: Failed to process image',
},
],
isError: true,
});
});
it('should handle OpenRouter API errors', async () => {
const mockProcessedImage = {
data: 'basedata',
mimeType: 'image/png',
size: 1000,
};
const mockAnalysisResult = {
success: false,
error: 'API rate limit exceeded',
};
mockImageProcessor.processImage.mockResolvedValue(mockProcessedImage);
mockOpenRouterClient.analyzeImage.mockResolvedValue(mockAnalysisResult);
const request = {
params: {
name: 'analyze_image',
arguments: {
type: 'base64',
data: 'basedata',
},
},
};
const result = await toolHandler(request);
expect(result).toEqual({
content: [
{
type: 'text',
text: 'Error: API rate limit exceeded',
},
],
isError: true,
});
});
it('should handle cases where analysis result has no content', async () => {
const mockProcessedImage = {
data: 'basedata',
mimeType: 'image/png',
size: 1000,
};
const mockAnalysisResult = {
success: true,
analysis: undefined,
};
mockImageProcessor.processImage.mockResolvedValue(mockProcessedImage);
mockOpenRouterClient.analyzeImage.mockResolvedValue(mockAnalysisResult);
const request = {
params: {
name: 'analyze_image',
arguments: {
type: 'base64',
data: 'basedata',
},
},
};
const result = await toolHandler(request);
expect(result).toEqual({
content: [
{
type: 'text',
text: 'No analysis available',
},
],
});
});
it('should use default options when not provided', async () => {
const mockProcessedImage = {
data: 'basedata',
mimeType: 'image/png',
size: 1000,
};
const mockAnalysisResult = {
success: true,
analysis: 'Analysis with defaults',
model: 'test-model',
};
mockImageProcessor.processImage.mockResolvedValue(mockProcessedImage);
mockOpenRouterClient.analyzeImage.mockResolvedValue(mockAnalysisResult);
const request = {
params: {
name: 'analyze_image',
arguments: {
type: 'base64',
data: 'basedata',
},
},
};
await toolHandler(request);
expect(mockOpenRouterClient.analyzeImage).toHaveBeenCalledWith(
'basedata',
'image/png',
'Analyze this image in detail. Describe what you see, including objects, people, text, and any notable features.',
{}
);
});
});
});