Google Calendar AutoAuth MCP Server

#!/usr/bin/env node import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; import { google } from 'googleapis'; import { z } from "zod"; import { zodToJsonSchema } from "zod-to-json-schema"; import { OAuth2Client } from 'google-auth-library'; import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; import http from 'http'; import open from 'open'; import os from 'os'; import { formatDateTime, parseDateTime } from "./utils.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); // Configuration paths const CONFIG_DIR = path.join(os.homedir(), '.calendar-mcp'); const OAUTH_PATH = process.env.CALENDAR_OAUTH_PATH || path.join(CONFIG_DIR, 'gcp-oauth.keys.json'); const CREDENTIALS_PATH = process.env.CALENDAR_CREDENTIALS_PATH || path.join(CONFIG_DIR, 'credentials.json'); // Define time zone for calendar operations const DEFAULT_TIMEZONE = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; // OAuth2 configuration let oauth2Client: OAuth2Client; async function loadCredentials() { try { // Create config directory if it doesn't exist if (!fs.existsSync(CONFIG_DIR)) { fs.mkdirSync(CONFIG_DIR, { recursive: true }); } // Check for OAuth keys in current directory first, then in config directory const localOAuthPath = path.join(process.cwd(), 'gcp-oauth.keys.json'); let oauthPath = OAUTH_PATH; if (fs.existsSync(localOAuthPath)) { // If found in current directory, copy to config directory fs.copyFileSync(localOAuthPath, OAUTH_PATH); console.log('OAuth keys found in current directory, copied to global config.'); } if (!fs.existsSync(OAUTH_PATH)) { console.error('Error: OAuth keys file not found. Please place gcp-oauth.keys.json in current directory or', CONFIG_DIR); process.exit(1); } const keysContent = JSON.parse(fs.readFileSync(OAUTH_PATH, 'utf8')); const keys = keysContent.installed || keysContent.web; if (!keys) { console.error('Error: Invalid OAuth keys file format. File should contain either "installed" or "web" credentials.'); process.exit(1); } oauth2Client = new OAuth2Client( keys.client_id, keys.client_secret, 'http://localhost:3000/oauth2callback' ); if (fs.existsSync(CREDENTIALS_PATH)) { const credentials = JSON.parse(fs.readFileSync(CREDENTIALS_PATH, 'utf8')); oauth2Client.setCredentials(credentials); } } catch (error) { console.error('Error loading credentials:', error); process.exit(1); } } async function authenticate() { const server = http.createServer(); server.listen(3000); return new Promise<void>((resolve, reject) => { const authUrl = oauth2Client.generateAuthUrl({ access_type: 'offline', scope: ['https://www.googleapis.com/auth/calendar'], }); console.log('Please visit this URL to authenticate:', authUrl); open(authUrl); server.on('request', async (req, res) => { if (!req.url?.startsWith('/oauth2callback')) return; const url = new URL(req.url, 'http://localhost:3000'); const code = url.searchParams.get('code'); if (!code) { res.writeHead(400); res.end('No code provided'); reject(new Error('No code provided')); return; } try { const { tokens } = await oauth2Client.getToken(code); oauth2Client.setCredentials(tokens); fs.writeFileSync(CREDENTIALS_PATH, JSON.stringify(tokens)); res.writeHead(200); res.end('Authentication successful! You can close this window.'); server.close(); resolve(); } catch (error) { res.writeHead(500); res.end('Authentication failed'); reject(error); } }); }); } // Schema definitions for Google Calendar operations const CreateEventSchema = z.object({ summary: z.string().describe("Event title/summary"), description: z.string().optional().describe("Event description or details"), location: z.string().optional().describe("Event location"), start: z.string().describe("Start time in ISO format (YYYY-MM-DDTHH:MM:SS) or natural language like 'tomorrow at 2pm'"), end: z.string().describe("End time in ISO format (YYYY-MM-DDTHH:MM:SS) or natural language like '3 hours later'"), attendees: z.array(z.string()).optional().describe("List of attendee email addresses"), calendarId: z.string().optional().default("primary").describe("Calendar ID (default: primary)"), reminders: z.object({ useDefault: z.boolean().optional(), overrides: z.array(z.object({ method: z.enum(["email", "popup"]), minutes: z.number() })).optional() }).optional().describe("Reminder settings for the event") }); const GetEventSchema = z.object({ eventId: z.string().describe("ID of the event to retrieve"), calendarId: z.string().optional().default("primary").describe("Calendar ID (default: primary)") }); const UpdateEventSchema = z.object({ eventId: z.string().describe("ID of the event to update"), calendarId: z.string().optional().default("primary").describe("Calendar ID (default: primary)"), summary: z.string().optional().describe("Updated event title/summary"), description: z.string().optional().describe("Updated event description"), location: z.string().optional().describe("Updated event location"), start: z.string().optional().describe("Updated start time (ISO format or natural language)"), end: z.string().optional().describe("Updated end time (ISO format or natural language)"), attendees: z.array(z.string()).optional().describe("Updated list of attendee email addresses") }); const DeleteEventSchema = z.object({ eventId: z.string().describe("ID of the event to delete"), calendarId: z.string().optional().default("primary").describe("Calendar ID (default: primary)") }); const ListEventsSchema = z.object({ calendarId: z.string().optional().default("primary").describe("Calendar ID (default: primary)"), timeMin: z.string().optional().describe("Start time in ISO format or natural language (default: now)"), timeMax: z.string().optional().describe("End time in ISO format or natural language"), maxResults: z.number().optional().default(10).describe("Maximum number of events to return (default: 10)"), orderBy: z.enum(["startTime", "updated"]).optional().default("startTime").describe("Sort order (default: startTime)") }); const SearchEventsSchema = z.object({ query: z.string().describe("Search query (e.g., 'meeting', 'john')"), calendarId: z.string().optional().default("primary").describe("Calendar ID (default: primary)"), timeMin: z.string().optional().describe("Start time in ISO format or natural language (default: now)"), timeMax: z.string().optional().describe("End time in ISO format or natural language"), maxResults: z.number().optional().default(10).describe("Maximum number of events to return (default: 10)") }); const ListCalendarsSchema = z.object({}); // Main function async function main() { await loadCredentials(); if (process.argv[2] === 'auth') { await authenticate(); console.log('Authentication completed successfully'); process.exit(0); } // Initialize Calendar API const calendar = google.calendar({ version: 'v3', auth: oauth2Client }); // Server implementation const server = new Server({ name: "calendar", version: "1.0.0", capabilities: { tools: {}, }, }); // Tool handlers server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [ { name: "create_event", description: "Creates a new event in Google Calendar", inputSchema: zodToJsonSchema(CreateEventSchema), }, { name: "get_event", description: "Retrieves details of a specific calendar event", inputSchema: zodToJsonSchema(GetEventSchema), }, { name: "update_event", description: "Updates an existing calendar event", inputSchema: zodToJsonSchema(UpdateEventSchema), }, { name: "delete_event", description: "Deletes a calendar event", inputSchema: zodToJsonSchema(DeleteEventSchema), }, { name: "list_events", description: "Lists calendar events within specified time range", inputSchema: zodToJsonSchema(ListEventsSchema), }, { name: "search_events", description: "Searches for calendar events matching a query", inputSchema: zodToJsonSchema(SearchEventsSchema), }, { name: "list_calendars", description: "Lists all available calendars", inputSchema: zodToJsonSchema(ListCalendarsSchema), }, ], })); server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; try { switch (name) { case "create_event": { const validatedArgs = CreateEventSchema.parse(args); // Process start and end times const startDateTime = parseDateTime(validatedArgs.start); const endDateTime = parseDateTime(validatedArgs.end, startDateTime); // Prepare the event object const event: any = { summary: validatedArgs.summary, start: { dateTime: startDateTime.toISOString(), timeZone: DEFAULT_TIMEZONE, }, end: { dateTime: endDateTime.toISOString(), timeZone: DEFAULT_TIMEZONE, }, }; // Add optional fields if provided if (validatedArgs.description) event.description = validatedArgs.description; if (validatedArgs.location) event.location = validatedArgs.location; if (validatedArgs.reminders) event.reminders = validatedArgs.reminders; // Add attendees if provided if (validatedArgs.attendees && validatedArgs.attendees.length > 0) { event.attendees = validatedArgs.attendees.map(email => ({ email })); } // Insert the event const response = await calendar.events.insert({ calendarId: validatedArgs.calendarId, requestBody: event, }); return { content: [ { type: "text", text: `Event created successfully!\nEvent ID: ${response.data.id}\nTitle: ${response.data.summary}\nStart: ${formatDateTime(response.data.start)}\nEnd: ${formatDateTime(response.data.end)}\nLink: ${response.data.htmlLink}`, }, ], }; } case "get_event": { const validatedArgs = GetEventSchema.parse(args); const response = await calendar.events.get({ calendarId: validatedArgs.calendarId, eventId: validatedArgs.eventId, }); const event = response.data; // Format attendees if present let attendeesText = ''; if (event.attendees && event.attendees.length > 0) { attendeesText = '\nAttendees:\n' + event.attendees.map(a => { let status = ''; if (a.responseStatus) { status = ` (${a.responseStatus.replace('needsAction', 'pending')})`; } return `- ${a.email}${status}`; }).join('\n'); } // Format reminders if present let remindersText = ''; if (event.reminders && event.reminders.overrides && event.reminders.overrides.length > 0) { remindersText = '\nReminders:\n' + event.reminders.overrides.map(r => `- ${r.method} (${r.minutes} minutes before)` ).join('\n'); } // Handle the created date safely const createdDate = event.created ? new Date(event.created).toLocaleString() : 'Unknown'; return { content: [ { type: "text", text: `Event Details:\nID: ${event.id}\nTitle: ${event.summary}\nStart: ${formatDateTime(event.start)}\nEnd: ${formatDateTime(event.end)}\nLocation: ${event.location || 'Not specified'}\nDescription: ${event.description || 'No description'}\nCreated: ${createdDate}${attendeesText}${remindersText}\nLink: ${event.htmlLink}`, }, ], }; } case "update_event": { const validatedArgs = UpdateEventSchema.parse(args); // First, get the current event const currentEvent = await calendar.events.get({ calendarId: validatedArgs.calendarId, eventId: validatedArgs.eventId, }); // Prepare update object const updatedEvent: any = {}; // Add fields that are being updated if (validatedArgs.summary) updatedEvent.summary = validatedArgs.summary; if (validatedArgs.description) updatedEvent.description = validatedArgs.description; if (validatedArgs.location) updatedEvent.location = validatedArgs.location; // Handle start time updates if (validatedArgs.start) { const startDateTime = parseDateTime(validatedArgs.start); updatedEvent.start = { dateTime: startDateTime.toISOString(), timeZone: currentEvent.data.start?.timeZone || DEFAULT_TIMEZONE, }; } // Handle end time updates if (validatedArgs.end) { // If start time was updated, use it as reference for end time const referenceTime = validatedArgs.start ? parseDateTime(validatedArgs.start) : currentEvent.data.start?.dateTime ? new Date(currentEvent.data.start.dateTime) : new Date(); const endDateTime = parseDateTime(validatedArgs.end, referenceTime); updatedEvent.end = { dateTime: endDateTime.toISOString(), timeZone: currentEvent.data.end?.timeZone || DEFAULT_TIMEZONE, }; } // Handle attendees updates if (validatedArgs.attendees && validatedArgs.attendees.length > 0) { updatedEvent.attendees = validatedArgs.attendees.map(email => ({ email })); } // Perform the update const response = await calendar.events.patch({ calendarId: validatedArgs.calendarId, eventId: validatedArgs.eventId, requestBody: updatedEvent, }); return { content: [ { type: "text", text: `Event updated successfully!\nEvent ID: ${response.data.id}\nTitle: ${response.data.summary}\nStart: ${formatDateTime(response.data.start)}\nEnd: ${formatDateTime(response.data.end)}\nLink: ${response.data.htmlLink}`, }, ], }; } case "delete_event": { const validatedArgs = DeleteEventSchema.parse(args); await calendar.events.delete({ calendarId: validatedArgs.calendarId, eventId: validatedArgs.eventId, }); return { content: [ { type: "text", text: `Event with ID ${validatedArgs.eventId} has been successfully deleted from calendar ${validatedArgs.calendarId}.`, }, ], }; } case "list_events": { const validatedArgs = ListEventsSchema.parse(args); // Process time parameters const now = new Date(); // Default timeMin is now const timeMin = validatedArgs.timeMin ? parseDateTime(validatedArgs.timeMin) : now; // Default timeMax (if not provided) is 7 days from timeMin let timeMax = null; if (validatedArgs.timeMax) { timeMax = parseDateTime(validatedArgs.timeMax, timeMin); } else { timeMax = new Date(timeMin); timeMax.setDate(timeMax.getDate() + 7); } const response = await calendar.events.list({ calendarId: validatedArgs.calendarId, timeMin: timeMin.toISOString(), timeMax: timeMax.toISOString(), maxResults: validatedArgs.maxResults, orderBy: validatedArgs.orderBy, singleEvents: true, // Expand recurring events }); const events = response.data.items || []; if (events.length === 0) { return { content: [ { type: "text", text: `No events found in the specified time range (${timeMin.toLocaleDateString()} - ${timeMax.toLocaleDateString()}).`, }, ], }; } // Format the events for display const eventsDisplay = events.map((event, index) => { const start = formatDateTime(event.start); const end = formatDateTime(event.end); return `${index + 1}. ${event.summary} (ID: ${event.id})\n When: ${start} - ${end}\n Where: ${event.location || 'Not specified'}\n`; }).join('\n'); return { content: [ { type: "text", text: `Found ${events.length} events between ${timeMin.toLocaleDateString()} and ${timeMax.toLocaleDateString()}:\n\n${eventsDisplay}`, }, ], }; } case "search_events": { const validatedArgs = SearchEventsSchema.parse(args); // Process time parameters const now = new Date(); // Default timeMin is now const timeMin = validatedArgs.timeMin ? parseDateTime(validatedArgs.timeMin) : now; // Default timeMax (if not provided) is 30 days from timeMin let timeMax = null; if (validatedArgs.timeMax) { timeMax = parseDateTime(validatedArgs.timeMax, timeMin); } else { timeMax = new Date(timeMin); timeMax.setDate(timeMax.getDate() + 30); } // Perform the search - Google Calendar API doesn't have a direct search endpoint, // so we'll get events and filter them manually const response = await calendar.events.list({ calendarId: validatedArgs.calendarId, timeMin: timeMin.toISOString(), timeMax: timeMax.toISOString(), maxResults: 100, // Get more results for filtering singleEvents: true, }); let events = response.data.items || []; // Filter events based on the query string const query = validatedArgs.query.toLowerCase(); events = events.filter(event => { const summary = (event.summary || '').toLowerCase(); const description = (event.description || '').toLowerCase(); const location = (event.location || '').toLowerCase(); // Search in all text fields return summary.includes(query) || description.includes(query) || location.includes(query); }); // Limit the number of results events = events.slice(0, validatedArgs.maxResults); if (events.length === 0) { return { content: [ { type: "text", text: `No events found matching "${validatedArgs.query}" in the specified time range.`, }, ], }; } // Format the events for display const eventsDisplay = events.map((event, index) => { const start = formatDateTime(event.start); const end = formatDateTime(event.end); return `${index + 1}. ${event.summary} (ID: ${event.id})\n When: ${start} - ${end}\n Where: ${event.location || 'Not specified'}\n`; }).join('\n'); return { content: [ { type: "text", text: `Found ${events.length} events matching "${validatedArgs.query}":\n\n${eventsDisplay}`, }, ], }; } case "list_calendars": { const response = await calendar.calendarList.list(); const calendars = response.data.items || []; if (calendars.length === 0) { return { content: [ { type: "text", text: "No calendars found in your Google account.", }, ], }; } // Format the calendars for display const calendarsDisplay = calendars.map((cal, index) => { return `${index + 1}. ${cal.summary} (ID: ${cal.id})\n Access: ${cal.accessRole}\n Primary: ${cal.primary ? 'Yes' : 'No'}\n`; }).join('\n'); return { content: [ { type: "text", text: `Found ${calendars.length} calendars:\n\n${calendarsDisplay}`, }, ], }; } default: throw new Error(`Unknown tool: ${name}`); } } catch (error: any) { return { content: [ { type: "text", text: `Error: ${error.message}`, }, ], }; } }); const transport = new StdioServerTransport(); server.connect(transport); } main().catch((error) => { console.error('Server error:', error); process.exit(1); });