server.test.ts•19 kB
import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals';
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import { OpenFoodFactsClient } from '../src/client.js';
import { ToolHandlers } from '../src/handlers.js';
import { tools } from '../src/tools.js';
import {
mockNutellaProductResponse,
mockHealthyYogurtProductResponse,
mockSearchResponse,
mockBarcodes,
} from './fixtures/products.js';
// Mock dependencies
jest.mock('../src/client.js');
jest.mock('../src/handlers.js');
describe('MCP Server Integration', () => {
let server: Server;
let mockClient: jest.Mocked<OpenFoodFactsClient>;
let mockHandlers: jest.Mocked<ToolHandlers>;
beforeEach(() => {
// Create mocked client
mockClient = {
getProduct: jest.fn(),
searchProducts: jest.fn(),
getProductsByBarcodes: jest.fn(),
} as any;
// Create mocked handlers
mockHandlers = {
handleGetProduct: jest.fn(),
handleSearchProducts: jest.fn(),
handleAnalyzeProduct: jest.fn(),
handleCompareProducts: jest.fn(),
handleGetProductSuggestions: jest.fn(),
} as any;
// Mock constructor
(OpenFoodFactsClient as jest.MockedClass<typeof OpenFoodFactsClient>).mockImplementation(() => mockClient);
(ToolHandlers as jest.MockedClass<typeof ToolHandlers>).mockImplementation(() => mockHandlers);
// Create server instance
server = new Server(
{
name: 'open-food-facts-mcp',
version: '1.0.0',
},
{
capabilities: {
tools: {},
},
}
);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('ListTools', () => {
it('should return all available tools', async () => {
const request = {
method: 'tools/list' as const,
params: {},
};
const response = await server.handleRequest(ListToolsRequestSchema.parse(request));
expect(response.tools).toHaveLength(5);
expect(response.tools).toEqual(tools);
// Verify all expected tools are present
const toolNames = response.tools.map(tool => tool.name);
expect(toolNames).toContain('get_product');
expect(toolNames).toContain('search_products');
expect(toolNames).toContain('analyze_product');
expect(toolNames).toContain('compare_products');
expect(toolNames).toContain('get_product_suggestions');
});
it('should have correct tool schemas', async () => {
const request = {
method: 'tools/list' as const,
params: {},
};
const response = await server.handleRequest(ListToolsRequestSchema.parse(request));
const getProductTool = response.tools.find(tool => tool.name === 'get_product');
expect(getProductTool).toBeDefined();
expect(getProductTool!.inputSchema.properties).toHaveProperty('barcode');
expect(getProductTool!.inputSchema.required).toContain('barcode');
const searchTool = response.tools.find(tool => tool.name === 'search_products');
expect(searchTool).toBeDefined();
expect(searchTool!.inputSchema.properties).toHaveProperty('search');
expect(searchTool!.inputSchema.properties).toHaveProperty('categories');
expect(searchTool!.inputSchema.required).toEqual([]);
});
});
describe('CallTool - get_product', () => {
beforeEach(() => {
// Re-setup server with handlers for each test
const handlers = new ToolHandlers(mockClient);
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
switch (name) {
case 'get_product':
return await handlers.handleGetProduct(args.barcode);
case 'search_products':
return await handlers.handleSearchProducts(args);
case 'analyze_product':
return await handlers.handleAnalyzeProduct(args.barcode);
case 'compare_products':
return await handlers.handleCompareProducts(args.barcodes, args.focus);
case 'get_product_suggestions':
return await handlers.handleGetProductSuggestions(args);
default:
throw new Error(`Unknown tool: ${name}`);
}
});
});
it('should handle get_product successfully', async () => {
const expectedResponse = {
content: [
{
type: 'text' as const,
text: '**Nutella** product information...',
},
],
};
mockHandlers.handleGetProduct.mockResolvedValue(expectedResponse);
const request = {
method: 'tools/call' as const,
params: {
name: 'get_product',
arguments: {
barcode: mockBarcodes.nutella,
},
},
};
const response = await server.handleRequest(CallToolRequestSchema.parse(request));
expect(mockHandlers.handleGetProduct).toHaveBeenCalledWith(mockBarcodes.nutella);
expect(response).toEqual(expectedResponse);
});
it('should handle missing barcode parameter', async () => {
const request = {
method: 'tools/call' as const,
params: {
name: 'get_product',
arguments: {}, // Missing barcode
},
};
await expect(server.handleRequest(CallToolRequestSchema.parse(request)))
.rejects.toThrow();
});
it('should handle invalid barcode type', async () => {
const request = {
method: 'tools/call' as const,
params: {
name: 'get_product',
arguments: {
barcode: 123, // Should be string
},
},
};
await expect(server.handleRequest(CallToolRequestSchema.parse(request)))
.rejects.toThrow();
});
});
describe('CallTool - search_products', () => {
beforeEach(() => {
const handlers = new ToolHandlers(mockClient);
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
if (name === 'search_products') {
return await handlers.handleSearchProducts(args);
}
throw new Error(`Unknown tool: ${name}`);
});
});
it('should handle search_products with all parameters', async () => {
const expectedResponse = {
content: [
{
type: 'text' as const,
text: 'Found 150 products...',
},
],
};
mockHandlers.handleSearchProducts.mockResolvedValue(expectedResponse);
const searchParams = {
search: 'chocolate',
categories: 'snacks',
brands: 'ferrero',
countries: 'france',
nutrition_grades: 'a,b',
nova_groups: '1,2',
sort_by: 'popularity',
page: 1,
page_size: 20,
};
const request = {
method: 'tools/call' as const,
params: {
name: 'search_products',
arguments: searchParams,
},
};
const response = await server.handleRequest(CallToolRequestSchema.parse(request));
expect(mockHandlers.handleSearchProducts).toHaveBeenCalledWith(searchParams);
expect(response).toEqual(expectedResponse);
});
it('should handle search_products with no parameters', async () => {
const expectedResponse = {
content: [
{
type: 'text' as const,
text: 'Search results...',
},
],
};
mockHandlers.handleSearchProducts.mockResolvedValue(expectedResponse);
const request = {
method: 'tools/call' as const,
params: {
name: 'search_products',
arguments: {},
},
};
const response = await server.handleRequest(CallToolRequestSchema.parse(request));
expect(mockHandlers.handleSearchProducts).toHaveBeenCalledWith({});
expect(response).toEqual(expectedResponse);
});
it('should validate sort_by enum values', async () => {
const request = {
method: 'tools/call' as const,
params: {
name: 'search_products',
arguments: {
sort_by: 'invalid_sort',
},
},
};
await expect(server.handleRequest(CallToolRequestSchema.parse(request)))
.rejects.toThrow();
});
it('should validate page_size limits', async () => {
const request = {
method: 'tools/call' as const,
params: {
name: 'search_products',
arguments: {
page_size: 0, // Below minimum
},
},
};
await expect(server.handleRequest(CallToolRequestSchema.parse(request)))
.rejects.toThrow();
});
});
describe('CallTool - analyze_product', () => {
beforeEach(() => {
const handlers = new ToolHandlers(mockClient);
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
if (name === 'analyze_product') {
return await handlers.handleAnalyzeProduct(args.barcode);
}
throw new Error(`Unknown tool: ${name}`);
});
});
it('should handle analyze_product successfully', async () => {
const expectedResponse = {
content: [
{
type: 'text' as const,
text: '**Nutritional Analysis: Nutella**...',
},
],
};
mockHandlers.handleAnalyzeProduct.mockResolvedValue(expectedResponse);
const request = {
method: 'tools/call' as const,
params: {
name: 'analyze_product',
arguments: {
barcode: mockBarcodes.nutella,
},
},
};
const response = await server.handleRequest(CallToolRequestSchema.parse(request));
expect(mockHandlers.handleAnalyzeProduct).toHaveBeenCalledWith(mockBarcodes.nutella);
expect(response).toEqual(expectedResponse);
});
});
describe('CallTool - compare_products', () => {
beforeEach(() => {
const handlers = new ToolHandlers(mockClient);
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
if (name === 'compare_products') {
return await handlers.handleCompareProducts(args.barcodes, args.focus);
}
throw new Error(`Unknown tool: ${name}`);
});
});
it('should handle compare_products successfully', async () => {
const expectedResponse = {
content: [
{
type: 'text' as const,
text: '**Product Comparison (nutrition)**...',
},
],
};
mockHandlers.handleCompareProducts.mockResolvedValue(expectedResponse);
const barcodes = [mockBarcodes.nutella, mockBarcodes.yogurt];
const request = {
method: 'tools/call' as const,
params: {
name: 'compare_products',
arguments: {
barcodes,
focus: 'nutrition',
},
},
};
const response = await server.handleRequest(CallToolRequestSchema.parse(request));
expect(mockHandlers.handleCompareProducts).toHaveBeenCalledWith(barcodes, 'nutrition');
expect(response).toEqual(expectedResponse);
});
it('should validate barcodes array requirements', async () => {
// Test minimum items
const requestTooFew = {
method: 'tools/call' as const,
params: {
name: 'compare_products',
arguments: {
barcodes: ['123456789'], // Only one item
},
},
};
await expect(server.handleRequest(CallToolRequestSchema.parse(requestTooFew)))
.rejects.toThrow();
// Test maximum items
const tooManyBarcodes = Array(11).fill('123456789');
const requestTooMany = {
method: 'tools/call' as const,
params: {
name: 'compare_products',
arguments: {
barcodes: tooManyBarcodes,
},
},
};
await expect(server.handleRequest(CallToolRequestSchema.parse(requestTooMany)))
.rejects.toThrow();
});
it('should validate focus enum values', async () => {
const request = {
method: 'tools/call' as const,
params: {
name: 'compare_products',
arguments: {
barcodes: [mockBarcodes.nutella, mockBarcodes.yogurt],
focus: 'invalid_focus',
},
},
};
await expect(server.handleRequest(CallToolRequestSchema.parse(request)))
.rejects.toThrow();
});
});
describe('CallTool - get_product_suggestions', () => {
beforeEach(() => {
const handlers = new ToolHandlers(mockClient);
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
if (name === 'get_product_suggestions') {
return await handlers.handleGetProductSuggestions(args);
}
throw new Error(`Unknown tool: ${name}`);
});
});
it('should handle get_product_suggestions successfully', async () => {
const expectedResponse = {
content: [
{
type: 'text' as const,
text: 'Product suggestions in beverages:...',
},
],
};
mockHandlers.handleGetProductSuggestions.mockResolvedValue(expectedResponse);
const params = {
category: 'beverages',
dietary_preferences: ['vegan', 'organic'],
max_results: 5,
min_nutriscore: 'b',
};
const request = {
method: 'tools/call' as const,
params: {
name: 'get_product_suggestions',
arguments: params,
},
};
const response = await server.handleRequest(CallToolRequestSchema.parse(request));
expect(mockHandlers.handleGetProductSuggestions).toHaveBeenCalledWith(params);
expect(response).toEqual(expectedResponse);
});
it('should validate dietary_preferences enum values', async () => {
const request = {
method: 'tools/call' as const,
params: {
name: 'get_product_suggestions',
arguments: {
category: 'beverages',
dietary_preferences: ['invalid_preference'],
},
},
};
await expect(server.handleRequest(CallToolRequestSchema.parse(request)))
.rejects.toThrow();
});
it('should validate max_results limits', async () => {
const requestTooLow = {
method: 'tools/call' as const,
params: {
name: 'get_product_suggestions',
arguments: {
category: 'beverages',
max_results: 0,
},
},
};
await expect(server.handleRequest(CallToolRequestSchema.parse(requestTooLow)))
.rejects.toThrow();
const requestTooHigh = {
method: 'tools/call' as const,
params: {
name: 'get_product_suggestions',
arguments: {
category: 'beverages',
max_results: 51,
},
},
};
await expect(server.handleRequest(CallToolRequestSchema.parse(requestTooHigh)))
.rejects.toThrow();
});
it('should validate min_nutriscore enum values', async () => {
const request = {
method: 'tools/call' as const,
params: {
name: 'get_product_suggestions',
arguments: {
category: 'beverages',
min_nutriscore: 'f', // Invalid grade
},
},
};
await expect(server.handleRequest(CallToolRequestSchema.parse(request)))
.rejects.toThrow();
});
});
describe('Error Handling', () => {
beforeEach(() => {
const handlers = new ToolHandlers(mockClient);
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
try {
switch (name) {
case 'get_product':
return await handlers.handleGetProduct(args.barcode);
case 'search_products':
return await handlers.handleSearchProducts(args);
default:
throw new Error(`Unknown tool: ${name}`);
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
return {
content: [
{
type: 'text' as const,
text: `Error: ${errorMessage}`,
},
],
isError: true,
};
}
});
});
it('should handle unknown tool names', async () => {
const request = {
method: 'tools/call' as const,
params: {
name: 'nonexistent_tool',
arguments: {},
},
};
const response = await server.handleRequest(CallToolRequestSchema.parse(request));
expect(response.isError).toBe(true);
expect(response.content[0].text).toContain('Unknown tool: nonexistent_tool');
});
it('should handle handler errors gracefully', async () => {
mockHandlers.handleGetProduct.mockRejectedValue(new Error('Network timeout'));
const request = {
method: 'tools/call' as const,
params: {
name: 'get_product',
arguments: {
barcode: mockBarcodes.nutella,
},
},
};
const response = await server.handleRequest(CallToolRequestSchema.parse(request));
expect(response.isError).toBe(true);
expect(response.content[0].text).toContain('Error: Network timeout');
});
it('should handle non-Error exceptions', async () => {
mockHandlers.handleSearchProducts.mockRejectedValue('String error');
const request = {
method: 'tools/call' as const,
params: {
name: 'search_products',
arguments: {},
},
};
const response = await server.handleRequest(CallToolRequestSchema.parse(request));
expect(response.isError).toBe(true);
expect(response.content[0].text).toContain('Error: Unknown error occurred');
});
});
describe('Server Configuration', () => {
it('should have correct server metadata', () => {
expect(server).toBeDefined();
// Note: Server metadata is set in constructor, not easily accessible for testing
// This test verifies the server can be constructed without errors
});
it('should have tools capability enabled', () => {
expect(server).toBeDefined();
// Capabilities are internal to the server implementation
// This test verifies the server is configured with tools capability
});
});
});