Skip to main content
Glama

Google Calendar MCP

validators.test.ts14 kB
import { describe, it, expect } from 'vitest'; import { ToolSchemas } from '../../../tools/registry.js'; // Use the unified schemas from registry const UpdateEventArgumentsSchema = ToolSchemas['update-event']; const ListEventsArgumentsSchema = ToolSchemas['list-events']; // Helper to generate a future date string in timezone-naive format function getFutureDateString(daysFromNow: number = 365): string { const futureDate = new Date(); futureDate.setDate(futureDate.getDate() + daysFromNow); // Format as timezone-naive ISO string (no timezone suffix) return futureDate.toISOString().split('.')[0]; } describe('UpdateEventArgumentsSchema with Recurring Event Support', () => { describe('Basic Validation', () => { it('should validate basic required fields', () => { const validArgs = { calendarId: 'primary', eventId: 'event123', timeZone: 'America/Los_Angeles' }; const result = UpdateEventArgumentsSchema.parse(validArgs); expect(result.modificationScope).toBeUndefined(); // optional with no default expect(result.calendarId).toBe('primary'); expect(result.eventId).toBe('event123'); expect(result.timeZone).toBe('America/Los_Angeles'); }); it('should reject missing required fields', () => { const invalidArgs = { calendarId: 'primary', // missing eventId and timeZone }; expect(() => UpdateEventArgumentsSchema.parse(invalidArgs)).toThrow(); }); it('should validate optional fields when provided', () => { const validArgs = { calendarId: 'primary', eventId: 'event123', timeZone: 'America/Los_Angeles', summary: 'Updated Meeting', description: 'Updated description', location: 'New Location', colorId: '9', start: '2024-06-15T10:00:00', end: '2024-06-15T11:00:00' }; const result = UpdateEventArgumentsSchema.parse(validArgs); expect(result.summary).toBe('Updated Meeting'); expect(result.description).toBe('Updated description'); expect(result.location).toBe('New Location'); expect(result.colorId).toBe('9'); }); }); describe('Modification Scope Validation', () => { it('should leave modificationScope undefined when not provided', () => { const args = { calendarId: 'primary', eventId: 'event123', timeZone: 'America/Los_Angeles' }; const result = UpdateEventArgumentsSchema.parse(args); expect(result.modificationScope).toBeUndefined(); }); it('should accept valid modificationScope values', () => { const validScopes = ['thisEventOnly', 'all', 'thisAndFollowing'] as const; validScopes.forEach(scope => { const args: any = { calendarId: 'primary', eventId: 'event123', timeZone: 'America/Los_Angeles', modificationScope: scope }; // Add required fields for each scope if (scope === 'thisEventOnly') { args.originalStartTime = '2024-06-15T10:00:00'; } else if (scope === 'thisAndFollowing') { args.futureStartDate = getFutureDateString(90); // 90 days from now } const result = UpdateEventArgumentsSchema.parse(args); expect(result.modificationScope).toBe(scope); }); }); it('should reject invalid modificationScope values', () => { const args = { calendarId: 'primary', eventId: 'event123', timeZone: 'America/Los_Angeles', modificationScope: 'invalid' }; expect(() => UpdateEventArgumentsSchema.parse(args)).toThrow(); }); }); describe('Single Instance Scope Validation', () => { it('should require originalStartTime when modificationScope is "thisEventOnly"', () => { const args = { calendarId: 'primary', eventId: 'event123', timeZone: 'America/Los_Angeles', modificationScope: 'thisEventOnly' // missing originalStartTime }; expect(() => UpdateEventArgumentsSchema.parse(args)).toThrow( /originalStartTime is required when modificationScope is 'thisEventOnly'/ ); }); it('should accept valid originalStartTime for thisEventOnly scope', () => { const args = { calendarId: 'primary', eventId: 'event123', timeZone: 'America/Los_Angeles', modificationScope: 'thisEventOnly', originalStartTime: '2024-06-15T10:00:00' }; const result = UpdateEventArgumentsSchema.parse(args); expect(result.modificationScope).toBe('thisEventOnly'); expect(result.originalStartTime).toBe('2024-06-15T10:00:00'); }); it('should reject invalid originalStartTime format', () => { const args = { calendarId: 'primary', eventId: 'event123', timeZone: 'America/Los_Angeles', modificationScope: 'thisEventOnly', originalStartTime: '2024-06-15 10:00:00' // invalid format }; expect(() => UpdateEventArgumentsSchema.parse(args)).toThrow(); }); it('should accept originalStartTime without timezone designator', () => { const args = { calendarId: 'primary', eventId: 'event123', timeZone: 'America/Los_Angeles', modificationScope: 'thisEventOnly', originalStartTime: '2024-06-15T10:00:00' // timezone-naive format (expected) }; expect(() => UpdateEventArgumentsSchema.parse(args)).not.toThrow(); }); }); describe('Future Instances Scope Validation', () => { it('should require futureStartDate when modificationScope is "thisAndFollowing"', () => { const args = { calendarId: 'primary', eventId: 'event123', timeZone: 'America/Los_Angeles', modificationScope: 'thisAndFollowing' // missing futureStartDate }; expect(() => UpdateEventArgumentsSchema.parse(args)).toThrow( /futureStartDate is required when modificationScope is 'thisAndFollowing'/ ); }); it('should accept valid futureStartDate for thisAndFollowing scope', () => { const futureDateString = getFutureDateString(30); // 30 days from now const args = { calendarId: 'primary', eventId: 'event123', timeZone: 'America/Los_Angeles', modificationScope: 'thisAndFollowing', futureStartDate: futureDateString }; const result = UpdateEventArgumentsSchema.parse(args); expect(result.modificationScope).toBe('thisAndFollowing'); expect(result.futureStartDate).toBe(futureDateString); }); it('should reject futureStartDate in the past', () => { const pastDate = new Date(); pastDate.setFullYear(pastDate.getFullYear() - 1); // Format as ISO string without milliseconds const pastDateString = pastDate.toISOString().split('.')[0] + 'Z'; const args = { calendarId: 'primary', eventId: 'event123', timeZone: 'America/Los_Angeles', modificationScope: 'thisAndFollowing', futureStartDate: pastDateString }; expect(() => UpdateEventArgumentsSchema.parse(args)).toThrow( /futureStartDate must be in the future/ ); }); it('should reject invalid futureStartDate format', () => { const args = { calendarId: 'primary', eventId: 'event123', timeZone: 'America/Los_Angeles', modificationScope: 'thisAndFollowing', futureStartDate: '2024-12-31 10:00:00' // invalid format }; expect(() => UpdateEventArgumentsSchema.parse(args)).toThrow(); }); }); describe('Datetime Format Validation', () => { const validDatetimes = [ '2024-06-15T10:00:00', // timezone-naive (preferred) '2024-12-31T23:59:59', // timezone-naive (preferred) '2024-01-01T00:00:00', // timezone-naive (preferred) '2024-06-15T10:00:00Z', // timezone-aware (accepted) '2024-06-15T10:00:00-07:00', // timezone-aware (accepted) '2024-06-15T10:00:00+05:30' // timezone-aware (accepted) ]; const invalidDatetimes = [ '2024-06-15 10:00:00', // space instead of T '24-06-15T10:00:00', // short year '2024-6-15T10:00:00', // single digit month '2024-06-15T10:00' // missing seconds ]; validDatetimes.forEach(datetime => { it(`should accept valid datetime format: ${datetime}`, () => { const args = { calendarId: 'primary', eventId: 'event123', timeZone: 'America/Los_Angeles', start: datetime, end: datetime }; expect(() => UpdateEventArgumentsSchema.parse(args)).not.toThrow(); }); }); invalidDatetimes.forEach(datetime => { it(`should reject invalid datetime format: ${datetime}`, () => { const args = { calendarId: 'primary', eventId: 'event123', timeZone: 'America/Los_Angeles', start: datetime }; expect(() => UpdateEventArgumentsSchema.parse(args)).toThrow(); }); }); }); describe('Complex Scenarios', () => { it('should validate complete update with all fields', () => { const args = { calendarId: 'primary', eventId: 'event123', timeZone: 'America/Los_Angeles', modificationScope: 'thisAndFollowing', futureStartDate: getFutureDateString(60), // 60 days from now summary: 'Updated Meeting', description: 'Updated description', location: 'New Conference Room', start: '2024-06-15T10:00:00', end: '2024-06-15T11:00:00', colorId: '9', attendees: [ { email: 'user1@example.com' }, { email: 'user2@example.com' } ], reminders: { useDefault: false, overrides: [ { method: 'email', minutes: 1440 }, { method: 'popup', minutes: 10 } ] }, recurrence: ['RRULE:FREQ=WEEKLY;BYDAY=MO'] }; const result = UpdateEventArgumentsSchema.parse(args); expect(result).toMatchObject(args); }); it('should not require conditional fields for "all" scope', () => { const args = { calendarId: 'primary', eventId: 'event123', timeZone: 'America/Los_Angeles', modificationScope: 'all', summary: 'Updated Meeting' // no originalStartTime or futureStartDate required }; expect(() => UpdateEventArgumentsSchema.parse(args)).not.toThrow(); }); it('should allow optional conditional fields when not required', () => { const args = { calendarId: 'primary', eventId: 'event123', timeZone: 'America/Los_Angeles', modificationScope: 'all', originalStartTime: '2024-06-15T10:00:00', // optional for 'all' scope summary: 'Updated Meeting' }; const result = UpdateEventArgumentsSchema.parse(args); expect(result.originalStartTime).toBe('2024-06-15T10:00:00'); }); }); describe('Backward Compatibility', () => { it('should maintain compatibility with existing update calls', () => { // Existing call format without new parameters const legacyArgs = { calendarId: 'primary', eventId: 'event123', timeZone: 'America/Los_Angeles', summary: 'Updated Meeting', location: 'Conference Room A' }; const result = UpdateEventArgumentsSchema.parse(legacyArgs); expect(result.modificationScope).toBeUndefined(); // optional with no default expect(result.summary).toBe('Updated Meeting'); expect(result.location).toBe('Conference Room A'); }); }); }); describe('ListEventsArgumentsSchema JSON String Handling', () => { it('should parse JSON string calendarId into array', () => { const input = { calendarId: '["primary", "secondary@gmail.com"]', timeMin: '2024-01-01T00:00:00Z', timeMax: '2024-01-02T00:00:00Z' }; const result = ListEventsArgumentsSchema.parse(input); // The new schema keeps JSON strings as strings (they are parsed in the handler) expect(result.calendarId).toBe('["primary", "secondary@gmail.com"]'); }); it('should handle regular string calendarId', () => { const input = { calendarId: 'primary', timeMin: '2024-01-01T00:00:00Z', timeMax: '2024-01-02T00:00:00Z' }; const result = ListEventsArgumentsSchema.parse(input); expect(result.calendarId).toBe('primary'); }); it('should handle regular array calendarId', () => { // Arrays are now supported via preprocessing const input = { calendarId: ['primary', 'secondary@gmail.com'], timeMin: '2024-01-01T00:00:00Z', timeMax: '2024-01-02T00:00:00Z' }; // Arrays are now kept as arrays (not transformed to JSON strings) const result = ListEventsArgumentsSchema.parse(input); expect(result.calendarId).toEqual(['primary', 'secondary@gmail.com']); }); it('should reject invalid JSON string', () => { // Invalid JSON strings are accepted by the schema but will fail in the handler const input = { calendarId: '["primary", invalid]', timeMin: '2024-01-01T00:00:00Z', timeMax: '2024-01-02T00:00:00Z' }; // The schema accepts any string - validation happens in the handler const result = ListEventsArgumentsSchema.parse(input); expect(result.calendarId).toBe('["primary", invalid]'); }); it('should reject JSON string with non-string elements', () => { // Schema accepts any string - validation happens in the handler const input = { calendarId: '["primary", 123]', timeMin: '2024-01-01T00:00:00Z', timeMax: '2024-01-02T00:00:00Z' }; // The schema accepts any string - validation happens in the handler const result = ListEventsArgumentsSchema.parse(input); expect(result.calendarId).toBe('["primary", 123]'); }); });

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/nspady/google-calendar-mcp'

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