UNPKG

@cocal/google-calendar-mcp

Version:

Google Calendar MCP Server with extensive support for calendar management

1,461 lines (1,426 loc) 98.2 kB
#!/usr/bin/env node // src/index.ts import { fileURLToPath as fileURLToPath2 } from "url"; // src/server.ts import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { McpError as McpError3, ErrorCode as ErrorCode3 } from "@modelcontextprotocol/sdk/types.js"; // src/auth/client.ts import { OAuth2Client } from "google-auth-library"; import * as fs from "fs/promises"; // src/auth/utils.ts import * as path2 from "path"; import { fileURLToPath } from "url"; // src/auth/paths.js import path from "path"; import { homedir } from "os"; function getSecureTokenPath() { const configDir = process.env.XDG_CONFIG_HOME || path.join(homedir(), ".config"); return path.join(configDir, "google-calendar-mcp", "tokens.json"); } function getLegacyTokenPath() { return path.join(process.cwd(), ".gcp-saved-tokens.json"); } function getAccountMode() { const explicitMode = process.env.GOOGLE_ACCOUNT_MODE?.toLowerCase(); if (explicitMode === "test" || explicitMode === "normal") { return explicitMode; } if (process.env.NODE_ENV === "test") { return "test"; } return "normal"; } // src/auth/utils.ts function getProjectRoot() { const __dirname2 = path2.dirname(fileURLToPath(import.meta.url)); const projectRoot = path2.join(__dirname2, ".."); return path2.resolve(projectRoot); } function getAccountMode2() { return getAccountMode(); } function getSecureTokenPath2() { return getSecureTokenPath(); } function getLegacyTokenPath2() { return getLegacyTokenPath(); } function getKeysFilePath() { const envCredentialsPath = process.env.GOOGLE_OAUTH_CREDENTIALS; if (envCredentialsPath) { return path2.resolve(envCredentialsPath); } const projectRoot = getProjectRoot(); const keysPath = path2.join(projectRoot, "gcp-oauth.keys.json"); return keysPath; } function generateCredentialsErrorMessage() { return ` OAuth credentials not found. Please provide credentials using one of these methods: 1. Environment variable: Set GOOGLE_OAUTH_CREDENTIALS to the path of your credentials file: export GOOGLE_OAUTH_CREDENTIALS="/path/to/gcp-oauth.keys.json" 2. Default file path: Place your gcp-oauth.keys.json file in the package root directory. Token storage: - Tokens are saved to: ${getSecureTokenPath2()} - 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 5. Download the credentials file as gcp-oauth.keys.json `.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() { 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(); return new OAuth2Client({ clientId: credentials.client_id, clientSecret: credentials.client_secret, redirectUri: credentials.redirect_uris[0] }); } 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 { OAuth2Client as OAuth2Client2 } from "google-auth-library"; // src/auth/tokenManager.ts import fs2 from "fs/promises"; import { GaxiosError } from "gaxios"; import { mkdir } from "fs/promises"; import { dirname as dirname2 } from "path"; var TokenManager = class { oauth2Client; tokenPath; accountMode; constructor(oauth2Client) { this.oauth2Client = oauth2Client; this.tokenPath = getSecureTokenPath2(); this.accountMode = getAccountMode2(); this.setupTokenRefresh(); } // Method to expose the token path getTokenPath() { return this.tokenPath; } // Method to get current account mode getAccountMode() { return this.accountMode; } // Method to switch account mode (useful for testing) setAccountMode(mode) { this.accountMode = mode; } async ensureTokenDirectoryExists() { try { await mkdir(dirname2(this.tokenPath), { recursive: true }); } catch (error) { process.stderr.write(`Failed to create token directory: ${error} `); } } async loadMultiAccountTokens() { try { const fileContent = await fs2.readFile(this.tokenPath, "utf-8"); const parsed = JSON.parse(fileContent); if (parsed.access_token || parsed.refresh_token) { const multiAccountTokens = { normal: parsed }; await this.saveMultiAccountTokens(multiAccountTokens); return multiAccountTokens; } return parsed; } catch (error) { if (error instanceof Error && "code" in error && error.code === "ENOENT") { return {}; } throw error; } } async saveMultiAccountTokens(multiAccountTokens) { await this.ensureTokenDirectoryExists(); await fs2.writeFile(this.tokenPath, JSON.stringify(multiAccountTokens, null, 2), { mode: 384 }); } setupTokenRefresh() { this.oauth2Client.on("tokens", async (newTokens) => { try { const multiAccountTokens = await this.loadMultiAccountTokens(); const currentTokens = multiAccountTokens[this.accountMode] || {}; const updatedTokens = { ...currentTokens, ...newTokens, refresh_token: newTokens.refresh_token || currentTokens.refresh_token }; multiAccountTokens[this.accountMode] = updatedTokens; await this.saveMultiAccountTokens(multiAccountTokens); if (process.env.NODE_ENV !== "test") { process.stderr.write(`Tokens updated and saved for ${this.accountMode} account `); } } catch (error) { if (error instanceof Error && "code" in error && error.code === "ENOENT") { try { const multiAccountTokens = { [this.accountMode]: newTokens }; await this.saveMultiAccountTokens(multiAccountTokens); if (process.env.NODE_ENV !== "test") { process.stderr.write(`New tokens saved for ${this.accountMode} account `); } } catch (writeError) { process.stderr.write("Error saving initial tokens: "); if (writeError) { process.stderr.write(writeError.toString()); } process.stderr.write("\n"); } } else { process.stderr.write("Error saving updated tokens: "); if (error instanceof Error) { process.stderr.write(error.message); } else if (typeof error === "string") { process.stderr.write(error); } process.stderr.write("\n"); } } }); } async migrateLegacyTokens() { const legacyPath = getLegacyTokenPath2(); 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") { process.stderr.write("Invalid legacy token format, skipping migration\n"); return false; } await this.ensureTokenDirectoryExists(); await fs2.writeFile(this.tokenPath, JSON.stringify(legacyTokens, null, 2), { mode: 384 }); process.stderr.write(`Migrated tokens from legacy location: ${legacyPath} to: ${this.tokenPath} `); try { await fs2.unlink(legacyPath); process.stderr.write("Removed legacy token file\n"); } catch (unlinkErr) { process.stderr.write(`Warning: Could not remove legacy token file: ${unlinkErr} `); } return true; } catch (error) { process.stderr.write(`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) { process.stderr.write(`No token file found at: ${this.tokenPath} `); return false; } } const multiAccountTokens = await this.loadMultiAccountTokens(); const tokens = multiAccountTokens[this.accountMode]; if (!tokens || typeof tokens !== "object") { process.stderr.write(`No tokens found for ${this.accountMode} account in file: ${this.tokenPath} `); return false; } this.oauth2Client.setCredentials(tokens); process.stderr.write(`Loaded tokens for ${this.accountMode} account `); return true; } catch (error) { process.stderr.write(`Error loading tokens for ${this.accountMode} account: `); if (error instanceof Error && "code" in error && error.code !== "ENOENT") { try { await fs2.unlink(this.tokenPath); process.stderr.write("Removed potentially corrupted token file\n"); } 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) { if (process.env.NODE_ENV !== "test") { process.stderr.write(`Auth token expired or nearing expiry for ${this.accountMode} account, 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); if (process.env.NODE_ENV !== "test") { process.stderr.write(`Token refreshed successfully for ${this.accountMode} account `); } return true; } catch (refreshError) { if (refreshError instanceof GaxiosError && refreshError.response?.data?.error === "invalid_grant") { process.stderr.write(`Error refreshing auth token for ${this.accountMode} account: Invalid grant. Token likely expired or revoked. Please re-authenticate. `); return false; } else { process.stderr.write(`Error refreshing auth token for ${this.accountMode} account: `); if (refreshError instanceof Error) { process.stderr.write(refreshError.message); } else if (typeof refreshError === "string") { process.stderr.write(refreshError); } process.stderr.write("\n"); return false; } } } else if (!this.oauth2Client.credentials.access_token && !this.oauth2Client.credentials.refresh_token) { process.stderr.write(`No access or refresh token available for ${this.accountMode} account. Please re-authenticate. `); return false; } else { return true; } } async validateTokens(accountMode) { const modeToValidate = accountMode || this.accountMode; const currentMode = this.accountMode; try { if (modeToValidate !== currentMode) { this.accountMode = modeToValidate; } 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; } } const result = await this.refreshTokensIfNeeded(); return result; } finally { if (modeToValidate !== currentMode) { this.accountMode = currentMode; } } } async saveTokens(tokens) { try { const multiAccountTokens = await this.loadMultiAccountTokens(); multiAccountTokens[this.accountMode] = tokens; await this.saveMultiAccountTokens(multiAccountTokens); this.oauth2Client.setCredentials(tokens); process.stderr.write(`Tokens saved successfully for ${this.accountMode} account to: ${this.tokenPath} `); } catch (error) { process.stderr.write(`Error saving tokens for ${this.accountMode} account: ${error} `); throw error; } } async clearTokens() { try { this.oauth2Client.setCredentials({}); const multiAccountTokens = await this.loadMultiAccountTokens(); delete multiAccountTokens[this.accountMode]; if (Object.keys(multiAccountTokens).length === 0) { await fs2.unlink(this.tokenPath); process.stderr.write(`All tokens cleared, file deleted `); } else { await this.saveMultiAccountTokens(multiAccountTokens); process.stderr.write(`Tokens cleared for ${this.accountMode} account `); } } catch (error) { if (error instanceof Error && "code" in error && error.code === "ENOENT") { process.stderr.write("Token file already deleted\n"); } else { process.stderr.write(`Error clearing tokens for ${this.accountMode} account: ${error} `); } } } // Method to list available accounts async listAvailableAccounts() { try { const multiAccountTokens = await this.loadMultiAccountTokens(); return Object.keys(multiAccountTokens); } catch (error) { return []; } } // Method to switch to a different account (useful for runtime switching) async switchAccount(newMode) { this.accountMode = newMode; return this.loadSavedTokens(); } }; // src/auth/server.ts import http from "http"; import { URL } from "url"; import open from "open"; var AuthServer = class { baseOAuth2Client; // Used by TokenManager for validation/refresh flowOAuth2Client = null; // Used specifically for the auth code flow server = null; tokenManager; portRange; activeConnections = /* @__PURE__ */ new Set(); // Track active socket connections authCompletedSuccessfully = false; // Flag for standalone script constructor(oauth2Client) { this.baseOAuth2Client = oauth2Client; this.tokenManager = new TokenManager(oauth2Client); this.portRange = { start: 3500, end: 3505 }; } createServer() { const server = http.createServer(async (req, res) => { const url = new URL(req.url || "/", `http://${req.headers.host}`); if (url.pathname === "/") { const clientForUrl = this.flowOAuth2Client || this.baseOAuth2Client; const scopes = ["https://www.googleapis.com/auth/calendar"]; const authUrl = clientForUrl.generateAuthUrl({ access_type: "offline", scope: scopes, prompt: "consent" }); const accountMode = getAccountMode2(); res.writeHead(200, { "Content-Type": "text/html" }); res.end(` <h1>Google Calendar Authentication</h1> <p><strong>Account Mode:</strong> <code>${accountMode}</code></p> <p>You are authenticating for the <strong>${accountMode}</strong> account.</p> <a href="${authUrl}">Authenticate with Google</a> `); } else if (url.pathname === "/oauth2callback") { const code = url.searchParams.get("code"); if (!code) { res.writeHead(400, { "Content-Type": "text/plain" }); res.end("Authorization code missing"); return; } if (!this.flowOAuth2Client) { res.writeHead(500, { "Content-Type": "text/plain" }); res.end("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(); const accountMode = this.tokenManager.getAccountMode(); res.writeHead(200, { "Content-Type": "text/html" }); res.end(` <!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; } .account-mode { background-color: #e3f2fd; padding: 1em; border-radius: 5px; margin: 1em 0; } </style> </head> <body> <div class="container"> <h1>Authentication Successful!</h1> <div class="account-mode"> <p><strong>Account Mode:</strong> <code>${accountMode}</code></p> <p>Your authentication tokens have been saved for the <strong>${accountMode}</strong> account.</p> </div> <p>Tokens saved 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"; process.stderr.write(`\u2717 Token save failed: ${message} `); res.writeHead(500, { "Content-Type": "text/html" }); res.end(` <!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> `); } } else { res.writeHead(404, { "Content-Type": "text/plain" }); res.end("Not Found"); } }); server.on("connection", (socket) => { this.activeConnections.add(socket); socket.on("close", () => { this.activeConnections.delete(socket); }); }); return server; } async start(openBrowser = true) { return Promise.race([ this.startWithTimeout(openBrowser), new Promise((_, reject) => { setTimeout(() => reject(new Error("Auth server start timed out after 10 seconds")), 1e4); }) ]).catch(() => false); } async startWithTimeout(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; } const authorizeUrl = this.flowOAuth2Client.generateAuthUrl({ access_type: "offline", scope: ["https://www.googleapis.com/auth/calendar"], prompt: "consent" }); process.stderr.write(` \u{1F517} Authentication URL: ${authorizeUrl} `); process.stderr.write(`Or visit: http://localhost:${port} `); if (openBrowser) { try { await open(authorizeUrl); process.stderr.write(`Browser opened automatically. If it didn't open, use the URL above. `); } catch (error) { process.stderr.write(`Could not open browser automatically. Please use the URL above. `); } } else { process.stderr.write(`Please visit the URL above to complete authentication. `); } return true; } async startServerOnAvailablePort() { for (let port = this.portRange.start; port <= this.portRange.end; port++) { try { await new Promise((resolve2, reject) => { const testServer = this.createServer(); testServer.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) { for (const connection of this.activeConnections) { connection.destroy(); } this.activeConnections.clear(); const timeout = setTimeout(() => { process.stderr.write("Server close timeout, forcing exit...\n"); this.server = null; resolve2(); }, 2e3); this.server.close((err) => { clearTimeout(timeout); if (err) { reject(err); } else { this.server = null; resolve2(); } }); } else { resolve2(); } }); } }; // src/tools/registry.ts import { z } from "zod"; import { zodToJsonSchema } from "zod-to-json-schema"; // src/handlers/core/BaseToolHandler.ts import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; import { GaxiosError as GaxiosError2 } from "gaxios"; import { google } from "googleapis"; var BaseToolHandler = class { handleGoogleApiError(error) { if (error instanceof GaxiosError2) { const status = error.response?.status; const errorData = error.response?.data; if (errorData?.error === "invalid_grant") { throw new McpError( ErrorCode.InvalidRequest, "Authentication token is invalid or expired. Please re-run the authentication process (e.g., `npm run auth`)." ); } if (status === 403) { throw new McpError( ErrorCode.InvalidRequest, `Access denied: ${errorData?.error?.message || "Insufficient permissions"}` ); } if (status === 404) { throw new McpError( ErrorCode.InvalidRequest, `Resource not found: ${errorData?.error?.message || "The requested calendar or event does not exist"}` ); } if (status === 429) { throw new McpError( ErrorCode.InternalError, "Rate limit exceeded. Please try again later." ); } if (status && status >= 500) { throw new McpError( ErrorCode.InternalError, `Google API server error: ${errorData?.error?.message || error.message}` ); } throw new McpError( ErrorCode.InvalidRequest, `Google API error: ${errorData?.error?.message || error.message}` ); } if (error instanceof Error) { throw new McpError( ErrorCode.InternalError, `Internal error: ${error.message}` ); } throw new McpError( ErrorCode.InternalError, "An unknown error occurred" ); } getCalendar(auth) { return google.calendar({ version: "v3", auth, timeout: 3e3 // 3 second timeout for API calls }); } async withTimeout(promise, timeoutMs = 3e4) { const timeoutPromise = new Promise((_, reject) => { setTimeout(() => reject(new Error(`Operation timed out after ${timeoutMs}ms`)), timeoutMs); }); return Promise.race([promise, timeoutPromise]); } /** * Gets calendar details including default timezone * @param client OAuth2Client * @param calendarId Calendar ID to fetch details for * @returns Calendar details with timezone */ async getCalendarDetails(client, calendarId) { try { const calendar = this.getCalendar(client); const response = await calendar.calendarList.get({ calendarId }); if (!response.data) { throw new Error(`Calendar ${calendarId} not found`); } return response.data; } catch (error) { throw this.handleGoogleApiError(error); } } /** * Gets the default timezone for a calendar, falling back to UTC if not available * @param client OAuth2Client * @param calendarId Calendar ID * @returns Timezone string (IANA format) */ async getCalendarTimezone(client, calendarId) { try { const calendarDetails = await this.getCalendarDetails(client, calendarId); return calendarDetails.timeZone || "UTC"; } catch (error) { return "UTC"; } } }; // src/handlers/core/ListCalendarsHandler.ts var ListCalendarsHandler = class extends BaseToolHandler { async runTool(_, oauth2Client) { const calendars = await this.listCalendars(oauth2Client); 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 with detailed information. */ formatCalendarList(calendars) { return calendars.map((cal) => { const name = this.sanitizeString(cal.summaryOverride || cal.summary || "Untitled"); const id = this.sanitizeString(cal.id || "no-id"); const timezone = this.sanitizeString(cal.timeZone || "Unknown"); const kind = this.sanitizeString(cal.kind || "Unknown"); const accessRole = this.sanitizeString(cal.accessRole || "Unknown"); const isPrimary = cal.primary ? " (PRIMARY)" : ""; const isSelected = cal.selected !== false ? "Yes" : "No"; const isHidden = cal.hidden ? "Yes" : "No"; const backgroundColor = this.sanitizeString(cal.backgroundColor || "Default"); let description = ""; if (cal.description) { const sanitizedDesc = this.sanitizeString(cal.description); description = sanitizedDesc.length > 100 ? ` Description: ${sanitizedDesc.substring(0, 100)}...` : ` Description: ${sanitizedDesc}`; } let defaultReminders = "None"; if (cal.defaultReminders && cal.defaultReminders.length > 0) { defaultReminders = cal.defaultReminders.map((reminder) => { const method = this.sanitizeString(reminder.method || "unknown"); const minutes = reminder.minutes || 0; return `${method} (${minutes}min before)`; }).join(", "); } return `${name}${isPrimary} (${id}) Timezone: ${timezone} Kind: ${kind} Access Role: ${accessRole} Selected: ${isSelected} Hidden: ${isHidden} Background Color: ${backgroundColor} Default Reminders: ${defaultReminders}${description}`; }).join("\n\n"); } /** * Sanitizes a string to prevent crashes by removing problematic characters */ sanitizeString(str) { if (!str) return ""; return str.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "").replace(/[\uFFFE\uFFFF]/g, "").substring(0, 500).trim(); } }; // src/handlers/utils.ts function generateEventUrl(calendarId, eventId) { const encodedCalendarId = encodeURIComponent(calendarId); const encodedEventId = encodeURIComponent(eventId); return `https://calendar.google.com/calendar/event?eid=${encodedEventId}&cid=${encodedCalendarId}`; } function getEventUrl(event, calendarId) { if (event.htmlLink) { return event.htmlLink; } else if (calendarId && event.id) { return generateEventUrl(calendarId, event.id); } return null; } function formatDateTime(dateTime, date, timeZone) { if (!dateTime && !date) return "unspecified"; try { const dt = dateTime || date; if (!dt) return "unspecified"; const parsedDate = new Date(dt); if (isNaN(parsedDate.getTime())) return dt; if (date && !dateTime) { return parsedDate.toLocaleDateString("en-US", { weekday: "short", year: "numeric", month: "short", day: "numeric" }); } const options = { weekday: "short", year: "numeric", month: "short", day: "numeric", hour: "numeric", minute: "2-digit", timeZoneName: "short" }; if (timeZone) { options.timeZone = timeZone; } return parsedDate.toLocaleString("en-US", options); } catch (error) { return dateTime || date || "unspecified"; } } function formatAttendees(attendees) { if (!attendees || attendees.length === 0) return ""; const formatted = attendees.map((attendee) => { const email = attendee.email || "unknown"; const name = attendee.displayName || email; const status = attendee.responseStatus || "unknown"; const statusText = { "accepted": "accepted", "declined": "declined", "tentative": "tentative", "needsAction": "pending" }[status] || "unknown"; return `${name} (${statusText})`; }).join(", "); return ` Guests: ${formatted}`; } function formatEventWithDetails(event, calendarId) { const title = event.summary ? `Event: ${event.summary}` : "Untitled Event"; const eventId = event.id ? ` Event ID: ${event.id}` : ""; const description = event.description ? ` Description: ${event.description}` : ""; const location = event.location ? ` Location: ${event.location}` : ""; const startTime = formatDateTime(event.start?.dateTime, event.start?.date, event.start?.timeZone || void 0); const endTime = formatDateTime(event.end?.dateTime, event.end?.date, event.end?.timeZone || void 0); let timeInfo; if (event.start?.date) { if (event.start.date === event.end?.date) { timeInfo = ` Date: ${startTime}`; } else { const endDate = event.end?.date ? new Date(event.end.date) : null; if (endDate) { endDate.setDate(endDate.getDate() - 1); const adjustedEndTime = endDate.toLocaleDateString("en-US", { weekday: "short", year: "numeric", month: "short", day: "numeric" }); timeInfo = ` Start Date: ${startTime} End Date: ${adjustedEndTime}`; } else { timeInfo = ` Start Date: ${startTime}`; } } } else { timeInfo = ` Start: ${startTime} End: ${endTime}`; } const attendeeInfo = formatAttendees(event.attendees); const eventUrl = getEventUrl(event, calendarId); const urlInfo = eventUrl ? ` View: ${eventUrl}` : ""; return `${title}${eventId}${description}${timeInfo}${location}${attendeeInfo}${urlInfo}`; } // src/handlers/core/BatchRequestHandler.ts var BatchRequestError = class extends Error { constructor(message, errors, partial = false) { super(message); this.errors = errors; this.partial = partial; this.name = "BatchRequestError"; } }; var BatchRequestHandler = class { // 1 second constructor(auth) { this.auth = auth; this.boundary = "batch_boundary_" + Date.now(); } batchEndpoint = "https://www.googleapis.com/batch/calendar/v3"; boundary; maxRetries = 3; baseDelay = 1e3; async executeBatch(requests) { if (requests.length === 0) { return []; } if (requests.length > 50) { throw new Error("Batch requests cannot exceed 50 requests per batch"); } return this.executeBatchWithRetry(requests, 0); } async executeBatchWithRetry(requests, attempt) { try { const batchBody = this.createBatchBody(requests); const token = await this.auth.getAccessToken(); const response = await fetch(this.batchEndpoint, { method: "POST", headers: { "Authorization": `Bearer ${token.token}`, "Content-Type": `multipart/mixed; boundary=${this.boundary}` }, body: batchBody }); const responseText = await response.text(); if (response.status === 429 && attempt < this.maxRetries) { const retryAfter = response.headers.get("Retry-After"); const delay = retryAfter ? parseInt(retryAfter) * 1e3 : this.baseDelay * Math.pow(2, attempt); process.stderr.write(`Rate limited, retrying after ${delay}ms (attempt ${attempt + 1}/${this.maxRetries}) `); await this.sleep(delay); return this.executeBatchWithRetry(requests, attempt + 1); } if (!response.ok) { throw new BatchRequestError( `Batch request failed: ${response.status} ${response.statusText}`, [{ statusCode: response.status, message: `HTTP ${response.status}: ${response.statusText}`, details: responseText }] ); } return this.parseBatchResponse(responseText); } catch (error) { if (error instanceof BatchRequestError) { throw error; } if (attempt < this.maxRetries && this.isRetryableError(error)) { const delay = this.baseDelay * Math.pow(2, attempt); process.stderr.write(`Network error, retrying after ${delay}ms (attempt ${attempt + 1}/${this.maxRetries}): ${error instanceof Error ? error.message : "Unknown error"} `); await this.sleep(delay); return this.executeBatchWithRetry(requests, attempt + 1); } throw new BatchRequestError( `Failed to execute batch request: ${error instanceof Error ? error.message : "Unknown error"}`, [{ statusCode: 0, message: error instanceof Error ? error.message : "Unknown error", details: error }] ); } } isRetryableError(error) { if (error instanceof Error) { const message = error.message.toLowerCase(); return message.includes("network") || message.includes("timeout") || message.includes("econnreset") || message.includes("enotfound"); } return false; } sleep(ms) { return new Promise((resolve2) => setTimeout(resolve2, ms)); } createBatchBody(requests) { return requests.map((req, index) => { const parts = [ `--${this.boundary}`, `Content-Type: application/http`, `Content-ID: <item${index + 1}>`, "", `${req.method} ${req.path} HTTP/1.1` ]; if (req.headers) { Object.entries(req.headers).forEach(([key, value]) => { parts.push(`${key}: ${value}`); }); } if (req.body) { parts.push("Content-Type: application/json"); parts.push(""); parts.push(JSON.stringify(req.body)); } return parts.join("\r\n"); }).join("\r\n\r\n") + `\r --${this.boundary}--`; } parseBatchResponse(responseText) { const lines = responseText.split(/\r?\n/); let boundary = null; for (let i = 0; i < Math.min(10, lines.length); i++) { const line = lines[i]; if (line.toLowerCase().includes("content-type:") && line.includes("boundary=")) { const boundaryMatch = line.match(/boundary=([^\s\r\n;]+)/); if (boundaryMatch) { boundary = boundaryMatch[1]; break; } } } if (!boundary) { const boundaryMatch = responseText.match(/--([a-zA-Z0-9_-]+)/); if (boundaryMatch) { boundary = boundaryMatch[1]; } } if (!boundary) { throw new Error("Could not find boundary in batch response"); } const parts = responseText.split(`--${boundary}`); const responses = []; for (let i = 1; i < parts.length; i++) { const part = parts[i]; if (part.trim() === "" || part.trim() === "--" || part.trim().startsWith("--")) continue; const response = this.parseResponsePart(part); if (response) { responses.push(response); } } return responses; } parseResponsePart(part) { const lines = part.split(/\r?\n/); let httpLineIndex = -1; for (let i = 0; i < lines.length; i++) { if (lines[i].startsWith("HTTP/1.1")) { httpLineIndex = i; break; } } if (httpLineIndex === -1) return null; const httpLine = lines[httpLineIndex]; const statusMatch = httpLine.match(/HTTP\/1\.1 (\d+)/); if (!statusMatch) return null; const statusCode = parseInt(statusMatch[1]); const headers = {}; let bodyStartIndex = httpLineIndex + 1; for (let i = httpLineIndex + 1; i < lines.length; i++) { const line = lines[i]; if (line.trim() === "") { bodyStartIndex = i + 1; break; } const colonIndex = line.indexOf(":"); if (colonIndex > 0) { const key = line.substring(0, colonIndex).trim(); const value = line.substring(colonIndex + 1).trim(); headers[key] = value; } } let body = null; if (bodyStartIndex < lines.length) { const bodyLines = []; for (let i = bodyStartIndex; i < lines.length; i++) { bodyLines.push(lines[i]); } while (bodyLines.length > 0 && bodyLines[bodyLines.length - 1].trim() === "") { bodyLines.pop(); } if (bodyLines.length > 0) { const bodyText = bodyLines.join("\n"); if (bodyText.trim()) { try { body = JSON.parse(bodyText); } catch { body = bodyText; } } } } return { statusCode, headers, body }; } }; // src/handlers/utils/datetime.ts function hasTimezoneInDatetime(datetime) { return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(Z|[+-]\d{2}:\d{2})$/.test(datetime); } function convertToRFC3339(datetime, fallbackTimezone) { if (hasTimezoneInDatetime(datetime)) { return datetime; } else { try { const match = datetime.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})$/); if (!match) { throw new Error("Invalid datetime format"); } const [, year, month, day, hour, minute, second] = match.map(Number); const utcDate = new Date(Date.UTC(year, month - 1, day, hour, minute, second)); const targetDate = convertLocalTimeToUTC(year, month - 1, day, hour, minute, second, fallbackTimezone); return targetDate.toISOString().replace(/\.000Z$/, "Z"); } catch (error) { return datetime + "Z"; } } } function convertLocalTimeToUTC(year, month, day, hour, minute, second, timezone) { let testDate = new Date(Date.UTC(year, month, day, hour, minute, second)); const options = { timeZone: timezone, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false }; const formatter = new Intl.DateTimeFormat("sv-SE", options); const formattedInTargetTZ = formatter.format(testDate); const [datePart, timePart] = formattedInTargetTZ.split(" "); const [targetYear, targetMonth, targetDay] = datePart.split("-").map(Number); const [targetHour, targetMinute, targetSecond] = timePart.split(":").map(Number); const wantedTime = new Date(year, month, day, hour, minute, second).getTime(); const actualTime = new Date(targetYear, targetMonth - 1, targetDay, targetHour, targetMinute, targetSecond).getTime(); const offsetMs = wantedTime - actualTime; return new Date(testDate.getTime() + offsetMs); } function createTimeObject(datetime, fallbackTimezone) { if (hasTimezoneInDatetime(datetime)) { return { dateTime: datetime }; } else { return { dateTime: datetime, timeZone: fallbackTimezone }; } } // src/handlers/core/ListEventsHandler.ts var ListEventsHandler = class extends BaseToolHandler { async runTool(args, oauth2Client) { const validArgs = args; const calendarIds = Array.isArray(validArgs.calendarId) ? validArgs.calendarId : [validArgs.calendarId]; const allEvents = await this.fetchEvents(oauth2Client, calendarIds, { timeMin: validArgs.timeMin, timeMax: validArgs.timeMax, timeZone: validArgs.timeZone }); if (allEvents.length === 0) { return { content: [{ type: "text", text: `No events found in ${calendarIds.length} calendar(s).` }] }; } let text = calendarIds.length === 1 ? `Found ${allEvents.length} event(s): ` : `Found ${allEvents.length} event(s) across ${calendarIds.length} calendars: `; if (calendarIds.length === 1) { allEvents.forEach((event, index) => { const eventDetails = formatEventWithDetails(event, event.calendarId); text += `${index + 1}. ${eventDetails} `; }); } else { const grouped = this.groupEventsByCalendar(allEvents); for (const [calendarId, events] of Object.entries(grouped)) { text += `Calendar: ${calendarId} `; events.forEach((event, index) => { const eventDetails = formatEventWithDetails(event, event.calendarId); text += `${index + 1}. ${eventDetails} `; }); text += "\n"; } } return { content: [{ type: "text", text: text.trim() }] }; } async fetchEvents(client, calendarIds, options) { if (calendarIds.length === 1) { return this.fetchSingleCalendarEvents(client, calendarIds[0], options); } return this.fetchMultipleCalendarEvents(client, calendarIds, options); } async fetchSingleCalendarEvents(client, calendarId, options) { try { const calendar = this.getCalendar(client); let timeMin = options.timeMin; let timeMax = options.timeMax; if (timeMin || timeMax) { const timezone = options.timeZone || await this.getCalendarTimezone(client, calendarId); timeMin = timeMin ? convertToRFC3339(timeMin, timezone) : void 0; timeMax = timeMax ? convertToRFC3339(timeMax, timezone) : void 0; } const response = await calendar.events.list({ calendarId, timeMin, timeMax, singleEvents: true, orderBy: "startTime" }); return (response.data.items || []).map((event) => ({ ...event, calendarId })); } catch (error) { throw this.handleGoogleApiError(error); } } async fetchMultipleCalendarEvents(client, calendarIds, options) { const batchHandler = new BatchRequestHandler(client); const requests = await Promise.all(calendarIds.map(async (calendarId) => ({ method: "GET", path: await this.buildEventsPath(client, calendarId, options) }))); const responses = await batchHandler.executeBatch(requests); const { events, errors } = this.processBatchResponses(responses, calendarIds); if (errors.length > 0) { process.stderr.write(`Some calendars had errors: ${errors.map((e) => `${e.calendarId}: ${e.error}`).join(", ")} `); } return this.sortEventsByStartTime(events); } async buildEventsPath(client, calendarId, options) { let timeMin = options.timeMin; let timeMax = options.timeMax; if (timeMin || timeMax) { const timezone = options.timeZone || await this.getCalendarTimezone(client, calendarId); timeMin = timeMin ? convertToRFC3339(timeMin, timezone) : void 0; timeMax = timeMax ? convertToRFC3339(timeMax, timezone) : void 0; } const params = new URLSearchParams({ singleEvents: "true", orderBy: "startTime", ...timeMin && { timeMin }, ...timeMax && { timeMax } }); return `/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events?${params.toString()}`; } processBatchResponses(responses, calendarIds) { const events = []; const errors = []; responses.forEach((response, index) => { const calendarId = calendarIds[index]; if (response.statusCode === 200 && response.body?.items) { const calendarEvents = response.body.items.map((event) => ({ ...event, calendarId })); events.push(...calendarEvents); } else { const errorMessage = response.body?.error?.message || response.body?.message || `HTTP ${response.statusCode}`; errors.push({ calendarId, error: errorMessage }); } }); return { events, errors }; } sortEventsByStartTime(events) { return events.sort((a, b) => { const aStart = a.start?.dateTime || a.start?.date || ""; const bStart = b.start?.dateTime || b.start?.date || ""; return aStart.localeCompare(bStart); }); } groupEventsByCalendar(events) { return events.reduce((acc, event) => { const calId = event.calendarId; if (!acc[calId]) acc[calId] = []; acc[calId].push(event); return acc; }, {}); } }; // src/handlers/core/SearchEventsHandler.ts var SearchEventsHandler = class extends BaseToolHandler { async runTool(args, oauth2Client) { const validArgs = args; const events = await this.searchEvents(oauth2Client, validArgs); if (events.length === 0) { return { content: [{ type: "text", text: "No events found matching your search criteria." }] }; } let text = `Found ${events.length} event(s) matching your search: `; events.forEach((event, index) => { const eventDetails = formatEventWithDetails(event, validArgs.calendarId); text += `${index + 1}. ${eventDetails} `; }); return { content: [{ type: "text", text: text.trim() }] }; } async searchEvents(client, args) { try { const calendar = this.getCalendar(client); const timezone = args.timeZone || await this.getCalendarTimezone(client, args.calendarId); const timeMin = convertToRFC3339(args.timeMin, timezone); const timeMax = convertToRFC3339(args.timeMax, timezone); const response = await calendar.events.list({ calendarId: args.calendarId, q: args.query, timeMin, timeMax, singleEvents: true, orderBy: "startTime" }); return response.data.items || []; } catch (error) { throw this.handleGoogleApiError(error); } } }; // src/handlers/core/ListColorsHandler.ts var ListColorsHandler = class extends BaseToolHandler { async runTool(_, oauth2Client) { const colors = await this.listColors(oauth2Client); return { content: [{ type: "text", text: `Available event colors: ${this.formatColorList(colors)}` }] }; } async listColors(client) { try { const calendar = this.getCalendar(client); const response = await calendar.colors.get(); if (!response.data) throw new Error("Failed to retrieve colors"); return response.data; } catch (error) { throw this.handleGoogleApiError(error); } } /** * Formats the color information into a user-friendly string. */ formatColorList(colors) { const eventColors = colors.event || {}; return Object.entries(eventColors).map(([id, colorInfo]) => `Color ID: ${id} - ${colorInfo.background} (background) / ${colorInfo.foreground} (foreground)`).join("\n"); } }; // src/handlers/core/CreateEventHandler.ts var CreateEventHandler = class extends BaseToolHandler