Skip to main content
Glama
tools.test.js18.3 kB
/** * Integration tests for MCP tool handlers * Tests the front-end/API layer - tool invocation, response format, error handling */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' // Mock all dependencies vi.mock('fs', () => ({ default: { existsSync: vi.fn().mockReturnValue(true), readFileSync: vi.fn(), writeFileSync: vi.fn(), mkdirSync: vi.fn(), unlinkSync: vi.fn(), readdirSync: vi.fn().mockReturnValue([]) }, existsSync: vi.fn().mockReturnValue(true), readFileSync: vi.fn(), writeFileSync: vi.fn(), mkdirSync: vi.fn(), unlinkSync: vi.fn(), readdirSync: vi.fn().mockReturnValue([]) })) vi.mock('@lancedb/lancedb', () => ({ connect: vi.fn().mockResolvedValue({ tableNames: vi.fn().mockResolvedValue(['emails', 'messages', 'calendar']), openTable: vi.fn().mockResolvedValue({ search: vi.fn().mockReturnValue({ limit: vi.fn().mockReturnValue({ toArray: vi.fn().mockResolvedValue([]) }) }) }) }) })) vi.mock('@xenova/transformers', () => ({ pipeline: vi.fn().mockResolvedValue( vi.fn().mockResolvedValue({ data: new Float32Array(384).fill(0.1) }) ) })) vi.mock('../../lib/shell.js', () => ({ safeSqlite3: vi.fn().mockReturnValue('0'), safeSqlite3Json: vi.fn().mockReturnValue([]), safeOsascript: vi.fn().mockReturnValue('') })) // ============ TOOL DEFINITION TESTS ============ describe('MCP Tool Definitions', () => { // These tests verify the tool schemas are correct describe('Mail Tools Schema', () => { const mailSearchSchema = { name: 'mail_search', required: ['query'], properties: { query: { type: 'string' }, sender: { type: 'string' }, recipient: { type: 'string' }, has_attachment: { type: 'boolean' }, mailbox: { type: 'string' }, sent_only: { type: 'boolean' }, flagged_only: { type: 'boolean' }, days_back: { type: 'number' }, limit: { type: 'number' }, sort_by: { enum: ['relevance', 'date'] } } } it('should require query parameter', () => { expect(mailSearchSchema.required).toContain('query') }) it('should support all filter options', () => { expect(mailSearchSchema.properties).toHaveProperty('sender') expect(mailSearchSchema.properties).toHaveProperty('recipient') expect(mailSearchSchema.properties).toHaveProperty('has_attachment') expect(mailSearchSchema.properties).toHaveProperty('mailbox') expect(mailSearchSchema.properties).toHaveProperty('sent_only') expect(mailSearchSchema.properties).toHaveProperty('flagged_only') }) it('should support sort_by enum', () => { expect(mailSearchSchema.properties.sort_by.enum).toContain('relevance') expect(mailSearchSchema.properties.sort_by.enum).toContain('date') }) }) describe('Messages Tools Schema', () => { const messagesSearchSchema = { name: 'messages_search', required: ['query'], properties: { query: { type: 'string' }, contact: { type: 'string' }, group_chat_only: { type: 'boolean' }, group_chat_name: { type: 'string' }, has_attachment: { type: 'boolean' }, days_back: { type: 'number' }, limit: { type: 'number' }, sort_by: { enum: ['relevance', 'date'] } } } it('should require query parameter', () => { expect(messagesSearchSchema.required).toContain('query') }) it('should support group chat filters', () => { expect(messagesSearchSchema.properties).toHaveProperty('group_chat_only') expect(messagesSearchSchema.properties).toHaveProperty('group_chat_name') }) }) describe('Calendar Tools Schema', () => { const calendarSearchSchema = { name: 'calendar_search', required: ['query'], properties: { query: { type: 'string' }, calendar_name: { type: 'string' }, all_day_only: { type: 'boolean' }, days_back: { type: 'number' }, days_ahead: { type: 'number' }, limit: { type: 'number' }, sort_by: { enum: ['relevance', 'date'] } } } it('should require query parameter', () => { expect(calendarSearchSchema.required).toContain('query') }) it('should support days_ahead for future events', () => { expect(calendarSearchSchema.properties).toHaveProperty('days_ahead') }) }) describe('Contacts Tools Schema', () => { const contactsSearchSchema = { name: 'contacts_search', required: ['query'], properties: { query: { type: 'string' }, limit: { type: 'number' } } } it('should require query parameter', () => { expect(contactsSearchSchema.required).toContain('query') }) }) }) // ============ TOOL RESPONSE FORMAT TESTS ============ describe('MCP Response Format', () => { const formatMCPResponse = (content, isError = false) => { return { content: [{ type: 'text', text: content }], isError } } it('should format success response correctly', () => { const response = formatMCPResponse('Search results...') expect(response.content[0].type).toBe('text') expect(response.content[0].text).toBe('Search results...') expect(response.isError).toBe(false) }) it('should format error response correctly', () => { const response = formatMCPResponse('Error: Invalid query', true) expect(response.isError).toBe(true) }) }) // ============ INPUT VALIDATION TESTS ============ describe('Tool Input Validation', () => { describe('Required Parameters', () => { const validateRequired = (params, required) => { for (const field of required) { if (params[field] === undefined || params[field] === null || params[field] === '') { return { valid: false, error: `${field} is required` } } } return { valid: true } } it('should reject missing query for search tools', () => { const result = validateRequired({}, ['query']) expect(result.valid).toBe(false) expect(result.error).toContain('query') }) it('should accept valid query', () => { const result = validateRequired({ query: 'test' }, ['query']) expect(result.valid).toBe(true) }) it('should reject empty string query', () => { const result = validateRequired({ query: '' }, ['query']) expect(result.valid).toBe(false) }) }) describe('Type Coercion', () => { const coerceNumber = (value, defaultValue) => { if (value === undefined || value === null) return defaultValue const num = parseInt(value, 10) return isNaN(num) ? defaultValue : num } it('should coerce string to number', () => { expect(coerceNumber('30', 10)).toBe(30) }) it('should use default for invalid string', () => { expect(coerceNumber('abc', 10)).toBe(10) }) it('should use default for undefined', () => { expect(coerceNumber(undefined, 10)).toBe(10) }) }) describe('Boolean Parameters', () => { const coerceBoolean = (value) => { if (value === undefined || value === null) return undefined if (typeof value === 'boolean') return value if (value === 'true') return true if (value === 'false') return false return undefined } it('should handle boolean true', () => { expect(coerceBoolean(true)).toBe(true) }) it('should handle string "true"', () => { expect(coerceBoolean('true')).toBe(true) }) it('should return undefined for invalid value', () => { expect(coerceBoolean('maybe')).toBeUndefined() }) }) }) // ============ ERROR HANDLING TESTS ============ describe('Tool Error Handling', () => { describe('Unknown Tool', () => { const handleToolCall = (toolName, validTools) => { if (!validTools.includes(toolName)) { return { error: `Unknown tool: ${toolName}` } } return { error: null } } it('should reject unknown tool names', () => { const validTools = ['mail_search', 'messages_search', 'calendar_search'] const result = handleToolCall('invalid_tool', validTools) expect(result.error).toContain('Unknown tool') }) it('should accept valid tool names', () => { const validTools = ['mail_search', 'messages_search', 'calendar_search'] const result = handleToolCall('mail_search', validTools) expect(result.error).toBeNull() }) }) describe('Index Not Ready', () => { const checkIndexReady = async (indexReady, isFirstRun) => { if (!indexReady) { if (isFirstRun) { return 'Building initial index. This may take several minutes on first run. Please try again shortly.' } return 'Indexing new data. Please try again in a moment.' } return null } it('should return first-run message when index not ready', async () => { const message = await checkIndexReady(false, true) expect(message).toContain('first run') }) it('should return update message for incremental index', async () => { const message = await checkIndexReady(false, false) expect(message).toContain('Indexing new data') }) it('should return null when index is ready', async () => { const message = await checkIndexReady(true, false) expect(message).toBeNull() }) }) describe('Invalid File Path', () => { const validatePath = (filePath, allowedDir) => { if (!filePath) { return { valid: false, error: 'File path is required' } } if (!filePath.startsWith(allowedDir)) { return { valid: false, error: 'Access denied: path outside allowed directory' } } return { valid: true } } it('should reject paths outside allowed directory', () => { const result = validatePath('/etc/passwd', '/Users/test/Library/Mail') expect(result.valid).toBe(false) expect(result.error).toContain('Access denied') }) it('should accept paths in allowed directory', () => { const result = validatePath('/Users/test/Library/Mail/INBOX/msg.emlx', '/Users/test/Library/Mail') expect(result.valid).toBe(true) }) }) }) // ============ CROSS-TOOL WORKFLOW TESTS ============ describe('Cross-Tool Workflows', () => { describe('Search → Read Flow', () => { it('should return filePath in search results for mail_read', () => { const searchResult = { subject: 'Test Email', from: 'john@example.com', date: '2024-01-15', filePath: '/Users/test/Library/Mail/INBOX/123.emlx' } expect(searchResult).toHaveProperty('filePath') expect(searchResult.filePath).toMatch(/\.emlx$/) }) it('should include data needed for follow-up queries', () => { const searchResult = { sender: 'john@example.com', chatIdentifier: '+1234567890', messageId: '<unique@example.com>' } // These fields enable follow-up queries expect(searchResult.sender || searchResult.chatIdentifier).toBeTruthy() }) }) describe('Person Search Aggregation', () => { const aggregatePersonResults = (mailResults, messageResults, calendarResults) => { return { emails: mailResults, messages: messageResults, events: calendarResults, totalCount: mailResults.length + messageResults.length + calendarResults.length } } it('should aggregate results from all sources', () => { const mail = [{ subject: 'Email 1' }, { subject: 'Email 2' }] const messages = [{ text: 'Message 1' }] const calendar = [{ title: 'Meeting' }] const result = aggregatePersonResults(mail, messages, calendar) expect(result.emails.length).toBe(2) expect(result.messages.length).toBe(1) expect(result.events.length).toBe(1) expect(result.totalCount).toBe(4) }) }) }) // ============ SMART SEARCH INTENT DETECTION ============ describe('Smart Search Intent Detection', () => { const detectIntent = (query) => { const q = query.toLowerCase() // Calendar intent signals if (q.match(/\b(meeting|event|calendar|schedule|appointment|when|free|busy|available)\b/)) { return 'calendar' } // Messages intent signals if (q.match(/\b(text|message|imessage|sms|chat|conversation|say|said|told)\b/)) { return 'messages' } // Email intent signals if (q.match(/\b(email|mail|inbox|sent|attachment|forward|reply)\b/)) { return 'mail' } // Default to searching all sources return 'all' } it('should detect calendar intent', () => { expect(detectIntent('when is my next meeting')).toBe('calendar') expect(detectIntent('schedule for tomorrow')).toBe('calendar') expect(detectIntent('am I free at 3pm')).toBe('calendar') }) it('should detect messages intent', () => { expect(detectIntent('text from John')).toBe('messages') expect(detectIntent('what did Sarah say')).toBe('messages') expect(detectIntent('iMessage conversation')).toBe('messages') }) it('should detect email intent', () => { expect(detectIntent('email from boss')).toBe('mail') expect(detectIntent('inbox messages')).toBe('mail') expect(detectIntent('email with attachment')).toBe('mail') }) it('should default to all for ambiguous queries', () => { expect(detectIntent('budget discussion')).toBe('all') expect(detectIntent('project update')).toBe('all') }) }) // ============ RESULT FORMATTING TESTS ============ describe('Result Formatting', () => { describe('Email Result Format', () => { const formatEmailResult = (email, rank) => { return `${rank}. **${email.subject || 'No Subject'}** From: ${email.from || 'Unknown'} Date: ${email.date || 'Unknown'} ${email.hasAttachment ? '📎 Has attachment' : ''} Preview: ${(email.body || '').substring(0, 100)}...` } it('should format email with all fields', () => { const email = { subject: 'Test Subject', from: 'john@example.com', date: '2024-01-15', body: 'This is the email body content', hasAttachment: true } const formatted = formatEmailResult(email, 1) expect(formatted).toContain('Test Subject') expect(formatted).toContain('john@example.com') expect(formatted).toContain('📎') }) it('should handle missing fields gracefully', () => { const email = {} const formatted = formatEmailResult(email, 1) expect(formatted).toContain('No Subject') expect(formatted).toContain('Unknown') }) }) describe('Calendar Event Format', () => { const formatCalendarEvent = (event) => { let result = `**${event.title || 'Untitled Event'}**` if (event.start) result += `\n When: ${event.start}` if (event.location) result += `\n Where: ${event.location}` if (event.calendar) result += `\n Calendar: ${event.calendar}` return result } it('should format event with location', () => { const event = { title: 'Team Meeting', start: '2024-01-15 10:00 AM', location: 'Conference Room A', calendar: 'Work' } const formatted = formatCalendarEvent(event) expect(formatted).toContain('Team Meeting') expect(formatted).toContain('Conference Room A') expect(formatted).toContain('Work') }) }) describe('Message Format', () => { const formatMessage = (msg) => { const sender = msg.sender || 'Unknown' const date = msg.date || 'Unknown date' const text = msg.text || '' const isGroup = msg.participantCount > 2 return `[${date}] ${sender}${isGroup ? ' (group)' : ''}: ${text}` } it('should indicate group chat', () => { const msg = { sender: 'John', date: '10:30 AM', text: 'Hello everyone', participantCount: 5 } const formatted = formatMessage(msg) expect(formatted).toContain('(group)') }) it('should not indicate group for 1:1 chat', () => { const msg = { sender: 'John', date: '10:30 AM', text: 'Hello', participantCount: 2 } const formatted = formatMessage(msg) expect(formatted).not.toContain('(group)') }) }) }) // ============ CACHING BEHAVIOR TESTS ============ describe('Caching Behavior', () => { describe('Embedding Cache', () => { const CACHE_TTL = 5 * 60 * 1000 // 5 minutes const CACHE_MAX = 100 class EmbeddingCache { constructor() { this.cache = new Map() } get(text) { const cached = this.cache.get(text) if (cached && Date.now() - cached.timestamp < CACHE_TTL) { return cached.vector } return null } set(text, vector) { if (this.cache.size >= CACHE_MAX) { // Evict oldest const oldest = [...this.cache.entries()] .sort((a, b) => a[1].timestamp - b[1].timestamp)[0] if (oldest) this.cache.delete(oldest[0]) } this.cache.set(text, { vector, timestamp: Date.now() }) } get size() { return this.cache.size } } it('should return cached embedding within TTL', () => { const cache = new EmbeddingCache() cache.set('test query', [0.1, 0.2, 0.3]) const result = cache.get('test query') expect(result).toEqual([0.1, 0.2, 0.3]) }) it('should return null for expired cache', () => { const cache = new EmbeddingCache() cache.cache.set('test', { vector: [0.1], timestamp: Date.now() - (10 * 60 * 1000) // 10 minutes ago }) const result = cache.get('test') expect(result).toBeNull() }) it('should evict oldest when at capacity', () => { const cache = new EmbeddingCache() // Fill cache for (let i = 0; i < 100; i++) { cache.set(`query${i}`, [i]) } expect(cache.size).toBe(100) // Add one more cache.set('newQuery', [999]) expect(cache.size).toBe(100) // Still at max expect(cache.get('newQuery')).toEqual([999]) }) }) })

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/sfls1397/Apple-Tools-MCP'

If you have feedback or need assistance with the MCP directory API, please join our Discord server