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 { z } from "zod";
import { AuthServer } from './auth-server.js';
import { TokenManager } from './token-manager.js';
// Utility functions for date format conversion
function parseBrazilianDate(dateString: string): Date {
// Handle different Brazilian date formats
// DD/MM/YYYY
const dateRegex = /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/;
// DD/MM/YYYY HH:MM
const dateTimeRegex = /^(\d{1,2})\/(\d{1,2})\/(\d{4}) (\d{1,2}):(\d{1,2})$/;
let date: Date;
if (dateTimeRegex.test(dateString)) {
const [_, day, month, year, hours, minutes] = dateTimeRegex.exec(dateString) || [];
date = new Date(parseInt(year), parseInt(month) - 1, parseInt(day), parseInt(hours), parseInt(minutes));
} else if (dateRegex.test(dateString)) {
const [_, day, month, year] = dateRegex.exec(dateString) || [];
date = new Date(parseInt(year), parseInt(month) - 1, parseInt(day));
} else {
// If not in Brazilian format, try parsing as ISO
date = new Date(dateString);
}
if (isNaN(date.getTime())) {
throw new Error(`Invalid date format: ${dateString}. Use DD/MM/YYYY or DD/MM/YYYY HH:MM`);
}
return date;
}
function formatToISOString(date: Date): string {
return date.toISOString();
}
function formatToBrazilianDate(isoString: string): string {
const date = new Date(isoString);
return `${date.getDate().toString().padStart(2, '0')}/${(date.getMonth() + 1).toString().padStart(2, '0')}/${date.getFullYear()}`;
}
function formatToBrazilianDateTime(isoString: string): string {
const date = new Date(isoString);
return `${date.getDate().toString().padStart(2, '0')}/${(date.getMonth() + 1).toString().padStart(2, '0')}/${date.getFullYear()} ${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
}
interface CalendarListEntry {
id?: string | null;
summary?: string | null;
}
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;
}
interface CalendarEventAttendee {
email?: string | null;
responseStatus?: string | null;
}
// Define Zod schemas for validation
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(),
});
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(),
});
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 url = new URL(import.meta.url);
const pathname = url.protocol === 'file:' ?
// Remove leading slash on Windows paths
url.pathname.replace(/^\/([A-Z]:)/, '$1') :
url.pathname;
return path.join(
path.dirname(pathname),
'../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;
}
}
// 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 or Brazilian format (DD/MM/YYYY or DD/MM/YYYY HH:MM) (optional)",
},
timeMax: {
type: "string",
description: "End time in ISO format or Brazilian format (DD/MM/YYYY or DD/MM/YYYY HH:MM) (optional)",
},
},
required: ["calendarId"],
},
},
{
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 or Brazilian format (DD/MM/YYYY or DD/MM/YYYY HH:MM). For all-day events, use DD/MM/YYYY format.",
},
end: {
type: "string",
description: "End time in ISO format or Brazilian format (DD/MM/YYYY or DD/MM/YYYY HH:MM). For all-day events, use DD/MM/YYYY 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"]
}
}
},
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 or Brazilian format (DD/MM/YYYY or DD/MM/YYYY HH:MM). For all-day events, use DD/MM/YYYY format.",
},
end: {
type: "string",
description: "New end time in ISO format or Brazilian format (DD/MM/YYYY or DD/MM/YYYY HH:MM). For all-day events, use DD/MM/YYYY format.",
},
location: {
type: "string",
description: "New 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"]
}
}
},
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);
// Convert Brazilian date format to ISO if provided
let timeMin = validArgs.timeMin;
let timeMax = validArgs.timeMax;
if (timeMin) {
try {
timeMin = formatToISOString(parseBrazilianDate(timeMin));
} catch (e) {
// If parsing fails, assume it's already in ISO format
}
}
if (timeMax) {
try {
timeMax = formatToISOString(parseBrazilianDate(timeMax));
} catch (e) {
// If parsing fails, assume it's already in ISO format
}
}
const response = await calendar.events.list({
calendarId: validArgs.calendarId,
timeMin: timeMin,
timeMax: timeMax,
singleEvents: true,
orderBy: 'startTime',
});
const events = response.data.items || [];
return {
content: [{
type: "text",
text: events.map((event: CalendarEvent) => {
const attendeeList = event.attendees
? `\nAttendees: ${event.attendees.map((a: CalendarEventAttendee) =>
`${a.email || 'no-email'} (${a.responseStatus || 'unknown'})`).join(', ')}`
: '';
const locationInfo = event.location ? `\nLocation: ${event.location}` : '';
// Format dates in Brazilian format
let startDate = event.start?.dateTime || event.start?.date || 'unspecified';
let endDate = event.end?.dateTime || event.end?.date || 'unspecified';
if (startDate !== 'unspecified') {
startDate = event.start?.dateTime ?
formatToBrazilianDateTime(startDate) :
formatToBrazilianDate(startDate);
}
if (endDate !== 'unspecified') {
endDate = event.end?.dateTime ?
formatToBrazilianDateTime(endDate) :
formatToBrazilianDate(endDate);
}
return `${event.summary || 'Untitled'} (${event.id || 'no-id'})${locationInfo}\nInício: ${startDate}\nFim: ${endDate}${attendeeList}\n`;
}).join('\n')
}]
};
}
case "create-event": {
const validArgs = CreateEventArgumentsSchema.parse(args);
// Convert Brazilian date format to ISO
let startDateTime = validArgs.start;
let endDateTime = validArgs.end;
let isAllDay = false;
// Check if this is an all-day event (just date without time)
const dateOnlyRegex = /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/;
if (dateOnlyRegex.test(startDateTime) && dateOnlyRegex.test(endDateTime)) {
isAllDay = true;
// Parse start date
const [_, startDay, startMonth, startYear] = dateOnlyRegex.exec(startDateTime) || [];
const startDate = new Date(parseInt(startYear), parseInt(startMonth) - 1, parseInt(startDay));
// Parse end date and add 1 day (Google Calendar requires end date to be exclusive)
const [__, endDay, endMonth, endYear] = dateOnlyRegex.exec(endDateTime) || [];
const endDate = new Date(parseInt(endYear), parseInt(endMonth) - 1, parseInt(endDay));
endDate.setDate(endDate.getDate() + 1);
// Format as YYYY-MM-DD for Google Calendar API
startDateTime = `${startDate.getFullYear()}-${(startDate.getMonth() + 1).toString().padStart(2, '0')}-${startDate.getDate().toString().padStart(2, '0')}`;
endDateTime = `${endDate.getFullYear()}-${(endDate.getMonth() + 1).toString().padStart(2, '0')}-${endDate.getDate().toString().padStart(2, '0')}`;
} else {
// Not an all-day event, convert to ISO format
try {
startDateTime = formatToISOString(parseBrazilianDate(startDateTime));
} catch (e) {
// If parsing fails, assume it's already in ISO format
}
try {
endDateTime = formatToISOString(parseBrazilianDate(endDateTime));
} catch (e) {
// If parsing fails, assume it's already in ISO format
}
}
const event = await calendar.events.insert({
calendarId: validArgs.calendarId,
requestBody: {
summary: validArgs.summary,
description: validArgs.description,
start: isAllDay ? { date: startDateTime } : { dateTime: startDateTime },
end: isAllDay ? { date: endDateTime } : { dateTime: endDateTime },
attendees: validArgs.attendees,
location: validArgs.location,
},
}).then(response => response.data);
return {
content: [{
type: "text",
text: `Evento criado: ${event.summary} (${event.id})`
}]
};
}
case "update-event": {
const validArgs = UpdateEventArgumentsSchema.parse(args);
// Convert Brazilian date format to ISO if provided
let startDateTime = validArgs.start;
let endDateTime = validArgs.end;
let startIsAllDay = false;
let endIsAllDay = false;
// Check if this is an all-day event update
const dateOnlyRegex = /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/;
if (startDateTime && dateOnlyRegex.test(startDateTime)) {
startIsAllDay = true;
// Parse start date
const [_, startDay, startMonth, startYear] = dateOnlyRegex.exec(startDateTime) || [];
const startDate = new Date(parseInt(startYear), parseInt(startMonth) - 1, parseInt(startDay));
// Format as YYYY-MM-DD for Google Calendar API
startDateTime = `${startDate.getFullYear()}-${(startDate.getMonth() + 1).toString().padStart(2, '0')}-${startDate.getDate().toString().padStart(2, '0')}`;
} else if (startDateTime) {
try {
startDateTime = formatToISOString(parseBrazilianDate(startDateTime));
} catch (e) {
// If parsing fails, assume it's already in ISO format
}
}
if (endDateTime && dateOnlyRegex.test(endDateTime)) {
endIsAllDay = true;
// Parse end date and add 1 day (Google Calendar requires end date to be exclusive)
const [_, endDay, endMonth, endYear] = dateOnlyRegex.exec(endDateTime) || [];
const endDate = new Date(parseInt(endYear), parseInt(endMonth) - 1, parseInt(endDay));
endDate.setDate(endDate.getDate() + 1);
// Format as YYYY-MM-DD for Google Calendar API
endDateTime = `${endDate.getFullYear()}-${(endDate.getMonth() + 1).toString().padStart(2, '0')}-${endDate.getDate().toString().padStart(2, '0')}`;
} else if (endDateTime) {
try {
endDateTime = formatToISOString(parseBrazilianDate(endDateTime));
} catch (e) {
// If parsing fails, assume it's already in ISO format
}
}
// Prepare the request body
const requestBody: any = {
summary: validArgs.summary,
description: validArgs.description,
attendees: validArgs.attendees,
location: validArgs.location,
};
// Add start and end times if provided
if (startDateTime) {
requestBody.start = startIsAllDay ? { date: startDateTime } : { dateTime: startDateTime };
}
if (endDateTime) {
requestBody.end = endIsAllDay ? { date: endDateTime } : { dateTime: endDateTime };
}
const event = await calendar.events.patch({
calendarId: validArgs.calendarId,
eventId: validArgs.eventId,
requestBody: requestBody,
}).then(response => response.data);
return {
content: [{
type: "text",
text: `Evento atualizado: ${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: `Evento excluído com sucesso`
}]
};
}
default:
throw new Error(`Unknown tool: ${name}`);
}
} catch (error) {
console.error('Error processing request:', error);
throw error;
}
});
function getKeysFilePath(): string {
const url = new URL(import.meta.url);
const pathname = url.protocol === 'file:' ?
// Remove leading slash on Windows paths
url.pathname.replace(/^\/([A-Z]:)/, '$1') :
url.pathname;
const relativePath = path.join(
path.dirname(pathname),
'../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);
});