google-calendar-mcp

import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { ListToolsRequestSchema, CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js"; import { google } from 'googleapis'; import { OAuth2Client } from 'google-auth-library'; import * as fs from 'fs/promises'; import * as path from 'path'; import { fileURLToPath } from 'url'; import { z } from "zod"; import { AuthServer } from './auth-server.js'; import { TokenManager } from './token-manager.js'; interface CalendarListEntry { id?: string | null; summary?: string | null; } interface CalendarEventReminder { method: 'email' | 'popup'; minutes: number; } interface CalendarEvent { id?: string | null; summary?: string | null; start?: { dateTime?: string | null; date?: string | null; }; end?: { dateTime?: string | null; date?: string | null; }; location?: string | null; attendees?: CalendarEventAttendee[] | null; colorId?: string | null; reminders?: { useDefault: boolean; overrides?: CalendarEventReminder[]; }; } interface CalendarEventAttendee { email?: string | null; responseStatus?: string | null; } // Define Zod schemas for validation const ReminderSchema = z.object({ method: z.enum(['email', 'popup']).default('popup'), minutes: z.number(), }); const RemindersSchema = z.object({ useDefault: z.boolean(), overrides: z.array(ReminderSchema).optional(), }); const ListEventsArgumentsSchema = z.object({ calendarId: z.string(), timeMin: z.string().optional(), timeMax: z.string().optional(), }); const CreateEventArgumentsSchema = z.object({ calendarId: z.string(), summary: z.string(), description: z.string().optional(), start: z.string(), end: z.string(), attendees: z.array(z.object({ email: z.string() })).optional(), location: z.string().optional(), colorId: z.string().optional(), reminders: RemindersSchema.optional(), }); const UpdateEventArgumentsSchema = z.object({ calendarId: z.string(), eventId: z.string(), summary: z.string().optional(), description: z.string().optional(), start: z.string().optional(), end: z.string().optional(), attendees: z.array(z.object({ email: z.string() })).optional(), location: z.string().optional(), colorId: z.string().optional(), reminders: RemindersSchema.optional(), }); const DeleteEventArgumentsSchema = z.object({ calendarId: z.string(), eventId: z.string(), }); // Create server instance const server = new Server( { name: "google-calendar", version: "1.0.0", }, { capabilities: { tools: {}, }, } ); // Initialize OAuth2 client async function initializeOAuth2Client() { try { const keysContent = await fs.readFile(getKeysFilePath(), 'utf-8'); const keys = JSON.parse(keysContent); const { client_id, client_secret, redirect_uris } = keys.installed; return new OAuth2Client({ clientId: client_id, clientSecret: client_secret, redirectUri: redirect_uris[0] }); } catch (error) { console.error("Error loading OAuth keys:", error); throw error; } } let oauth2Client: OAuth2Client; let tokenManager: TokenManager; let authServer: AuthServer; // Helper function to get secure token path function getSecureTokenPath(): string { const __dirname = path.dirname(fileURLToPath(import.meta.url)); return path.join(__dirname, '../.gcp-saved-tokens.json'); } // Helper function to load and refresh tokens async function loadSavedTokens(): Promise<boolean> { try { const tokenPath = getSecureTokenPath(); if (!await fs.access(tokenPath).then(() => true).catch(() => false)) { console.error('No token file found'); return false; } const tokens = JSON.parse(await fs.readFile(tokenPath, 'utf-8')); if (!tokens || typeof tokens !== 'object') { console.error('Invalid token format'); return false; } oauth2Client.setCredentials(tokens); const expiryDate = tokens.expiry_date; const isExpired = expiryDate ? Date.now() >= (expiryDate - 5 * 60 * 1000) : true; if (isExpired && tokens.refresh_token) { try { const response = await oauth2Client.refreshAccessToken(); const newTokens = response.credentials; if (!newTokens.access_token) { throw new Error('Received invalid tokens during refresh'); } await fs.writeFile(tokenPath, JSON.stringify(newTokens, null, 2), { mode: 0o600 }); oauth2Client.setCredentials(newTokens); } catch (refreshError) { console.error('Error refreshing auth token:', refreshError); return false; } } oauth2Client.on('tokens', async (newTokens) => { try { const currentTokens = JSON.parse(await fs.readFile(tokenPath, 'utf-8')); const updatedTokens = { ...currentTokens, ...newTokens, refresh_token: newTokens.refresh_token || currentTokens.refresh_token }; await fs.writeFile(tokenPath, JSON.stringify(updatedTokens, null, 2), { mode: 0o600 }); } catch (error) { console.error('Error saving updated tokens:', error); } }); return true; } catch (error) { console.error('Error loading tokens:', error); return false; } } const reminders_input_property = { type: "object", description: "Reminder settings for the event", properties: { useDefault: { type: "boolean", description: "Whether to use the default reminders", }, overrides: { type: "array", description: "Custom reminders (uses popup notifications by default unless email is specified)", items: { type: "object", properties: { method: { type: "string", enum: ["email", "popup"], description: "Reminder method (defaults to popup unless email is specified)", default: "popup" }, minutes: { type: "number", description: "Minutes before the event to trigger the reminder", } }, required: ["minutes"] } } }, required: ["useDefault"] } // List available tools server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: [ { name: "list-calendars", description: "List all available calendars", inputSchema: { type: "object", properties: {}, required: [], }, }, { name: "list-events", description: "List events from a calendar", inputSchema: { type: "object", properties: { calendarId: { type: "string", description: "ID of the calendar to list events from", }, timeMin: { type: "string", description: "Start time in ISO format (optional)", }, timeMax: { type: "string", description: "End time in ISO format (optional)", }, }, required: ["calendarId"], }, }, { name: "list-colors", description: "List available color IDs for calendar events", inputSchema: { type: "object", properties: {}, required: [], }, }, { name: "create-event", description: "Create a new calendar event", inputSchema: { type: "object", properties: { calendarId: { type: "string", description: "ID of the calendar to create event in", }, summary: { type: "string", description: "Title of the event", }, description: { type: "string", description: "Description of the event", }, start: { type: "string", description: "Start time in ISO format", }, end: { type: "string", description: "End time in ISO format", }, location: { type: "string", description: "Location of the event", }, attendees: { type: "array", description: "List of attendees", items: { type: "object", properties: { email: { type: "string", description: "Email address of the attendee" } }, required: ["email"] } }, colorId: { type: "string", description: "Color ID for the event", }, reminders: reminders_input_property }, required: ["calendarId", "summary", "start", "end"], }, }, { name: "update-event", description: "Update an existing calendar event", inputSchema: { type: "object", properties: { calendarId: { type: "string", description: "ID of the calendar containing the event", }, eventId: { type: "string", description: "ID of the event to update", }, summary: { type: "string", description: "New title of the event", }, description: { type: "string", description: "New description of the event", }, start: { type: "string", description: "New start time in ISO format", }, end: { type: "string", description: "New end time in ISO format", }, location: { type: "string", description: "New location of the event", }, colorId: { type: "string", description: "New color ID for the event", }, attendees: { type: "array", description: "List of attendees", items: { type: "object", properties: { email: { type: "string", description: "Email address of the attendee" } }, required: ["email"] } }, reminders: { ...reminders_input_property, description: "New reminder settings for the event", } }, required: ["calendarId", "eventId"], }, }, { name: "delete-event", description: "Delete a calendar event", inputSchema: { type: "object", properties: { calendarId: { type: "string", description: "ID of the calendar containing the event", }, eventId: { type: "string", description: "ID of the event to delete", }, }, required: ["calendarId", "eventId"], }, }, ], }; }); server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; // Check authentication before processing any request if (!await tokenManager.validateTokens()) { const port = authServer ? 3000 : null; const authMessage = port ? `Authentication required. Please visit http://localhost:${port} to authenticate with Google Calendar. If this port is unavailable, the server will try ports 3001-3004.` : 'Authentication required. Please run "npm run auth" to authenticate with Google Calendar.'; throw new Error(authMessage); } const calendar = google.calendar({ version: 'v3', auth: oauth2Client }); try { switch (name) { case "list-calendars": { const response = await calendar.calendarList.list(); const calendars = response.data.items || []; return { content: [{ type: "text", text: calendars.map((cal: CalendarListEntry) => `${cal.summary || 'Untitled'} (${cal.id || 'no-id'})`).join('\n') }] }; } case "list-events": { const validArgs = ListEventsArgumentsSchema.parse(args); const response = await calendar.events.list({ calendarId: validArgs.calendarId, timeMin: validArgs.timeMin, timeMax: validArgs.timeMax, singleEvents: true, orderBy: 'startTime', }); const events = response.data.items || []; return { content: [{ type: "text", text: events.map((event) => { const attendeeList = event.attendees ? `\nAttendees: ${event.attendees.map((a) => `${a.email || 'no-email'} (${a.responseStatus || 'unknown'})`).join(', ')}` : ''; const locationInfo = event.location ? `\nLocation: ${event.location}` : ''; const colorInfo = event.colorId ? `\nColor ID: ${event.colorId}` : ''; const reminderInfo = event.reminders ? `\nReminders: ${event.reminders.useDefault ? 'Using default' : (event.reminders.overrides || []).map(r => `${r.method} ${r.minutes} minutes before`).join(', ') || 'None'}` : ''; return `${event.summary || 'Untitled'} (${event.id || 'no-id'})${locationInfo}\nStart: ${event.start?.dateTime || event.start?.date || 'unspecified'}\nEnd: ${event.end?.dateTime || event.end?.date || 'unspecified'}${attendeeList}${colorInfo}${reminderInfo}\n`; }).join('\n') }] }; } case "list-colors": { const response = await calendar.colors.get(); const colors = response.data.event || {}; const colorList = Object.entries(colors) .map(([id, colorInfo]: [string, any]) => `Color ID: ${id} - ${colorInfo.background} (background) / ${colorInfo.foreground} (foreground)` ).join('\n'); return { content: [{ type: "text", text: `Available event colors:\n${colorList}` }] }; } case "create-event": { const validArgs = CreateEventArgumentsSchema.parse(args); const event = await calendar.events.insert({ calendarId: validArgs.calendarId, requestBody: { summary: validArgs.summary, description: validArgs.description, start: { dateTime: validArgs.start }, end: { dateTime: validArgs.end }, attendees: validArgs.attendees, location: validArgs.location, colorId: validArgs.colorId, reminders: validArgs.reminders, }, }).then(response => response.data); return { content: [{ type: "text", text: `Event created: ${event.summary} (${event.id})` }] }; } case "update-event": { const validArgs = UpdateEventArgumentsSchema.parse(args); const event = await calendar.events.patch({ calendarId: validArgs.calendarId, eventId: validArgs.eventId, requestBody: { summary: validArgs.summary, description: validArgs.description, start: validArgs.start ? { dateTime: validArgs.start } : undefined, end: validArgs.end ? { dateTime: validArgs.end } : undefined, attendees: validArgs.attendees, location: validArgs.location, colorId: validArgs.colorId, reminders: validArgs.reminders, }, }).then(response => response.data); return { content: [{ type: "text", text: `Event updated: ${event.summary} (${event.id})` }] }; } case "delete-event": { const validArgs = DeleteEventArgumentsSchema.parse(args); await calendar.events.delete({ calendarId: validArgs.calendarId, eventId: validArgs.eventId, }); return { content: [{ type: "text", text: `Event deleted successfully` }] }; } default: throw new Error(`Unknown tool: ${name}`); } } catch (error) { console.error('Error processing request:', error); throw error; } }); function getKeysFilePath(): string { const __dirname = path.dirname(fileURLToPath(import.meta.url)); const relativePath = path.join(__dirname, '../gcp-oauth.keys.json'); const absolutePath = path.resolve(relativePath); return absolutePath; } // Start the server async function main() { try { oauth2Client = await initializeOAuth2Client(); tokenManager = new TokenManager(oauth2Client); authServer = new AuthServer(oauth2Client); // Start auth server if needed if (!await tokenManager.loadSavedTokens()) { console.log('No valid tokens found, starting auth server...'); const success = await authServer.start(); if (!success) { console.error('Failed to start auth server'); process.exit(1); } } const transport = new StdioServerTransport(); await server.connect(transport); console.error("Google Calendar MCP Server running on stdio"); // Handle cleanup process.on('SIGINT', cleanup); process.on('SIGTERM', cleanup); } catch (error) { console.error("Server startup failed:", error); process.exit(1); } } async function cleanup() { console.log('Cleaning up...'); if (authServer) { await authServer.stop(); } if (tokenManager) { tokenManager.clearTokens(); } process.exit(0); } main().catch((error) => { console.error("Fatal error:", error); process.exit(1); });