Google Calendar AutoAuth MCP Server
by falgom4
- calendar-mcp
- src
#!/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);
});