Skip to main content
Glama
iceener

Google Calendar MCP Server

by iceener
google-calendar.ts16.1 kB
/** * Google Calendar API client. */ import { logger } from '../utils/logger.js'; const GOOGLE_CALENDAR_API_BASE = 'https://www.googleapis.com/calendar/v3'; // ============================================================================ // Types // ============================================================================ export interface CalendarListItem { id: string; summary: string; description?: string; primary?: boolean; backgroundColor?: string; foregroundColor?: string; accessRole: 'owner' | 'writer' | 'reader' | 'freeBusyReader'; timeZone?: string; } export interface EventDateTime { dateTime?: string; date?: string; timeZone?: string; } export interface EventAttendee { email: string; displayName?: string; responseStatus?: 'needsAction' | 'declined' | 'tentative' | 'accepted'; optional?: boolean; organizer?: boolean; self?: boolean; } export interface EventReminder { method: 'popup' | 'email'; minutes: number; } export interface ConferenceData { createRequest?: { requestId: string; conferenceSolutionKey?: { type: string }; status?: { statusCode: string }; }; entryPoints?: Array<{ entryPointType: string; uri: string; label?: string; }>; conferenceSolution?: { key: { type: string }; name: string; iconUri?: string; }; conferenceId?: string; } export interface CalendarEvent { id: string; summary?: string; description?: string; start?: EventDateTime; end?: EventDateTime; location?: string; status?: 'confirmed' | 'tentative' | 'cancelled'; htmlLink?: string; hangoutLink?: string; conferenceData?: ConferenceData; attendees?: EventAttendee[]; organizer?: { email: string; displayName?: string; self?: boolean }; creator?: { email: string; displayName?: string }; eventType?: | 'default' | 'birthday' | 'focusTime' | 'fromGmail' | 'outOfOffice' | 'workingLocation'; visibility?: 'default' | 'public' | 'private' | 'confidential'; colorId?: string; recurringEventId?: string; recurrence?: string[]; reminders?: { useDefault: boolean; overrides?: EventReminder[]; }; created?: string; updated?: string; } export interface FreeBusyResponse { timeMin: string; timeMax: string; calendars: Record< string, { busy: Array<{ start: string; end: string }>; errors?: Array<{ domain: string; reason: string }>; } >; } // ============================================================================ // Request Parameters // ============================================================================ export interface ListEventsParams { calendarId?: string; timeMin?: string; timeMax?: string; maxResults?: number; singleEvents?: boolean; orderBy?: 'startTime' | 'updated'; q?: string; eventTypes?: string[]; pageToken?: string; showDeleted?: boolean; } export interface CreateEventParams { calendarId?: string; summary: string; description?: string; start: EventDateTime; end: EventDateTime; location?: string; attendees?: string[]; addGoogleMeet?: boolean; recurrence?: string[]; reminders?: { useDefault: boolean; overrides?: EventReminder[] }; visibility?: 'default' | 'public' | 'private' | 'confidential'; colorId?: string; sendUpdates?: 'all' | 'externalOnly' | 'none'; } export interface QuickAddParams { calendarId?: string; text: string; sendUpdates?: 'all' | 'externalOnly' | 'none'; } export interface UpdateEventParams { calendarId?: string; eventId: string; summary?: string; description?: string; start?: EventDateTime; end?: EventDateTime; location?: string; attendees?: string[]; addGoogleMeet?: boolean; recurrence?: string[]; reminders?: { useDefault: boolean; overrides?: EventReminder[] }; visibility?: 'default' | 'public' | 'private' | 'confidential'; colorId?: string; sendUpdates?: 'all' | 'externalOnly' | 'none'; } export interface MoveEventParams { calendarId: string; eventId: string; destinationCalendarId: string; sendUpdates?: 'all' | 'externalOnly' | 'none'; } export interface DeleteEventParams { calendarId?: string; eventId: string; sendUpdates?: 'all' | 'externalOnly' | 'none'; } export interface RespondToEventParams { calendarId?: string; eventId: string; response: 'accepted' | 'declined' | 'tentative'; sendUpdates?: 'all' | 'externalOnly' | 'none'; } export interface FreeBusyParams { timeMin: string; timeMax: string; calendarIds?: string[]; timeZone?: string; } // ============================================================================ // Client // ============================================================================ export class GoogleCalendarClient { private accessToken: string; constructor(accessToken: string) { this.accessToken = accessToken; } private async request<T>(path: string, options: RequestInit = {}): Promise<T> { const url = `${GOOGLE_CALENDAR_API_BASE}${path}`; const headers = { Authorization: `Bearer ${this.accessToken}`, 'Content-Type': 'application/json', ...options.headers, }; try { const response = await fetch(url, { ...options, headers }); if (!response.ok) { let errorMessage = `Google Calendar API error: ${response.status} ${response.statusText}`; try { const errorData = (await response.json()) as { error?: { message?: string } }; if (errorData.error?.message) { errorMessage += ` - ${errorData.error.message}`; } } catch { // Ignore JSON parse error } throw new Error(errorMessage); } // Handle 204 No Content if (response.status === 204) { return {} as T; } return (await response.json()) as T; } catch (error) { logger.error('google-calendar-client', { message: 'Request failed', url, error: (error as Error).message, }); throw error; } } // -------------------------------------------------------------------------- // Calendars // -------------------------------------------------------------------------- async listCalendars(): Promise<{ items: CalendarListItem[] }> { return this.request('/users/me/calendarList'); } // -------------------------------------------------------------------------- // Events - Get Single // -------------------------------------------------------------------------- async getEvent(calendarId: string, eventId: string): Promise<CalendarEvent> { const path = `/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(eventId)}`; return this.request(path); } // -------------------------------------------------------------------------- // Events - List/Search // -------------------------------------------------------------------------- /** * Validates that timestamp has a timezone suffix (required by Google Calendar API). * Throws if timezone is missing — caller must provide RFC3339 format. */ private requireTimezone(timestamp: string): string { // Valid: ends with Z or has +/- offset like +01:00 or -07:00 if (/Z$|[+-]\d{2}:\d{2}$/.test(timestamp)) { return timestamp; } throw new Error( `Invalid timestamp format: "${timestamp}". Must be RFC3339 with timezone (e.g., 2025-12-06T19:00:00Z or 2025-12-06T19:00:00+01:00)`, ); } async listEvents( params: ListEventsParams, ): Promise<{ items: CalendarEvent[]; nextPageToken?: string }> { const calendarId = params.calendarId || 'primary'; const queryParams = new URLSearchParams(); if (params.timeMin) queryParams.set('timeMin', this.requireTimezone(params.timeMin)); if (params.timeMax) queryParams.set('timeMax', this.requireTimezone(params.timeMax)); if (params.maxResults) queryParams.set('maxResults', String(params.maxResults)); if (params.singleEvents !== undefined) queryParams.set('singleEvents', String(params.singleEvents)); if (params.orderBy) queryParams.set('orderBy', params.orderBy); if (params.q) queryParams.set('q', params.q); if (params.pageToken) queryParams.set('pageToken', params.pageToken); if (params.showDeleted) queryParams.set('showDeleted', String(params.showDeleted)); // eventTypes can be repeated if (params.eventTypes && params.eventTypes.length > 0) { for (const et of params.eventTypes) { queryParams.append('eventTypes', et); } } const query = queryParams.toString(); const path = `/calendars/${encodeURIComponent(calendarId)}/events${query ? `?${query}` : ''}`; return this.request(path); } // -------------------------------------------------------------------------- // Events - Create // -------------------------------------------------------------------------- async createEvent(params: CreateEventParams): Promise<CalendarEvent> { const calendarId = params.calendarId || 'primary'; const queryParams = new URLSearchParams(); if (params.sendUpdates) queryParams.set('sendUpdates', params.sendUpdates); if (params.addGoogleMeet) queryParams.set('conferenceDataVersion', '1'); const body: Record<string, unknown> = { summary: params.summary, start: params.start, end: params.end, }; if (params.description) body.description = params.description; if (params.location) body.location = params.location; if (params.visibility) body.visibility = params.visibility; if (params.colorId) body.colorId = params.colorId; if (params.recurrence) body.recurrence = params.recurrence; if (params.reminders) body.reminders = params.reminders; if (params.attendees && params.attendees.length > 0) { body.attendees = params.attendees.map((email) => ({ email })); } if (params.addGoogleMeet) { body.conferenceData = { createRequest: { requestId: `meet-${Date.now()}-${Math.random().toString(36).slice(2)}`, conferenceSolutionKey: { type: 'hangoutsMeet' }, }, }; } const query = queryParams.toString(); const path = `/calendars/${encodeURIComponent(calendarId)}/events${query ? `?${query}` : ''}`; return this.request(path, { method: 'POST', body: JSON.stringify(body), }); } async quickAdd(params: QuickAddParams): Promise<CalendarEvent> { const calendarId = params.calendarId || 'primary'; const queryParams = new URLSearchParams(); queryParams.set('text', params.text); if (params.sendUpdates) queryParams.set('sendUpdates', params.sendUpdates); const path = `/calendars/${encodeURIComponent(calendarId)}/events/quickAdd?${queryParams.toString()}`; return this.request(path, { method: 'POST' }); } // -------------------------------------------------------------------------- // Events - Update // -------------------------------------------------------------------------- async updateEvent(params: UpdateEventParams): Promise<CalendarEvent> { const calendarId = params.calendarId || 'primary'; const queryParams = new URLSearchParams(); if (params.sendUpdates) queryParams.set('sendUpdates', params.sendUpdates); if (params.addGoogleMeet) queryParams.set('conferenceDataVersion', '1'); const body: Record<string, unknown> = {}; if (params.summary !== undefined) body.summary = params.summary; if (params.description !== undefined) body.description = params.description; if (params.start !== undefined) body.start = params.start; if (params.end !== undefined) body.end = params.end; if (params.location !== undefined) body.location = params.location; if (params.visibility !== undefined) body.visibility = params.visibility; if (params.colorId !== undefined) body.colorId = params.colorId; if (params.recurrence !== undefined) body.recurrence = params.recurrence; if (params.reminders !== undefined) body.reminders = params.reminders; if (params.attendees !== undefined) { body.attendees = params.attendees.map((email) => ({ email })); } if (params.addGoogleMeet) { body.conferenceData = { createRequest: { requestId: `meet-${Date.now()}-${Math.random().toString(36).slice(2)}`, conferenceSolutionKey: { type: 'hangoutsMeet' }, }, }; } const query = queryParams.toString(); const path = `/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(params.eventId)}${query ? `?${query}` : ''}`; return this.request(path, { method: 'PATCH', body: JSON.stringify(body), }); } async moveEvent(params: MoveEventParams): Promise<CalendarEvent> { const queryParams = new URLSearchParams(); queryParams.set('destination', params.destinationCalendarId); if (params.sendUpdates) queryParams.set('sendUpdates', params.sendUpdates); const path = `/calendars/${encodeURIComponent(params.calendarId)}/events/${encodeURIComponent(params.eventId)}/move?${queryParams.toString()}`; return this.request(path, { method: 'POST' }); } // -------------------------------------------------------------------------- // Events - Delete // -------------------------------------------------------------------------- async deleteEvent(params: DeleteEventParams): Promise<void> { const calendarId = params.calendarId || 'primary'; const queryParams = new URLSearchParams(); if (params.sendUpdates) queryParams.set('sendUpdates', params.sendUpdates); const query = queryParams.toString(); const path = `/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(params.eventId)}${query ? `?${query}` : ''}`; await this.request(path, { method: 'DELETE' }); } // -------------------------------------------------------------------------- // Events - Respond (Accept/Decline/Tentative) // -------------------------------------------------------------------------- async respondToEvent(params: RespondToEventParams): Promise<CalendarEvent> { const calendarId = params.calendarId || 'primary'; // First, get the current event to find our attendee entry const event = await this.getEvent(calendarId, params.eventId); if (!event.attendees || event.attendees.length === 0) { throw new Error( 'This event has no attendees. You can only respond to events you were invited to.', ); } // Find the self attendee const selfAttendee = event.attendees.find((a) => a.self); if (!selfAttendee) { throw new Error( 'You are not an attendee of this event. Cannot update response status.', ); } // Update the attendee's response status const updatedAttendees = event.attendees.map((a) => { if (a.self) { return { ...a, responseStatus: params.response }; } return a; }); // PATCH the event with updated attendees const queryParams = new URLSearchParams(); if (params.sendUpdates) queryParams.set('sendUpdates', params.sendUpdates); const query = queryParams.toString(); const path = `/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(params.eventId)}${query ? `?${query}` : ''}`; return this.request(path, { method: 'PATCH', body: JSON.stringify({ attendees: updatedAttendees }), }); } // -------------------------------------------------------------------------- // Free/Busy // -------------------------------------------------------------------------- async getFreeBusy(params: FreeBusyParams): Promise<FreeBusyResponse> { const calendarIds = params.calendarIds || ['primary']; const body = { timeMin: this.requireTimezone(params.timeMin), timeMax: this.requireTimezone(params.timeMax), timeZone: params.timeZone, items: calendarIds.map((id) => ({ id })), }; return this.request('/freeBusy', { method: 'POST', body: JSON.stringify(body), }); } }

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/iceener/google-calendar-streamable-mcp-server'

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