Skip to main content
Glama

Google Calendar MCP

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { zodToJsonSchema } from "zod-to-json-schema"; import { BaseToolHandler } from "../handlers/core/BaseToolHandler.js"; // Import all handlers import { ListCalendarsHandler } from "../handlers/core/ListCalendarsHandler.js"; import { ListEventsHandler } from "../handlers/core/ListEventsHandler.js"; import { SearchEventsHandler } from "../handlers/core/SearchEventsHandler.js"; import { ListColorsHandler } from "../handlers/core/ListColorsHandler.js"; import { CreateEventHandler } from "../handlers/core/CreateEventHandler.js"; import { UpdateEventHandler } from "../handlers/core/UpdateEventHandler.js"; import { DeleteEventHandler } from "../handlers/core/DeleteEventHandler.js"; import { FreeBusyEventHandler } from "../handlers/core/FreeBusyEventHandler.js"; import { GetCurrentTimeHandler } from "../handlers/core/GetCurrentTimeHandler.js"; // Define all tool schemas with TypeScript inference export const ToolSchemas = { 'list-calendars': z.object({}), 'list-events': z.object({ calendarId: z.string().describe( "ID of the calendar(s) to list events from. Accepts either a single calendar ID string or an array of calendar IDs (passed as JSON string like '[\"cal1\", \"cal2\"]')" ), timeMin: z.string() .refine((val) => { const withTimezone = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(Z|[+-]\d{2}:\d{2})$/.test(val); const withoutTimezone = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/.test(val); return withTimezone || withoutTimezone; }, "Must be ISO 8601 format: '2026-01-01T00:00:00'") .describe("Start time boundary. Preferred: '2024-01-01T00:00:00' (uses timeZone parameter or calendar timezone). Also accepts: '2024-01-01T00:00:00Z' or '2024-01-01T00:00:00-08:00'.") .optional(), timeMax: z.string() .refine((val) => { const withTimezone = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(Z|[+-]\d{2}:\d{2})$/.test(val); const withoutTimezone = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/.test(val); return withTimezone || withoutTimezone; }, "Must be ISO 8601 format: '2026-01-01T00:00:00'") .describe("End time boundary. Preferred: '2024-01-01T23:59:59' (uses timeZone parameter or calendar timezone). Also accepts: '2024-01-01T23:59:59Z' or '2024-01-01T23:59:59-08:00'.") .optional(), timeZone: z.string().optional().describe( "Timezone as IANA Time Zone Database name (e.g., America/Los_Angeles). Takes priority over calendar's default timezone. Only used for timezone-naive datetime strings." ) }), 'search-events': z.object({ calendarId: z.string().describe("ID of the calendar (use 'primary' for the main calendar)"), query: z.string().describe( "Free text search query (searches summary, description, location, attendees, etc.)" ), timeMin: z.string() .refine((val) => { const withTimezone = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(Z|[+-]\d{2}:\d{2})$/.test(val); const withoutTimezone = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/.test(val); return withTimezone || withoutTimezone; }, "Must be ISO 8601 format: '2026-01-01T00:00:00'") .describe("Start time boundary. Preferred: '2024-01-01T00:00:00' (uses timeZone parameter or calendar timezone). Also accepts: '2024-01-01T00:00:00Z' or '2024-01-01T00:00:00-08:00'."), timeMax: z.string() .refine((val) => { const withTimezone = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(Z|[+-]\d{2}:\d{2})$/.test(val); const withoutTimezone = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/.test(val); return withTimezone || withoutTimezone; }, "Must be ISO 8601 format: '2026-01-01T00:00:00'") .describe("End time boundary. Preferred: '2024-01-01T23:59:59' (uses timeZone parameter or calendar timezone). Also accepts: '2024-01-01T23:59:59Z' or '2024-01-01T23:59:59-08:00'."), timeZone: z.string().optional().describe( "Timezone as IANA Time Zone Database name (e.g., America/Los_Angeles). Takes priority over calendar's default timezone. Only used for timezone-naive datetime strings." ) }), 'list-colors': z.object({}), 'create-event': z.object({ calendarId: z.string().describe("ID of the calendar (use 'primary' for the main calendar)"), summary: z.string().describe("Title of the event"), description: z.string().optional().describe("Description/notes for the event"), start: z.string() .refine((val) => { const withTimezone = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(Z|[+-]\d{2}:\d{2})$/.test(val); const withoutTimezone = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/.test(val); return withTimezone || withoutTimezone; }, "Must be ISO 8601 format: '2026-01-01T00:00:00'") .describe("Event start time: '2024-01-01T10:00:00'"), end: z.string() .refine((val) => { const withTimezone = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(Z|[+-]\d{2}:\d{2})$/.test(val); const withoutTimezone = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/.test(val); return withTimezone || withoutTimezone; }, "Must be ISO 8601 format: '2026-01-01T00:00:00'") .describe("Event end time: '2024-01-01T11:00:00'"), timeZone: z.string().optional().describe( "Timezone as IANA Time Zone Database name (e.g., America/Los_Angeles). Takes priority over calendar's default timezone. Only used for timezone-naive datetime strings." ), location: z.string().optional().describe("Location of the event"), attendees: z.array(z.object({ email: z.string().email().describe("Email address of the attendee") })).optional().describe("List of attendee email addresses"), colorId: z.string().optional().describe( "Color ID for the event (use list-colors to see available IDs)" ), reminders: z.object({ useDefault: z.boolean().describe("Whether to use the default reminders"), overrides: z.array(z.object({ method: z.enum(["email", "popup"]).default("popup").describe("Reminder method"), minutes: z.number().describe("Minutes before the event to trigger the reminder") }).partial({ method: true })).optional().describe("Custom reminders") }).describe("Reminder settings for the event").optional(), recurrence: z.array(z.string()).optional().describe( "Recurrence rules in RFC5545 format (e.g., [\"RRULE:FREQ=WEEKLY;COUNT=5\"])" ) }), 'update-event': z.object({ calendarId: z.string().describe("ID of the calendar (use 'primary' for the main calendar)"), eventId: z.string().describe("ID of the event to update"), summary: z.string().optional().describe("Updated title of the event"), description: z.string().optional().describe("Updated description/notes"), start: z.string() .refine((val) => { const withTimezone = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(Z|[+-]\d{2}:\d{2})$/.test(val); const withoutTimezone = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/.test(val); return withTimezone || withoutTimezone; }, "Must be ISO 8601 format: '2026-01-01T00:00:00'") .describe("Updated start time: '2024-01-01T10:00:00'") .optional(), end: z.string() .refine((val) => { const withTimezone = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(Z|[+-]\d{2}:\d{2})$/.test(val); const withoutTimezone = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/.test(val); return withTimezone || withoutTimezone; }, "Must be ISO 8601 format: '2026-01-01T00:00:00'") .describe("Updated end time: '2024-01-01T11:00:00'") .optional(), timeZone: z.string().optional().describe("Updated timezone as IANA Time Zone Database name. If not provided, uses the calendar's default timezone."), location: z.string().optional().describe("Updated location"), attendees: z.array(z.object({ email: z.string().email().describe("Email address of the attendee") })).optional().describe("Updated attendee list"), colorId: z.string().optional().describe("Updated color ID"), reminders: z.object({ useDefault: z.boolean().describe("Whether to use the default reminders"), overrides: z.array(z.object({ method: z.enum(["email", "popup"]).default("popup").describe("Reminder method"), minutes: z.number().describe("Minutes before the event to trigger the reminder") }).partial({ method: true })).optional().describe("Custom reminders") }).describe("Reminder settings for the event").optional(), recurrence: z.array(z.string()).optional().describe("Updated recurrence rules"), sendUpdates: z.enum(["all", "externalOnly", "none"]).default("all").describe( "Whether to send update notifications" ), modificationScope: z.enum(["thisAndFollowing", "all", "thisEventOnly"]).optional().describe( "Scope for recurring event modifications" ), originalStartTime: z.string() .refine((val) => { const withTimezone = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(Z|[+-]\d{2}:\d{2})$/.test(val); const withoutTimezone = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/.test(val); return withTimezone || withoutTimezone; }, "Must be ISO 8601 format: '2026-01-01T00:00:00'") .describe("Original start time in the ISO 8601 format '2024-01-01T10:00:00'") .optional(), futureStartDate: z.string() .refine((val) => { const withTimezone = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(Z|[+-]\d{2}:\d{2})$/.test(val); const withoutTimezone = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/.test(val); return withTimezone || withoutTimezone; }, "Must be ISO 8601 format: '2026-01-01T00:00:00'") .describe("Start date for future instances in the ISO 8601 format '2024-01-01T10:00:00'") .optional() }).refine( (data) => { // Require originalStartTime when modificationScope is 'thisEventOnly' if (data.modificationScope === 'thisEventOnly' && !data.originalStartTime) { return false; } return true; }, { message: "originalStartTime is required when modificationScope is 'thisEventOnly'", path: ["originalStartTime"] } ).refine( (data) => { // Require futureStartDate when modificationScope is 'thisAndFollowing' if (data.modificationScope === 'thisAndFollowing' && !data.futureStartDate) { return false; } return true; }, { message: "futureStartDate is required when modificationScope is 'thisAndFollowing'", path: ["futureStartDate"] } ).refine( (data) => { // Ensure futureStartDate is in the future when provided if (data.futureStartDate) { const futureDate = new Date(data.futureStartDate); const now = new Date(); return futureDate > now; } return true; }, { message: "futureStartDate must be in the future", path: ["futureStartDate"] } ), 'delete-event': z.object({ calendarId: z.string().describe("ID of the calendar (use 'primary' for the main calendar)"), eventId: z.string().describe("ID of the event to delete"), sendUpdates: z.enum(["all", "externalOnly", "none"]).default("all").describe( "Whether to send cancellation notifications" ) }), 'get-freebusy': z.object({ calendars: z.array(z.object({ id: z.string().describe("ID of the calendar (use 'primary' for the main calendar)") })).describe( "List of calendars and/or groups to query for free/busy information" ), timeMin: z.string() .refine((val) => { const withTimezone = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(Z|[+-]\d{2}:\d{2})$/.test(val); const withoutTimezone = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/.test(val); return withTimezone || withoutTimezone; }, "Must be ISO 8601 format: '2026-01-01T00:00:00'") .describe("Start time boundary. Preferred: '2024-01-01T00:00:00' (uses timeZone parameter or calendar timezone). Also accepts: '2024-01-01T00:00:00Z' or '2024-01-01T00:00:00-08:00'."), timeMax: z.string() .refine((val) => { const withTimezone = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(Z|[+-]\d{2}:\d{2})$/.test(val); const withoutTimezone = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/.test(val); return withTimezone || withoutTimezone; }, "Must be ISO 8601 format: '2026-01-01T00:00:00'") .describe("End time boundary. Preferred: '2024-01-01T23:59:59' (uses timeZone parameter or calendar timezone). Also accepts: '2024-01-01T23:59:59Z' or '2024-01-01T23:59:59-08:00'."), timeZone: z.string().optional().describe("Timezone for the query"), groupExpansionMax: z.number().int().max(100).optional().describe( "Maximum number of calendars to expand per group (max 100)" ), calendarExpansionMax: z.number().int().max(50).optional().describe( "Maximum number of calendars to expand (max 50)" ) }), 'get-current-time': z.object({ timeZone: z.string().optional().describe( "Optional IANA timezone (e.g., 'America/Los_Angeles', 'Europe/London', 'UTC'). If not provided, returns UTC time and system timezone for reference." ) }) } as const; // Generate TypeScript types from schemas export type ToolInputs = { [K in keyof typeof ToolSchemas]: z.infer<typeof ToolSchemas[K]> }; // Export individual types for convenience export type ListCalendarsInput = ToolInputs['list-calendars']; export type ListEventsInput = ToolInputs['list-events']; export type SearchEventsInput = ToolInputs['search-events']; export type ListColorsInput = ToolInputs['list-colors']; export type CreateEventInput = ToolInputs['create-event']; export type UpdateEventInput = ToolInputs['update-event']; export type DeleteEventInput = ToolInputs['delete-event']; export type GetFreeBusyInput = ToolInputs['get-freebusy']; export type GetCurrentTimeInput = ToolInputs['get-current-time']; interface ToolDefinition { name: keyof typeof ToolSchemas; description: string; schema: z.ZodType<any>; handler: new () => BaseToolHandler; handlerFunction?: (args: any) => Promise<any>; } export class ToolRegistry { private static extractSchemaShape(schema: z.ZodType<any>): any { const schemaAny = schema as any; // Handle ZodEffects (schemas with .refine()) if (schemaAny._def && schemaAny._def.typeName === 'ZodEffects') { return this.extractSchemaShape(schemaAny._def.schema); } // Handle regular ZodObject if ('shape' in schemaAny) { return schemaAny.shape; } // Handle other nested structures if (schemaAny._def && schemaAny._def.schema) { return this.extractSchemaShape(schemaAny._def.schema); } // Fallback to the original approach return schemaAny._def?.schema?.shape || schemaAny.shape; } private static tools: ToolDefinition[] = [ { name: "list-calendars", description: "List all available calendars", schema: ToolSchemas['list-calendars'], handler: ListCalendarsHandler }, { name: "list-events", description: "List events from one or more calendars.", schema: ToolSchemas['list-events'], handler: ListEventsHandler, handlerFunction: async (args: ListEventsInput & { calendarId: string | string[] }) => { // Validate and preprocess calendarId input for multi-calendar support let processedCalendarId: string | string[] = args.calendarId; // Handle case where calendarId is passed as a JSON string if (typeof args.calendarId === 'string' && args.calendarId.trim().startsWith('[') && args.calendarId.trim().endsWith(']')) { try { const parsed = JSON.parse(args.calendarId); if (Array.isArray(parsed) && parsed.every(id => typeof id === 'string' && id.length > 0)) { if (parsed.length === 0) { throw new Error("At least one calendar ID is required"); } if (parsed.length > 50) { throw new Error("Maximum 50 calendars allowed per request"); } if (new Set(parsed).size !== parsed.length) { throw new Error("Duplicate calendar IDs are not allowed"); } processedCalendarId = parsed; } else { throw new Error('JSON string must contain an array of non-empty strings'); } } catch (error) { throw new Error( `Invalid JSON format for calendarId: ${error instanceof Error ? error.message : 'Unknown parsing error'}` ); } } // Additional validation for arrays if (Array.isArray(processedCalendarId)) { if (processedCalendarId.length === 0) { throw new Error("At least one calendar ID is required"); } if (processedCalendarId.length > 50) { throw new Error("Maximum 50 calendars allowed per request"); } if (!processedCalendarId.every(id => typeof id === 'string' && id.length > 0)) { throw new Error("All calendar IDs must be non-empty strings"); } if (new Set(processedCalendarId).size !== processedCalendarId.length) { throw new Error("Duplicate calendar IDs are not allowed"); } } return { calendarId: processedCalendarId, timeMin: args.timeMin, timeMax: args.timeMax }; } }, { name: "search-events", description: "Search for events in a calendar by text query.", schema: ToolSchemas['search-events'], handler: SearchEventsHandler }, { name: "list-colors", description: "List available color IDs and their meanings for calendar events", schema: ToolSchemas['list-colors'], handler: ListColorsHandler }, { name: "create-event", description: "Create a new calendar event.", schema: ToolSchemas['create-event'], handler: CreateEventHandler }, { name: "update-event", description: "Update an existing calendar event with recurring event modification scope support.", schema: ToolSchemas['update-event'], handler: UpdateEventHandler }, { name: "delete-event", description: "Delete a calendar event.", schema: ToolSchemas['delete-event'], handler: DeleteEventHandler }, { name: "get-freebusy", description: "Query free/busy information for calendars. Note: Time range is limited to a maximum of 3 months between timeMin and timeMax.", schema: ToolSchemas['get-freebusy'], handler: FreeBusyEventHandler }, { name: "get-current-time", description: "Get current system time and timezone information.", schema: ToolSchemas['get-current-time'], handler: GetCurrentTimeHandler } ]; static getToolsWithSchemas() { return this.tools.map(tool => { const jsonSchema = zodToJsonSchema(tool.schema); return { name: tool.name, description: tool.description, inputSchema: jsonSchema }; }); } static async registerAll( server: McpServer, executeWithHandler: ( handler: any, args: any ) => Promise<{ content: Array<{ type: "text"; text: string }> }> ) { for (const tool of this.tools) { // Use the existing registerTool method which handles schema conversion properly server.registerTool( tool.name, { description: tool.description, inputSchema: this.extractSchemaShape(tool.schema) }, async (args: any) => { // Validate input using our Zod schema const validatedArgs = tool.schema.parse(args); // Apply any custom handler function preprocessing const processedArgs = tool.handlerFunction ? await tool.handlerFunction(validatedArgs) : validatedArgs; // Create handler instance and execute const handler = new tool.handler(); return executeWithHandler(handler, processedArgs); } ); } } }

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