@cocal/google-calendar-mcp
Version:
Google Calendar MCP Server with extensive support for calendar management
743 lines (727 loc) • 26.3 kB
JavaScript
// 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