UNPKG

mcp-google-calendar-plus

Version:

The ONLY MCP server for full Google Calendar AND Contacts management (create/update/delete) in Claude, Cursor, Windsurf

1,383 lines (1,362 loc) 102 kB
#!/usr/bin/env node // src/index.ts 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 { fileURLToPath as fileURLToPath2 } from "url"; import { readFileSync } from "fs"; import { join as join2, dirname as dirname3 } from "path"; // src/auth/client.ts import { OAuth2Client } from "google-auth-library"; import * as fs from "fs/promises"; // src/auth/utils.ts import * as path from "path"; import * as os from "os"; import { fileURLToPath } from "url"; function getProjectRoot() { const __dirname2 = path.dirname(fileURLToPath(import.meta.url)); const projectRoot = path.join(__dirname2, ".."); return path.resolve(projectRoot); } function getSecureTokenPath() { const customTokenPath = process.env.GOOGLE_CALENDAR_MCP_TOKEN_PATH; if (customTokenPath) { return path.resolve(customTokenPath); } const configHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"); const tokenDir = path.join(configHome, "google-calendar-mcp"); return path.join(tokenDir, "tokens.json"); } function getLegacyTokenPath() { const projectRoot = getProjectRoot(); return path.join(projectRoot, ".gcp-saved-tokens.json"); } function getKeysFilePath() { const envCredentialsPath = process.env.GOOGLE_OAUTH_CREDENTIALS; if (envCredentialsPath) { return path.resolve(envCredentialsPath); } const projectRoot = getProjectRoot(); const keysPath = path.join(projectRoot, "gcp-oauth.keys.json"); return keysPath; } function generateCredentialsErrorMessage() { return ` OAuth credentials not found. Please provide credentials using one of these methods: 1. Direct environment variables (simplest - no JSON file needed!): Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET: export GOOGLE_CLIENT_ID="your-client-id.apps.googleusercontent.com" export GOOGLE_CLIENT_SECRET="your-client-secret" 2. Credentials file via environment variable: Set GOOGLE_OAUTH_CREDENTIALS to the path of your credentials file: export GOOGLE_OAUTH_CREDENTIALS="/path/to/gcp-oauth.keys.json" 3. Default file path: Place your gcp-oauth.keys.json file in the package root directory. Token storage: - Tokens are saved to: ${getSecureTokenPath()} - To use a custom token location, set GOOGLE_CALENDAR_MCP_TOKEN_PATH environment variable To get OAuth credentials: 1. Go to the Google Cloud Console (https://console.cloud.google.com/) 2. Create or select a project 3. Enable the Google Calendar API 4. Create OAuth 2.0 credentials (Desktop app type) 5. Copy the Client ID and Client Secret `.trim(); } // src/auth/client.ts async function loadCredentialsFromFile() { const keysContent = await fs.readFile(getKeysFilePath(), "utf-8"); const keys = JSON.parse(keysContent); if (keys.installed) { const { client_id, client_secret, redirect_uris } = keys.installed; return { client_id, client_secret, redirect_uris }; } else if (keys.client_id && keys.client_secret) { return { client_id: keys.client_id, client_secret: keys.client_secret, redirect_uris: keys.redirect_uris || ["http://localhost:3000/oauth2callback"] }; } else { throw new Error('Invalid credentials file format. Expected either "installed" object or direct client_id/client_secret fields.'); } } async function loadCredentialsWithFallback() { const clientId = process.env.GOOGLE_CLIENT_ID; const clientSecret = process.env.GOOGLE_CLIENT_SECRET; if (clientId && clientSecret) { return { client_id: clientId, client_secret: clientSecret, redirect_uris: ["http://localhost:3000/oauth2callback"] }; } try { return await loadCredentialsFromFile(); } catch (fileError) { const errorMessage = generateCredentialsErrorMessage(); throw new Error(`${errorMessage} Original error: ${fileError instanceof Error ? fileError.message : fileError}`); } } async function initializeOAuth2Client() { try { const credentials = await loadCredentialsWithFallback(); const oauth2Client2 = new OAuth2Client({ clientId: credentials.client_id, clientSecret: credentials.client_secret, redirectUri: credentials.redirect_uris[0] }); return oauth2Client2; } catch (error) { throw new Error(`Error loading OAuth keys: ${error instanceof Error ? error.message : error}`); } } async function loadCredentials() { try { const credentials = await loadCredentialsWithFallback(); if (!credentials.client_id || !credentials.client_secret) { throw new Error("Client ID or Client Secret missing in credentials."); } return { client_id: credentials.client_id, client_secret: credentials.client_secret }; } catch (error) { throw new Error(`Error loading credentials: ${error instanceof Error ? error.message : error}`); } } // src/auth/server.ts import express from "express"; import { OAuth2Client as OAuth2Client2 } from "google-auth-library"; // src/auth/tokenManager.ts import * as fs2 from "fs/promises"; import * as path2 from "path"; import { GaxiosError } from "gaxios"; var TokenManager = class { oauth2Client; tokenPath; constructor(oauth2Client2) { this.oauth2Client = oauth2Client2; this.tokenPath = getSecureTokenPath(); this.setupTokenRefresh(); } // Method to expose the token path getTokenPath() { return this.tokenPath; } async ensureTokenDirectoryExists() { try { const dir = path2.dirname(this.tokenPath); await fs2.mkdir(dir, { recursive: true }); } catch (error) { if (error instanceof Error && "code" in error && error.code !== "EEXIST") { console.error("Failed to create token directory:", error); throw error; } } } setupTokenRefresh() { this.oauth2Client.on("tokens", async (newTokens) => { try { await this.ensureTokenDirectoryExists(); const currentTokens = JSON.parse(await fs2.readFile(this.tokenPath, "utf-8")); const updatedTokens = { ...currentTokens, ...newTokens, refresh_token: newTokens.refresh_token || currentTokens.refresh_token }; await fs2.writeFile(this.tokenPath, JSON.stringify(updatedTokens, null, 2), { mode: 384 }); console.error("Tokens updated and saved"); } catch (error) { if (error instanceof Error && "code" in error && error.code === "ENOENT") { try { await fs2.writeFile(this.tokenPath, JSON.stringify(newTokens, null, 2), { mode: 384 }); console.error("New tokens saved"); } catch (writeError) { console.error("Error saving initial tokens:", writeError); } } else { console.error("Error saving updated tokens:", error); } } }); } async migrateLegacyTokens() { const legacyPath = getLegacyTokenPath(); try { if (!await fs2.access(legacyPath).then(() => true).catch(() => false)) { return false; } const legacyTokens = JSON.parse(await fs2.readFile(legacyPath, "utf-8")); if (!legacyTokens || typeof legacyTokens !== "object") { console.error("Invalid legacy token format, skipping migration"); return false; } await this.ensureTokenDirectoryExists(); await fs2.writeFile(this.tokenPath, JSON.stringify(legacyTokens, null, 2), { mode: 384 }); console.error("Migrated tokens from legacy location:", legacyPath, "to:", this.tokenPath); try { await fs2.unlink(legacyPath); console.error("Removed legacy token file"); } catch (unlinkErr) { console.error("Warning: Could not remove legacy token file:", unlinkErr); } return true; } catch (error) { console.error("Error migrating legacy tokens:", error); return false; } } async loadSavedTokens() { try { await this.ensureTokenDirectoryExists(); const tokenExists = await fs2.access(this.tokenPath).then(() => true).catch(() => false); if (!tokenExists) { const migrated = await this.migrateLegacyTokens(); if (!migrated) { console.error("No token file found at:", this.tokenPath); return false; } } const tokens = JSON.parse(await fs2.readFile(this.tokenPath, "utf-8")); if (!tokens || typeof tokens !== "object") { console.error("Invalid token format in file:", this.tokenPath); return false; } this.oauth2Client.setCredentials(tokens); return true; } catch (error) { console.error("Error loading tokens:", error); if (error instanceof Error && "code" in error && error.code !== "ENOENT") { try { await fs2.unlink(this.tokenPath); console.error("Removed potentially corrupted token file"); } catch (unlinkErr) { } } return false; } } async refreshTokensIfNeeded() { const expiryDate = this.oauth2Client.credentials.expiry_date; const isExpired = expiryDate ? Date.now() >= expiryDate - 5 * 60 * 1e3 : !this.oauth2Client.credentials.access_token; if (isExpired && this.oauth2Client.credentials.refresh_token) { console.error("Auth token expired or nearing expiry, refreshing..."); try { const response = await this.oauth2Client.refreshAccessToken(); const newTokens = response.credentials; if (!newTokens.access_token) { throw new Error("Received invalid tokens during refresh"); } this.oauth2Client.setCredentials(newTokens); console.error("Token refreshed successfully"); return true; } catch (refreshError) { if (refreshError instanceof GaxiosError && refreshError.response?.data?.error === "invalid_grant") { console.error("Error refreshing auth token: Invalid grant. Token likely expired or revoked. Please re-authenticate."); return false; } else { console.error("Error refreshing auth token:", refreshError); return false; } } } else if (!this.oauth2Client.credentials.access_token && !this.oauth2Client.credentials.refresh_token) { console.error("No access or refresh token available. Please re-authenticate."); return false; } else { return true; } } async validateTokens() { if (!this.oauth2Client.credentials || !this.oauth2Client.credentials.access_token) { if (!await this.loadSavedTokens()) { return false; } if (!this.oauth2Client.credentials || !this.oauth2Client.credentials.access_token) { return false; } } return this.refreshTokensIfNeeded(); } async saveTokens(tokens) { try { await this.ensureTokenDirectoryExists(); await fs2.writeFile(this.tokenPath, JSON.stringify(tokens, null, 2), { mode: 384 }); this.oauth2Client.setCredentials(tokens); console.error("Tokens saved successfully to:", this.tokenPath); } catch (error) { console.error("Error saving tokens:", error); throw error; } } async clearTokens() { try { this.oauth2Client.setCredentials({}); await fs2.unlink(this.tokenPath); console.error("Tokens cleared successfully"); } catch (error) { if (error instanceof Error && "code" in error && error.code === "ENOENT") { console.error("Token file already deleted"); } else { console.error("Error clearing tokens:", error); } } } }; // src/auth/server.ts import open from "open"; var AuthServer = class { baseOAuth2Client; // Used by TokenManager for validation/refresh flowOAuth2Client = null; // Used specifically for the auth code flow app; server = null; tokenManager; portRange; authCompletedSuccessfully = false; // Flag for standalone script constructor(oauth2Client2) { this.baseOAuth2Client = oauth2Client2; this.tokenManager = new TokenManager(oauth2Client2); this.app = express(); this.portRange = { start: 3e3, end: 3004 }; this.setupRoutes(); } setupRoutes() { this.app.get("/", (req, res) => { const clientForUrl = this.flowOAuth2Client || this.baseOAuth2Client; const scopes = [ "https://www.googleapis.com/auth/calendar", "https://www.googleapis.com/auth/contacts" ]; const authUrl = clientForUrl.generateAuthUrl({ access_type: "offline", scope: scopes, prompt: "consent" }); res.send(` <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Google Calendar & Contacts MCP Authentication</title> <style> body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; display: flex; justify-content: center; align-items: center; min-height: 100vh; background-color: #f5f5f5; margin: 0; padding: 20px; } .container { text-align: center; padding: 2.5em; background-color: #fff; border-radius: 12px; box-shadow: 0 4px 6px rgba(0,0,0,0.1); max-width: 500px; } h1 { color: #1a73e8; margin-bottom: 0.5em; } h2 { color: #333; font-weight: normal; font-size: 1.2em; margin-bottom: 1.5em; } p { color: #666; line-height: 1.6; margin-bottom: 1.5em; } .btn { display: inline-block; background-color: #1a73e8; color: white; padding: 12px 32px; text-decoration: none; border-radius: 6px; font-size: 16px; font-weight: 500; transition: background-color 0.2s; } .btn:hover { background-color: #1557b0; } .permissions { background-color: #f8f9fa; padding: 1em; border-radius: 8px; margin: 1.5em 0; text-align: left; } .permissions h3 { margin: 0 0 0.5em 0; font-size: 1em; color: #333; } .permissions ul { margin: 0; padding-left: 1.5em; color: #666; } .permissions li { margin: 0.3em 0; } .footer { margin-top: 2em; font-size: 0.9em; color: #999; } </style> </head> <body> <div class="container"> <h1>\u{1F5D3}\uFE0F Google Calendar & Contacts MCP</h1> <h2>Authentication Required</h2> <p>Claude Desktop needs permission to access your Google Calendar and Contacts.</p> <div class="permissions"> <h3>This will allow Claude to:</h3> <ul> <li>View your calendar events</li> <li>Create new calendar events</li> <li>Update existing events</li> <li>Delete events</li> <li>Check your availability</li> <li>View and manage your contacts</li> <li>Create new contacts</li> <li>Update existing contacts</li> <li>Delete contacts</li> </ul> </div> <a href="${authUrl}" class="btn">Connect Google Calendar & Contacts</a> <p class="footer">You'll be redirected to Google to sign in securely.<br>Your credentials are never stored by this application.</p> </div> </body> </html> `); }); this.app.get("/oauth2callback", async (req, res) => { const code = req.query.code; if (!code) { res.status(400).send("Authorization code missing"); return; } if (!this.flowOAuth2Client) { res.status(500).send("Authentication flow not properly initiated."); return; } try { const { tokens } = await this.flowOAuth2Client.getToken(code); await this.tokenManager.saveTokens(tokens); this.authCompletedSuccessfully = true; const tokenPath = this.tokenManager.getTokenPath(); res.send(` <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Authentication Successful</title> <style> body { font-family: sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; background-color: #f4f4f4; margin: 0; } .container { text-align: center; padding: 2em; background-color: #fff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); } h1 { color: #4CAF50; } p { color: #333; margin-bottom: 0.5em; } code { background-color: #eee; padding: 0.2em 0.4em; border-radius: 3px; font-size: 0.9em; } </style> </head> <body> <div class="container"> <h1>Authentication Successful!</h1> <p>Your authentication tokens have been saved successfully to:</p> <p><code>${tokenPath}</code></p> <p>You can now close this browser window.</p> </div> </body> </html> `); } catch (error) { this.authCompletedSuccessfully = false; const message = error instanceof Error ? error.message : "Unknown error"; res.status(500).send(` <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Authentication Failed</title> <style> body { font-family: sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; background-color: #f4f4f4; margin: 0; } .container { text-align: center; padding: 2em; background-color: #fff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); } h1 { color: #F44336; } p { color: #333; } </style> </head> <body> <div class="container"> <h1>Authentication Failed</h1> <p>An error occurred during authentication:</p> <p><code>${message}</code></p> <p>Please try again or check the server logs.</p> </div> </body> </html> `); } }); } async start(openBrowser = true) { if (await this.tokenManager.validateTokens()) { this.authCompletedSuccessfully = true; return true; } const port = await this.startServerOnAvailablePort(); if (port === null) { this.authCompletedSuccessfully = false; return false; } try { const { client_id, client_secret } = await loadCredentials(); this.flowOAuth2Client = new OAuth2Client2( client_id, client_secret, `http://localhost:${port}/oauth2callback` ); } catch (error) { this.authCompletedSuccessfully = false; await this.stop(); return false; } if (openBrowser) { const authorizeUrl = this.flowOAuth2Client.generateAuthUrl({ access_type: "offline", scope: [ "https://www.googleapis.com/auth/calendar", "https://www.googleapis.com/auth/contacts" ], prompt: "consent" }); await open(authorizeUrl); } return true; } async startServerOnAvailablePort() { for (let port = this.portRange.start; port <= this.portRange.end; port++) { try { await new Promise((resolve2, reject) => { const testServer = this.app.listen(port, () => { this.server = testServer; resolve2(); }); testServer.on("error", (err) => { if (err.code === "EADDRINUSE") { testServer.close(() => reject(err)); } else { reject(err); } }); }); return port; } catch (error) { if (!(error instanceof Error && "code" in error && error.code === "EADDRINUSE")) { return null; } } } return null; } getRunningPort() { if (this.server) { const address = this.server.address(); if (typeof address === "object" && address !== null) { return address.port; } } return null; } async stop() { return new Promise((resolve2, reject) => { if (this.server) { this.server.close((err) => { if (err) { reject(err); } else { this.server = null; resolve2(); } }); } else { resolve2(); } }); } }; // src/handlers/listTools.ts var remindersInputProperty = { 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"] }; function getToolDefinitions() { return { tools: [ { name: "list-calendars", description: "List all available calendars", inputSchema: { type: "object", properties: {}, // No arguments needed required: [] } }, { name: "list-events", description: "List events from one or more calendars", inputSchema: { type: "object", properties: { calendarId: { oneOf: [ { type: "string", description: "ID of a single calendar" }, { type: "array", description: "Array of calendar IDs", items: { type: "string" }, minItems: 1, maxItems: 50 } ], description: "ID of the calendar(s) to list events from (use 'primary' for the main calendar)" }, timeMin: { type: "string", format: "date-time", description: "Start time in ISO format with timezone required (e.g., 2024-01-01T00:00:00Z or 2024-01-01T00:00:00+00:00). Date-time must end with Z (UTC) or +/-HH:MM offset." }, timeMax: { type: "string", format: "date-time", description: "End time in ISO format with timezone required (e.g., 2024-12-31T23:59:59Z or 2024-12-31T23:59:59+00:00). Date-time must end with Z (UTC) or +/-HH:MM offset." } }, required: ["calendarId"] } }, { name: "search-events", description: "Search for events in a calendar by text query", inputSchema: { type: "object", properties: { calendarId: { type: "string", description: "ID of the calendar to search events in (use 'primary' for the main calendar)" }, query: { type: "string", description: "Free text search query (searches summary, description, location, attendees, etc.)" }, timeMin: { type: "string", format: "date-time", description: "Start time boundary in ISO format with timezone required (e.g., 2024-01-01T00:00:00Z or 2024-01-01T00:00:00+00:00). Date-time must end with Z (UTC) or +/-HH:MM offset." }, timeMax: { type: "string", format: "date-time", description: "End time boundary in ISO format with timezone required (e.g., 2024-12-31T23:59:59Z or 2024-12-31T23:59:59+00:00). Date-time must end with Z (UTC) or +/-HH:MM offset." } }, required: ["calendarId", "query"] } }, { name: "list-colors", description: "List available color IDs and their meanings for calendar events", inputSchema: { type: "object", properties: {}, // No arguments needed required: [] } }, { name: "create-event", description: "Create a new calendar event", inputSchema: { type: "object", properties: { calendarId: { type: "string", description: "ID of the calendar to create the event in (use 'primary' for the main calendar)" }, summary: { type: "string", description: "Title of the event" }, description: { type: "string", description: "Description/notes for the event (optional)" }, start: { type: "string", format: "date-time", description: "Start time in ISO format with timezone required (e.g., 2024-08-15T10:00:00Z or 2024-08-15T10:00:00-07:00). Date-time must end with Z (UTC) or +/-HH:MM offset." }, end: { type: "string", format: "date-time", description: "End time in ISO format with timezone required (e.g., 2024-08-15T11:00:00Z or 2024-08-15T11:00:00-07:00). Date-time must end with Z (UTC) or +/-HH:MM offset." }, timeZone: { type: "string", description: "Timezone of the event start/end times, formatted as an IANA Time Zone Database name (e.g., America/Los_Angeles). Required if start/end times are specified, especially for recurring events." }, location: { type: "string", description: "Location of the event (optional)" }, attendees: { type: "array", description: "List of attendee email addresses (optional)", items: { type: "object", properties: { email: { type: "string", format: "email", description: "Email address of the attendee" } }, required: ["email"] } }, colorId: { type: "string", description: "Color ID for the event (optional, use list-colors to see available IDs)" }, reminders: remindersInputProperty, recurrence: { type: "array", description: 'List of recurrence rules (RRULE, EXRULE, RDATE, EXDATE) in RFC5545 format (optional). Example: ["RRULE:FREQ=WEEKLY;COUNT=5"]', items: { type: "string" } } }, required: ["calendarId", "summary", "start", "end", "timeZone"] } }, { name: "update-event", description: "Update an existing calendar event with recurring event modification scope support", 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 for the event (optional)" }, description: { type: "string", description: "New description for the event (optional)" }, start: { type: "string", format: "date-time", description: "New start time in ISO format with timezone required (e.g., 2024-08-15T10:00:00Z or 2024-08-15T10:00:00-07:00). Date-time must end with Z (UTC) or +/-HH:MM offset." }, end: { type: "string", format: "date-time", description: "New end time in ISO format with timezone required (e.g., 2024-08-15T11:00:00Z or 2024-08-15T11:00:00-07:00). Date-time must end with Z (UTC) or +/-HH:MM offset." }, timeZone: { type: "string", description: "Timezone for the start/end times (IANA format, e.g., America/Los_Angeles). Required if modifying start/end, or for recurring events." }, location: { type: "string", description: "New location for the event (optional)" }, colorId: { type: "string", description: "New color ID for the event (optional)" }, attendees: { type: "array", description: "New list of attendee email addresses (optional, replaces existing attendees)", items: { type: "object", properties: { email: { type: "string", format: "email", description: "Email address of the attendee" } }, required: ["email"] } }, reminders: { ...remindersInputProperty, description: "New reminder settings for the event (optional)" }, recurrence: { type: "array", description: 'New list of recurrence rules (RFC5545 format, optional, replaces existing rules). Example: ["RRULE:FREQ=DAILY;COUNT=10"]', items: { type: "string" } }, modificationScope: { type: "string", enum: ["single", "all", "future"], default: "all", description: "Scope of modification for recurring events: 'single' (one instance), 'all' (entire series), 'future' (this and future instances). Defaults to 'all' for backward compatibility." }, originalStartTime: { type: "string", format: "date-time", description: "Required when modificationScope is 'single'. Original start time of the specific instance to modify in ISO format with timezone (e.g., 2024-08-15T10:00:00-07:00)." }, futureStartDate: { type: "string", format: "date-time", description: "Required when modificationScope is 'future'. Start date for future modifications in ISO format with timezone (e.g., 2024-08-20T10:00:00-07:00). Must be a future date." } }, required: ["calendarId", "eventId", "timeZone"], // timeZone is technically required for PATCH allOf: [ { if: { properties: { modificationScope: { const: "single" } } }, then: { required: ["originalStartTime"] } }, { if: { properties: { modificationScope: { const: "future" } } }, then: { required: ["futureStartDate"] } } ] } }, { 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"] } }, { name: "get-freebusy", description: "Retrieve free/busy information for one or more calendars within a time range", inputSchema: { type: "object", properties: { timeMin: { type: "string", description: "The start of the interval in RFC3339 format" }, timeMax: { type: "string", description: "The end of the interval in RFC3339 format" }, timeZone: { type: "string", description: "Optional. Time zone used in the response (default is UTC)" }, groupExpansionMax: { type: "integer", description: "Optional. Maximum number of calendar identifiers to expand per group (max 100)" }, calendarExpansionMax: { type: "integer", description: "Optional. Maximum number of calendars to expand (max 50)" }, items: { type: "array", description: "List of calendar or group identifiers to check for availability", items: { type: "object", properties: { id: { type: "string", description: "The identifier of a calendar or group, it usually is a mail format" } }, required: ["id"] } } }, required: ["timeMin", "timeMax", "items"] } }, { name: "list-contacts", description: "List contacts from Google Contacts", inputSchema: { type: "object", properties: { pageSize: { type: "number", description: "Maximum number of contacts to return (default: 100, max: 2000)" }, pageToken: { type: "string", description: "Token for pagination to get the next page of results" }, query: { type: "string", description: "Optional search query to filter contacts" }, personFields: { type: "array", description: "Fields to include in the response (default: names,emailAddresses,phoneNumbers,addresses,organizations,biographies,photos)", items: { type: "string", enum: ["addresses", "ageRanges", "biographies", "birthdays", "calendarUrls", "clientData", "coverPhotos", "emailAddresses", "events", "externalIds", "genders", "imClients", "interests", "locales", "locations", "memberships", "metadata", "miscKeywords", "names", "nicknames", "occupations", "organizations", "phoneNumbers", "photos", "relations", "sipAddresses", "skills", "urls", "userDefined"] } }, sources: { type: "array", description: "Sources to get contacts from (default: READ_SOURCE_TYPE_CONTACT)", items: { type: "string", enum: ["READ_SOURCE_TYPE_CONTACT", "READ_SOURCE_TYPE_PROFILE", "READ_SOURCE_TYPE_DOMAIN_PROFILE", "READ_SOURCE_TYPE_OTHER_CONTACT"] } } }, required: [] } }, { name: "get-contact", description: "Get details of a specific contact", inputSchema: { type: "object", properties: { resourceName: { type: "string", description: "Resource name of the contact (e.g., 'people/c1234567890')" }, personFields: { type: "array", description: "Fields to include in the response", items: { type: "string", enum: ["addresses", "ageRanges", "biographies", "birthdays", "calendarUrls", "clientData", "coverPhotos", "emailAddresses", "events", "externalIds", "genders", "imClients", "interests", "locales", "locations", "memberships", "metadata", "miscKeywords", "names", "nicknames", "occupations", "organizations", "phoneNumbers", "photos", "relations", "sipAddresses", "skills", "urls", "userDefined"] } } }, required: ["resourceName"] } }, { name: "create-contact", description: "Create a new contact in Google Contacts", inputSchema: { type: "object", properties: { givenName: { type: "string", description: "First name of the contact" }, familyName: { type: "string", description: "Last name of the contact" }, middleName: { type: "string", description: "Middle name of the contact" }, displayName: { type: "string", description: "Display name (defaults to 'givenName familyName' if not provided)" }, emailAddresses: { type: "array", description: "Email addresses for the contact", items: { type: "object", properties: { value: { type: "string", format: "email", description: "Email address" }, type: { type: "string", enum: ["home", "work", "other"], description: "Type of email address (default: home)" } }, required: ["value"] } }, phoneNumbers: { type: "array", description: "Phone numbers for the contact", items: { type: "object", properties: { value: { type: "string", description: "Phone number" }, type: { type: "string", enum: ["home", "work", "mobile", "homeFax", "workFax", "otherFax", "pager", "workMobile", "workPager", "main", "googleVoice", "other"], description: "Type of phone number (default: home)" } }, required: ["value"] } }, addresses: { type: "array", description: "Physical addresses for the contact", items: { type: "object", properties: { streetAddress: { type: "string", description: "Street address" }, city: { type: "string", description: "City" }, region: { type: "string", description: "State or region" }, postalCode: { type: "string", description: "Postal or ZIP code" }, country: { type: "string", description: "Country" }, type: { type: "string", enum: ["home", "work", "other"], description: "Type of address (default: home)" } } } }, organizations: { type: "array", description: "Organizations/companies for the contact", items: { type: "object", properties: { name: { type: "string", description: "Organization name" }, title: { type: "string", description: "Job title" }, department: { type: "string", description: "Department" }, type: { type: "string", enum: ["work", "school", "other"], description: "Type of organization (default: work)" } } } }, biographies: { type: "array", description: "Biographical information", items: { type: "object", properties: { value: { type: "string", description: "Biography text" }, contentType: { type: "string", enum: ["TEXT_PLAIN", "TEXT_HTML"], description: "Content type (default: TEXT_PLAIN)" } }, required: ["value"] } }, notes: { type: "string", description: "Notes about the contact (will be added as a biography if biographies not provided)" } }, required: [] } }, { name: "update-contact", description: "Update an existing contact", inputSchema: { type: "object", properties: { resourceName: { type: "string", description: "Resource name of the contact to update (e.g., 'people/c1234567890')" }, updatePersonFields: { type: "array", description: "Fields to update (must specify which fields are being updated)", items: { type: "string", enum: ["names", "emailAddresses", "phoneNumbers", "addresses", "organizations", "biographies"] } }, givenName: { type: "string", description: "First name (required if updating names)" }, familyName: { type: "string", description: "Last name (required if updating names)" }, middleName: { type: "string", description: "Middle name" }, displayName: { type: "string", description: "Display name" }, emailAddresses: { type: "array", description: "Email addresses (replaces all existing if updating)", items: { type: "object", properties: { value: { type: "string", format: "email", description: "Email address" }, type: { type: "string", enum: ["home", "work", "other"], description: "Type of email address" } }, required: ["value"] } }, phoneNumbers: { type: "array", description: "Phone numbers (replaces all existing if updating)", items: { type: "object", properties: { value: { type: "string", description: "Phone number" }, type: { type: "string", enum: ["home", "work", "mobile", "homeFax", "workFax", "otherFax", "pager", "workMobile", "workPager", "main", "googleVoice", "other"], description: "Type of phone number" } }, required: ["value"] } }, addresses: { type: "array", description: "Physical addresses (replaces all existing if updating)", items: { type: "object", properties: { streetAddress: { type: "string", description: "Street address" }, city: { type: "string", description: "City" }, region: { type: "string", description: "State or region" }, postalCode: { type: "string", description: "Postal or ZIP code" }, country: { type: "string", description: "Country" }, type: { type: "string", enum: ["home", "work", "other"], description: "Type of address" } } } }, organizations: { type: "array", description: "Organizations (replaces all existing if updating)", items: { type: "object", properties: { name: { type: "string", description: "Organization name" }, title: { type: "string", description: "Job title" }, department: { type: "string", description: "Department" }, type: { type: "string", enum: ["work", "school", "other"], description: "Type of organization" } } } }, biographies: { type: "array", description: "Biographical information (replaces all existing if updating)", items: { type: "object", properties: { value: { type: "string", description: "Biography text" }, contentType: { type: "string", enum: ["TEXT_PLAIN", "TEXT_HTML"], description: "Content type" } }, required: ["value"] } } }, required: ["resourceName", "updatePersonFields"] } }, { name: "delete-contact", description: "Delete a contact", inputSchema: { type: "object", properties: { resourceName: { type: "string", description: "Resource name of the contact to delete (e.g., 'people/c1234567890')" } }, required: ["resourceName"] } } ] }; } // src/handlers/core/BaseToolHandler.ts import { GaxiosError as GaxiosError2 } from "gaxios"; import { google } from "googleapis"; var BaseToolHandler = class { handleGoogleApiError(error) { if (error instanceof GaxiosError2 && error.response?.data?.error === "invalid_grant") { throw new Error( "Google API Error: Authentication token is invalid or expired. Please re-run the authentication process (e.g., `npm run auth`)." ); } throw error; } getCalendar(auth) { return google.calendar({ version: "v3", auth }); } }; // src/handlers/core/ListCalendarsHandler.ts var ListCalendarsHandler = class extends BaseToolHandler { async runTool(_, oauth2Client2) { const calendars = await this.listCalendars(oauth2Client2); return { content: [{ type: "text", // This MUST be a string literal text: this.formatCalendarList(calendars) }] }; } async listCalendars(client) { try { const calendar = this.getCalendar(client); const response = await calendar.calendarList.list(); return response.data.items || []; } catch (error) { throw this.handleGoogleApiError(error); } } /** * Formats a list of calendars into a user-friendly string. */ formatCalendarList(calendars) { return calendars.map((cal) => `${cal.summary || "Untitled"} (${cal.id || "no-id"})`).join("\n"); } }; // src/schemas/validators.ts import { z } from "zod"; var ReminderSchema = z.object({ method: z.enum(["email", "popup"]).default("popup"), minutes: z.number() }); var RemindersSchema = z.object({ useDefault: z.boolean(), overrides: z.array(ReminderSchema).optional() }); var isoDateTimeWithTimezone = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?(Z|[+-]\d{2}:\d{2})$/; var ListEventsArgumentsSchema = z.object({ calendarId: z.preprocess( (val) => { if (typeof val === "string" && val.startsWith("[") && val.endsWith("]")) { try { return JSON.parse(val); } catch { return val; } } return val; }, z.union([ z.string().min(1, "Calendar ID cannot be empty"), z.array(z.string().min(