UNPKG

@cocal/google-calendar-mcp

Version:

Google Calendar MCP Server with extensive support for calendar management

743 lines (727 loc) 26.3 kB
// 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 __dirname = path2.dirname(fileURLToPath(import.meta.url)); const projectRoot = path2.join(__dirname, ".."); 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/auth-server.ts async function runAuthServer() { let authServer = null; try { const oauth2Client = await initializeOAuth2Client(); authServer = new AuthServer(oauth2Client); const success = await authServer.start(true); if (!success && !authServer.authCompletedSuccessfully) { process.stderr.write("Authentication failed. Could not start server or validate existing tokens. Check port availability (3000-3004) and try again.\n"); process.exit(1); } else if (authServer.authCompletedSuccessfully) { process.stderr.write("Authentication successful.\n"); process.exit(0); } process.stderr.write("Authentication server started. Please complete the authentication in your browser...\n"); process.stderr.write(`Waiting for OAuth callback on port ${authServer.getRunningPort()}... `); let lastDebugLog = 0; const pollInterval = setInterval(async () => { try { if (authServer?.authCompletedSuccessfully) { process.stderr.write("Authentication completed successfully detected. Stopping server...\n"); clearInterval(pollInterval); await authServer.stop(); process.stderr.write("Authentication successful. Server stopped.\n"); process.exit(0); } else { const now = Date.now(); if (now - lastDebugLog > 1e4) { process.stderr.write("Still waiting for authentication to complete...\n"); lastDebugLog = now; } } } catch (error) { process.stderr.write(`Error in polling interval: ${error instanceof Error ? error.message : "Unknown error"} `); clearInterval(pollInterval); if (authServer) await authServer.stop(); process.exit(1); } }, 5e3); process.on("SIGINT", async () => { clearInterval(pollInterval); if (authServer) { await authServer.stop(); } process.exit(0); }); } catch (error) { process.stderr.write(`Authentication error: ${error instanceof Error ? error.message : "Unknown error"} `); if (authServer) await authServer.stop(); process.exit(1); } } if (import.meta.url.endsWith("auth-server.js")) { runAuthServer().catch((error) => { process.stderr.write(`Unhandled error: ${error instanceof Error ? error.message : "Unknown error"} `); process.exit(1); }); } //# sourceMappingURL=auth-server.js.map