@cocal/google-calendar-mcp
Version:
Google Calendar MCP Server with extensive support for calendar management
1,478 lines (1,456 loc) • 257 kB
JavaScript
#!/usr/bin/env node
var __defProp = Object.defineProperty;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __esm = (fn, res) => function __init() {
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
};
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
// src/auth/paths.js
var paths_exports = {};
__export(paths_exports, {
getAccountMode: () => getAccountMode,
getLegacyTokenPath: () => getLegacyTokenPath,
getSecureTokenPath: () => getSecureTokenPath,
validateAccountId: () => validateAccountId
});
import path from "path";
import { homedir } from "os";
function getSecureTokenPath() {
if (process.env.GOOGLE_CALENDAR_MCP_TOKEN_PATH) {
return path.resolve(process.env.GOOGLE_CALENDAR_MCP_TOKEN_PATH);
}
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 validateAccountId(accountId2) {
if (!accountId2 || accountId2.length === 0) {
throw new Error("Invalid account ID. Must be 1-64 characters: lowercase letters, numbers, dashes, underscores only.");
}
if (RESERVED_NAMES.includes(accountId2)) {
throw new Error(`Account ID "${accountId2}" is reserved and cannot be used.`);
}
if (!/^[a-z0-9_-]{1,64}$/.test(accountId2)) {
throw new Error("Invalid account ID. Must be 1-64 characters: lowercase letters, numbers, dashes, underscores only.");
}
return accountId2;
}
function getAccountMode() {
const explicitMode = process.env.GOOGLE_ACCOUNT_MODE;
if (explicitMode !== void 0 && explicitMode !== null) {
return validateAccountId(explicitMode);
}
if (process.env.NODE_ENV === "test") {
return "test";
}
return "normal";
}
var RESERVED_NAMES;
var init_paths = __esm({
"src/auth/paths.js"() {
"use strict";
RESERVED_NAMES = [
".",
"..",
"con",
"prn",
"aux",
"nul",
"com1",
"com2",
"com3",
"com4",
"lpt1",
"lpt2",
"lpt3"
];
}
});
// src/auth/utils.ts
import * as path2 from "path";
import * as fs from "fs";
import { fileURLToPath } from "url";
function getProjectRoot() {
const __dirname3 = path2.dirname(fileURLToPath(import.meta.url));
const projectRoot = path2.join(__dirname3, "..");
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 getCredentialsProjectId() {
try {
const credentialsPath = getKeysFilePath();
if (!fs.existsSync(credentialsPath)) {
return void 0;
}
const credentialsContent = fs.readFileSync(credentialsPath, "utf-8");
const credentials = JSON.parse(credentialsContent);
if (credentials.installed?.project_id) {
return credentials.installed.project_id;
} else if (credentials.project_id) {
return credentials.project_id;
}
return void 0;
} catch (error) {
return void 0;
}
}
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();
}
var init_utils = __esm({
"src/auth/utils.ts"() {
"use strict";
init_paths();
}
});
// src/auth/client.ts
var client_exports = {};
__export(client_exports, {
initializeOAuth2Client: () => initializeOAuth2Client,
loadCredentials: () => loadCredentials
});
import { OAuth2Client } from "google-auth-library";
import * as fs2 from "fs/promises";
async function loadCredentialsFromFile() {
const keysContent = await fs2.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}`);
}
}
var init_client = __esm({
"src/auth/client.ts"() {
"use strict";
init_utils();
}
});
// src/index.ts
import { fileURLToPath as fileURLToPath4 } from "url";
// src/server.ts
init_client();
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { McpError as McpError5, ErrorCode as ErrorCode5 } from "@modelcontextprotocol/sdk/types.js";
import { readFileSync as readFileSync2 } from "fs";
import { join as join2, dirname as dirname3 } from "path";
import { fileURLToPath as fileURLToPath3 } from "url";
// src/auth/server.ts
import { OAuth2Client as OAuth2Client3, CodeChallengeMethod } from "google-auth-library";
// src/auth/tokenManager.ts
init_utils();
init_paths();
import { OAuth2Client as OAuth2Client2 } from "google-auth-library";
import fs3 from "fs/promises";
import { GaxiosError } from "gaxios";
import { mkdir } from "fs/promises";
import { dirname as dirname2 } from "path";
var TokenManager = class {
oauth2Client;
tokenPath;
accountMode;
accounts = /* @__PURE__ */ new Map();
credentials;
writeQueue = Promise.resolve();
constructor(oauth2Client) {
this.oauth2Client = oauth2Client;
this.tokenPath = getSecureTokenPath2();
this.accountMode = getAccountMode2();
this.credentials = {
clientId: oauth2Client._clientId,
clientSecret: oauth2Client._clientSecret,
redirectUri: oauth2Client._redirectUri
};
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 (supports arbitrary account IDs)
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}
`);
}
}
isFileNotFoundError(error) {
return error instanceof Error && "code" in error && error.code === "ENOENT";
}
async writeTokenFile(tokens) {
await this.ensureTokenDirectoryExists();
await fs3.writeFile(this.tokenPath, JSON.stringify(tokens, null, 2), { mode: 384 });
}
async loadMultiAccountTokens() {
try {
const fileContent = await fs3.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 (this.isFileNotFoundError(error)) {
return {};
}
throw error;
}
}
/**
* Raw token file read without migration logic.
* Used for atomic read-modify-write operations where we need to re-read current state.
*/
async loadMultiAccountTokensRaw() {
try {
const fileContent = await fs3.readFile(this.tokenPath, "utf-8");
return JSON.parse(fileContent);
} catch (error) {
if (this.isFileNotFoundError(error)) {
return {};
}
throw error;
}
}
async saveMultiAccountTokens(multiAccountTokens) {
return this.enqueueTokenWrite(async () => {
await this.writeTokenFile(multiAccountTokens);
});
}
enqueueTokenWrite(operation) {
const pendingWrite = this.writeQueue.catch(() => void 0).then(operation);
this.writeQueue = pendingWrite.catch((error) => {
process.stderr.write(`Error writing token file: ${error instanceof Error ? error.message : error}
`);
throw error;
}).catch(() => void 0);
return pendingWrite;
}
setupTokenRefresh() {
this.setupTokenRefreshForAccount(this.oauth2Client, this.accountMode);
}
/**
* Set up token refresh handler for a specific account
* Uses enqueueTokenWrite to prevent race conditions when multiple accounts refresh simultaneously
*/
setupTokenRefreshForAccount(client, accountId2) {
client.on("tokens", async (newTokens) => {
try {
await this.enqueueTokenWrite(async () => {
const multiAccountTokens = await this.loadMultiAccountTokens();
const currentTokens = multiAccountTokens[accountId2] || {};
const updatedTokens = {
...currentTokens,
...newTokens,
refresh_token: newTokens.refresh_token || currentTokens.refresh_token
};
multiAccountTokens[accountId2] = updatedTokens;
await this.writeTokenFile(multiAccountTokens);
});
if (process.env.NODE_ENV !== "test") {
process.stderr.write(`Tokens updated and saved for ${accountId2} account
`);
}
} catch (error) {
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 fs3.access(legacyPath).then(() => true).catch(() => false)) {
return false;
}
const legacyTokens = JSON.parse(await fs3.readFile(legacyPath, "utf-8"));
if (!legacyTokens || typeof legacyTokens !== "object") {
process.stderr.write("Invalid legacy token format, skipping migration\n");
return false;
}
await this.writeTokenFile(legacyTokens);
process.stderr.write(`Migrated tokens from legacy location: ${legacyPath} to: ${this.tokenPath}
`);
try {
await fs3.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 fs3.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) {
const msg = error instanceof Error ? error.message : String(error);
process.stderr.write(`Error loading tokens for ${this.accountMode} account: ${msg}
`);
if (error instanceof SyntaxError) {
try {
await fs3.unlink(this.tokenPath);
process.stderr.write("Removed corrupted token file\n");
} catch {
}
}
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, email) {
try {
await this.enqueueTokenWrite(async () => {
const multiAccountTokens = await this.loadMultiAccountTokens();
const cachedTokens = { ...tokens };
if (email) {
cachedTokens.cached_email = email;
}
multiAccountTokens[this.accountMode] = cachedTokens;
await this.writeTokenFile(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({});
await this.enqueueTokenWrite(async () => {
const multiAccountTokens = await this.loadMultiAccountTokens();
delete multiAccountTokens[this.accountMode];
if (Object.keys(multiAccountTokens).length === 0) {
await fs3.unlink(this.tokenPath);
process.stderr.write(`All tokens cleared, file deleted
`);
} else {
await this.writeTokenFile(multiAccountTokens);
process.stderr.write(`Tokens cleared for ${this.accountMode} account
`);
}
});
} catch (error) {
if (this.isFileNotFoundError(error)) {
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 [];
}
}
/**
* Remove a specific account's tokens from storage.
* @param accountId - The account ID to remove
* @throws Error if account doesn't exist or removal fails
*/
async removeAccount(accountId2) {
const normalizedId = accountId2.toLowerCase();
await this.enqueueTokenWrite(async () => {
const multiAccountTokens = await this.loadMultiAccountTokens();
if (!multiAccountTokens[normalizedId]) {
throw new Error(`Account "${normalizedId}" not found`);
}
delete multiAccountTokens[normalizedId];
if (Object.keys(multiAccountTokens).length === 0) {
await fs3.unlink(this.tokenPath);
process.stderr.write(`All tokens cleared, file deleted
`);
} else {
await this.writeTokenFile(multiAccountTokens);
process.stderr.write(`Account "${normalizedId}" removed successfully
`);
}
this.accounts.delete(normalizedId);
});
}
// Method to switch to a different account (supports arbitrary account IDs)
async switchAccount(newMode) {
this.accountMode = newMode;
return this.loadSavedTokens();
}
/**
* Load all authenticated accounts from token file
* Returns a Map of account ID to OAuth2Client
*
* Reuses existing OAuth2Client instances to prevent memory leaks
* Sets up token refresh handlers for new accounts
*/
async loadAllAccounts() {
try {
const multiAccountTokens = await this.loadMultiAccountTokens();
for (const accountId2 of this.accounts.keys()) {
if (!multiAccountTokens[accountId2]) {
const client = this.accounts.get(accountId2);
if (client) {
client.removeAllListeners("tokens");
}
this.accounts.delete(accountId2);
}
}
for (const [accountId2, tokens] of Object.entries(multiAccountTokens)) {
try {
validateAccountId(accountId2);
if (!tokens || typeof tokens !== "object" || !tokens.access_token) {
continue;
}
let client = this.accounts.get(accountId2);
if (!client) {
client = new OAuth2Client2(
this.credentials.clientId,
this.credentials.clientSecret,
this.credentials.redirectUri
);
this.setupTokenRefreshForAccount(client, accountId2);
this.accounts.set(accountId2, client);
}
client.setCredentials(tokens);
} catch (error) {
if (process.env.NODE_ENV !== "test") {
process.stderr.write(`Skipping invalid account "${accountId2}": ${error}
`);
}
continue;
}
}
return this.accounts;
} catch (error) {
if (error && error.code === "ENOENT") {
return /* @__PURE__ */ new Map();
}
throw error;
}
}
/**
* Get OAuth2Client for a specific account
* @param accountId The account ID to retrieve
* @throws Error if account not found or invalid
*/
getClient(accountId2) {
validateAccountId(accountId2);
const client = this.accounts.get(accountId2);
if (!client) {
throw new Error(`Account "${accountId2}" not found. Please authenticate this account first.`);
}
return client;
}
/**
* List all authenticated accounts with their email addresses, status, and calendars
* Uses cached data when available to avoid repeated API calls
*/
async listAccounts() {
try {
const multiAccountTokens = await this.loadMultiAccountTokens();
const accountList = [];
let tokensUpdated = false;
const CALENDAR_CACHE_TTL = 5 * 60 * 1e3;
for (const [accountId2, tokens] of Object.entries(multiAccountTokens)) {
if (!tokens || typeof tokens !== "object") {
continue;
}
let client = null;
if (tokens.access_token || tokens.refresh_token) {
try {
client = new OAuth2Client2(
this.credentials.clientId,
this.credentials.clientSecret,
this.credentials.redirectUri
);
client.setCredentials(tokens);
if (tokens.refresh_token && (!tokens.access_token || tokens.expiry_date && tokens.expiry_date < Date.now())) {
try {
const response = await client.refreshAccessToken();
client.setCredentials(response.credentials);
Object.assign(tokens, response.credentials);
tokensUpdated = true;
} catch {
}
}
} catch {
client = null;
}
}
let email = tokens.cached_email || "unknown";
if (!tokens.cached_email && client) {
try {
email = await this.getUserEmail(client);
if (email !== "unknown") {
tokens.cached_email = email;
tokensUpdated = true;
}
} catch {
}
}
let calendars = tokens.cached_calendars || [];
const cacheExpired = !tokens.calendars_cached_at || Date.now() - tokens.calendars_cached_at > CALENDAR_CACHE_TTL;
if (cacheExpired && client) {
try {
calendars = await this.fetchCalendarsForClient(client);
tokens.cached_calendars = calendars;
tokens.calendars_cached_at = Date.now();
tokensUpdated = true;
} catch {
}
}
let status = "active";
if (!tokens.refresh_token) {
if (!tokens.access_token || tokens.expiry_date && tokens.expiry_date < Date.now()) {
status = "expired";
}
}
accountList.push({ id: accountId2, email, status, calendars });
}
if (tokensUpdated) {
await this.enqueueTokenWrite(async () => {
const latestTokens = await this.loadMultiAccountTokensRaw();
for (const accountId2 of Object.keys(multiAccountTokens)) {
const localUpdates = multiAccountTokens[accountId2];
const latestAccount = latestTokens[accountId2];
if (latestAccount && localUpdates) {
if (localUpdates.cached_email) {
latestAccount.cached_email = localUpdates.cached_email;
}
if (localUpdates.cached_calendars) {
latestAccount.cached_calendars = localUpdates.cached_calendars;
latestAccount.calendars_cached_at = localUpdates.calendars_cached_at;
}
}
}
await this.writeTokenFile(latestTokens);
});
}
return accountList;
} catch (error) {
return [];
}
}
/**
* Fetch calendars for a specific OAuth2Client
*/
async fetchCalendarsForClient(client) {
const { google: google5 } = await import("googleapis");
const calendar = google5.calendar({ version: "v3", auth: client });
const response = await calendar.calendarList.list();
const items = response.data.items || [];
const calendars = items.map((cal) => ({
id: cal.id || "",
summary: cal.summary || "",
summaryOverride: cal.summaryOverride || void 0,
accessRole: cal.accessRole || "reader",
primary: cal.primary || false,
backgroundColor: cal.backgroundColor || void 0
}));
calendars.sort((a, b) => {
if (a.primary && !b.primary) return -1;
if (!a.primary && b.primary) return 1;
return (a.summaryOverride || a.summary).localeCompare(b.summaryOverride || b.summary);
});
return calendars;
}
/**
* Get user email address from OAuth2Client
* First tries getTokenInfo, then falls back to primary calendar ID
*/
async getUserEmail(client) {
try {
const tokenInfo = await client.getTokenInfo(client.credentials.access_token || "");
if (tokenInfo.email) {
return tokenInfo.email;
}
} catch {
}
try {
const { google: google5 } = await import("googleapis");
const calendar = google5.calendar({ version: "v3", auth: client });
const response = await calendar.calendars.get({ calendarId: "primary" });
const primaryId = response.data.id;
if (primaryId && primaryId.includes("@")) {
return primaryId;
}
} catch {
}
return "unknown";
}
};
// src/auth/server.ts
init_client();
init_utils();
import crypto from "crypto";
import http from "http";
import { URL as URL2 } from "url";
import open from "open";
// src/web/templates.ts
import fs4 from "fs/promises";
import path3 from "path";
import { fileURLToPath as fileURLToPath2 } from "url";
var __filename = fileURLToPath2(import.meta.url);
var __dirname = path3.dirname(__filename);
function escapeHtml(text) {
const htmlEscapes = {
"&": "&",
"<": "<",
">": ">",
'"': """,
"'": "'"
};
return text.replace(/[&<>"']/g, (char) => htmlEscapes[char]);
}
async function loadWebFile(fileName) {
const locations = [
path3.join(__dirname, fileName),
// src/web/file.html (source)
path3.join(__dirname, "web", fileName)
// build/web/file.html (bundled)
];
for (const filePath of locations) {
try {
await fs4.access(filePath);
return fs4.readFile(filePath, "utf-8");
} catch {
}
}
throw new Error(`Web file not found: ${fileName}. Tried: ${locations.join(", ")}`);
}
async function loadTemplate(templateName) {
return loadWebFile(templateName);
}
async function renderAuthSuccess(params) {
const template = await loadTemplate("auth-success.html");
const safeAccountId = escapeHtml(params.accountId);
let accountInfoSection;
if (params.email) {
accountInfoSection = `
<p class="account-email">${escapeHtml(params.email)}</p>
<p class="account-label">Saved as <code>${safeAccountId}</code></p>`;
} else {
accountInfoSection = `
<p class="account-email">Account connected</p>
<p class="account-label">Saved as <code>${safeAccountId}</code></p>`;
}
const closeButtonSection = params.showCloseButton ? `<button onclick="window.close()">Close Window</button>` : "";
const scriptSection = params.postMessageOrigin ? `<script>
if (window.opener) {
window.opener.postMessage({ type: 'auth-success', accountId: ${JSON.stringify(safeAccountId)} }, ${JSON.stringify(params.postMessageOrigin)});
}
setTimeout(() => window.close(), 3000);
</script>` : "";
return template.replace("{{accountInfo}}", accountInfoSection).replace("{{closeButton}}", closeButtonSection).replace("{{script}}", scriptSection);
}
async function renderAuthError(params) {
const template = await loadTemplate("auth-error.html");
const safeError = escapeHtml(params.errorMessage);
const closeButtonSection = params.showCloseButton ? `<button onclick="window.close()">Close Window</button>` : "";
return template.replace("{{errorMessage}}", safeError).replace("{{closeButton}}", closeButtonSection);
}
async function renderAuthLanding(params) {
const template = await loadTemplate("auth-landing.html");
const safeAccountId = escapeHtml(params.accountId);
const safeAuthUrl = escapeHtml(params.authUrl);
return template.replace(/\{\{accountId\}\}/g, safeAccountId).replace("{{authUrl}}", safeAuthUrl);
}
// src/auth/server.ts
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
mcpToolTimeout = null;
// Timeout for MCP tool auth flow
autoShutdownOnSuccess = false;
// Whether to auto-shutdown after successful auth
pendingAuthFlow = null;
// PKCE + state for current OAuth flow
constructor(oauth2Client) {
this.baseOAuth2Client = oauth2Client;
this.tokenManager = new TokenManager(oauth2Client);
this.portRange = { start: 3500, end: 3505 };
}
/**
* Creates the flow-specific OAuth2Client with the correct redirect URI.
*/
async createFlowOAuth2Client(port) {
const { client_id, client_secret } = await loadCredentials();
return new OAuth2Client3(
client_id,
client_secret,
`http://localhost:${port}/oauth2callback`
);
}
/**
* Generates an OAuth authorization URL with standard settings.
* Includes PKCE (Proof Key for Code Exchange) and a state parameter for CSRF protection.
* Requires that PKCE credentials and state have been pre-generated via preparePkceAndState().
*/
generateOAuthUrl(client) {
if (!this.pendingAuthFlow) {
throw new Error("Auth flow not initialized. Call preparePkceAndState() before generating auth URL.");
}
return client.generateAuthUrl({
access_type: "offline",
scope: ["https://www.googleapis.com/auth/calendar"],
prompt: "consent",
code_challenge_method: CodeChallengeMethod.S256,
code_challenge: this.pendingAuthFlow.codeChallenge,
state: this.pendingAuthFlow.state
});
}
/**
* Generates and stores PKCE verifier/challenge pair and a random state parameter.
* Must be called once per auth flow, before generateOAuthUrl().
* Subsequent calls to generateOAuthUrl() or landing page visits reuse these values.
*/
async preparePkceAndState(client) {
const { codeVerifier, codeChallenge } = await client.generateCodeVerifierAsync();
if (!codeChallenge) {
throw new Error("Failed to generate PKCE code challenge");
}
this.pendingAuthFlow = {
codeVerifier,
codeChallenge,
state: crypto.randomBytes(32).toString("hex")
};
}
async sendErrorPage(res, statusCode, errorMessage) {
const errorHtml = await renderAuthError({ errorMessage });
res.writeHead(statusCode, { "Content-Type": "text/html; charset=utf-8" });
res.end(errorHtml);
}
createServer() {
const server = http.createServer(async (req, res) => {
const url = new URL2(req.url || "/", `http://${req.headers.host}`);
if (url.pathname === "/styles.css") {
const css = await loadWebFile("styles.css");
res.writeHead(200, { "Content-Type": "text/css; charset=utf-8" });
res.end(css);
} else if (url.pathname === "/") {
try {
const clientForUrl = this.flowOAuth2Client || this.baseOAuth2Client;
const authUrl = this.generateOAuthUrl(clientForUrl);
const accountMode = getAccountMode2();
const landingHtml = await renderAuthLanding({
accountId: accountMode,
authUrl
});
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(landingHtml);
} catch (error) {
await this.sendErrorPage(res, 500, "Authentication flow not ready. Please restart the auth process.");
}
} else if (url.pathname === "/oauth2callback") {
const code = url.searchParams.get("code");
if (!code) {
await this.sendErrorPage(res, 400, "Authorization code missing");
return;
}
const returnedState = url.searchParams.get("state");
if (!this.pendingAuthFlow || !returnedState || returnedState !== this.pendingAuthFlow.state) {
process.stderr.write(`\u2717 OAuth callback rejected: invalid state parameter (possible CSRF attempt)
`);
await this.sendErrorPage(res, 403, "Invalid state parameter. This may indicate a CSRF attack or an expired authentication session. Please try authenticating again.");
return;
}
if (!this.flowOAuth2Client) {
await this.sendErrorPage(res, 500, "Authentication flow not properly initiated.");
return;
}
try {
const { tokens } = await this.flowOAuth2Client.getToken({
code,
codeVerifier: this.pendingAuthFlow.codeVerifier
});
this.pendingAuthFlow = null;
await this.tokenManager.saveTokens(tokens);
this.authCompletedSuccessfully = true;
const tokenPath = this.tokenManager.getTokenPath();
const accountMode = this.tokenManager.getAccountMode();
if (this.autoShutdownOnSuccess) {
if (this.mcpToolTimeout) {
clearTimeout(this.mcpToolTimeout);
this.mcpToolTimeout = null;
}
setTimeout(() => {
this.stop().catch(() => {
});
}, 2e3);
}
const successHtml = await renderAuthSuccess({
accountId: accountMode,
tokenPath
});
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(successHtml);
} catch (error) {
this.authCompletedSuccessfully = false;
this.pendingAuthFlow = null;
const message = error instanceof Error ? error.message : "Unknown error";
process.stderr.write(`\u2717 Token save failed: ${message}
`);
await this.sendErrorPage(res, 500, message);
}
} 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) {
process.stderr.write(`Could not start auth server on available port. Please check port availability (${this.portRange.start}-${this.portRange.end}) and try again.
`);
this.authCompletedSuccessfully = false;
return false;
}
try {
this.flowOAuth2Client = await this.createFlowOAuth2Client(port);
} catch (error) {
process.stderr.write(`\u2717 Failed to load OAuth credentials: ${error instanceof Error ? error.message : "Unknown error"}
`);
this.authCompletedSuccessfully = false;
await this.stop();
return false;
}
try {
await this.preparePkceAndState(this.flowOAuth2Client);
} catch (error) {
process.stderr.write(`\u2717 Failed to initialize PKCE: ${error instanceof Error ? error.message : "Unknown error"}
`);
this.authCompletedSuccessfully = false;
await this.stop();
return false;
}
const authorizeUrl = this.generateOAuthUrl(this.flowOAuth2Client);
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() {
if (this.mcpToolTimeout) {
clearTimeout(this.mcpToolTimeout);
this.mcpToolTimeout = null;
}
this.autoShutdownOnSuccess = false;
this.pendingAuthFlow = null;
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();
}
});
}
/**
* Start the auth server for use by an MCP tool.
*
* Unlike the regular start() method:
* - Does not open the browser automatically
* - Returns the auth URL for the MCP tool to return to the user
* - Auto-shutdowns after successful auth or timeout (5 minutes)
* - Does not validate existing tokens (allows adding new accounts)
*
* @param accountId - The account ID to authenticate
* @returns Result with auth URL on success, or error on failure
*/
async startForMcpTool(accountId2) {
if (this.server) {
await this.stop();
}
this.tokenManager.setAccountMode(accountId2);
const port = await this.startServerOnAvailablePort();
if (port === null) {
return {
success: false,
error: `Could not start auth server. Ports ${this.portRange.start}-${this.portRange.end} may be in use.`
};
}
try {
this.flowOAuth2Client = await this.createFlowOAuth2Client(port);
} catch (error) {
await this.stop();
return {
success: false,
error: `Failed to load OAuth credentials: ${error instanceof Error ? error.message : "Unknown error"}`
};
}
try {
await this.preparePkceAndState(this.flowOAuth2Client);
} catch (error) {
await this.stop();
return {
success: false,
error: `Failed to initialize PKCE: ${error instanceof Error ? error.message : "Unknown error"}`
};
}
const authUrl = this.generateOAuthUrl(this.flowOAuth2Client);
this.autoShutdownOnSuccess = true;
this.authCompletedSuccessfully = false;
this.mcpToolTimeout = setTimeout(async () => {
if (!this.authCompletedSuccessfully) {
process.stderr.write(`Auth timeout for account "${accountId2}" - shutting down auth server
`);
await this.stop();
}
}, 5 * 60 * 1e3);
return {
success: true,
authUrl,
callbackUrl: `http://localhost:${port}/oauth2callback`
};
}
};
// src/tools/registry.ts
import { z } from "zod";
// src/utils/field-mask-builder.ts
var ALLOWED_EVENT_FIELDS = [
"id",
"summary",
"description",
"start",
"end",
"location",
"attendees",
"colorId",
"transparency",
"extendedProperties",
"reminders",
"conferenceData",
"attachments",
"status",
"htmlLink",
"created",
"updated",
"creator",
"organizer",
"recurrence",
"recurringEventId",
"originalStartTime",
"visibility",
"iCalUID",
"sequence",
"hangoutLink",
"anyoneCanAddSelf",
"guestsCanInviteOthers",
"guestsCanModify",
"guestsCanSeeOtherGuests",
"privateCopy",
"locked",
"source",
"eventType"
];
var DEFAULT_EVENT_FIELDS = [
"id",
"summary",
"start",
"end",
"status",
"htmlLink",
"location",
"attendees",
"reminders",
"recurrence"
];
function validateFields(fields) {
const validFields = [];
const invalidFields = [];
for (const field of fields) {
if (ALLOWED_EVENT_FIELDS.includes(field)) {
validFields.push(field);
} else {
invalidFields.push(field);
}
}
if (invalidFields.length > 0) {
throw new Error(`Invalid fields requested: ${invalidFields.join(", ")}. Allowed fields: ${ALLOWED_EVENT_FIELDS.join(", ")}`);
}
return validFields;
}
function prepareFields(requestedFields, includeDefaults = true) {
if (!requestedFields || requestedFields.length === 0) {
return void 0;
}
const validFields = validateFields(requestedFields);
return includeDefaults ? [.../* @__PURE__ */ new Set([...DEFAULT_EVENT_FIELDS, ...validFields])] : validFields;
}
function buildEventFieldMask(requestedFields, includeDefaults = true) {
const fields = prepareFields(requestedFields, includeDefaults);
if (!fields) return void 0;
return `items(${fields.join(",")})`;
}
function buildSingleEventFieldMask(requestedFields, includeDefaults = true) {
const fields = prepareFields(requestedFields, includeDefaults);
if (!fields) return void 0;
return fields.join(",");
}
function buildListFieldMask(requestedFields, includeDefaults = true) {
if (!requestedFields || requestedFields.length === 0) {
return void 0;
}
const eventFieldMask = buildEventFieldMask(requestedFields, includeDefaults);
if (!eventFieldMask) {
return void 0;
}
return `${eventFieldMask},nextPageToken,nextSyncToken,kind,etag,summary,updated,timeZone,accessRole,defaultReminders`;
}
// src/handlers/core/BaseToolHandler.ts
init_utils();
import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js";
import { GaxiosError as GaxiosError2 } from "gaxios";
import { google as google2 } from "googleapis";
// src/services/CalendarRegistry.ts
init_utils();
import { google } from "googleapis";
var PERMISSION_RANK = {
"owner": 4,
"writer": 3,
"reader": 2,
"freeBusyReader": 1
};
var CalendarRegistry = class _CalendarRegistry {
static instance = null;
cache = /* @__PURE__ */ new Map();
CACHE_TTL = 5 * 60 * 1e3;
// 5 minutes
// Track in-flight requests to prevent duplicate API calls during concurrent access
inFlightRequests = /* @__PURE__ */ new Map();
/**
* Get the singleton instance of CalendarRegistry
*/
static getInstance() {
if (!_CalendarRegistry.instance) {
_CalendarRegistry.instance = new _CalendarRegistry();
}
return _CalendarRegistry.instance;
}
/**
* Reset the singleton instance (useful for testing or when accounts change)
* Clears the cache and resets the instance
*/
static resetInstance() {
if (_CalendarRegistry.instance) {
_CalendarRegistry.instance.clearCache();
}
_CalendarRegistry.instance = null;
}
/**
* Get calendar client for a specific account
*/
getCalendar(auth) {
const quotaProjectId = getCredentialsProjectId();
const config = {
version: "v3",
auth,
timeout: 3e3
};
if (quotaProjectId) {
config.quotaProjectId = quotaProjectId;
}
return google.calendar(config);
}
/**
* Fetch all calendars from all accounts and build unified registry.
* Uses in-flight request tracking to prevent duplicate API calls during concurrent access.
*/
async getUnifiedCalendars(accounts) {
const cacheKey = Array.from(accounts.keys()).sort().join(",");
const inFlight = this.inFlightRequests.get(cacheKey);
if (inFlight) {
return inFlight;
}
const cached = this.cache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < this.CACHE_TTL) {
return cached.data;
}
const requestPromise = this.fetchAndBuildUnifiedCalendars(accounts, cacheKey);
this.inFlightRequests.set(cacheKey, requestPromise);
try {
return await requestPromise;
} finally {
this.inFlightRequests.delete(cacheKey);
}
}
/**
* Internal method to fetch calendars and build the unified registry
*/
async fetchAndBuildUnifiedCalendars(accounts, cacheKey) {
const calendarsByAccount = await Promise.all(
Array.from(accounts.entries()).map(async ([accountId2, client]) => {
try {
const calendar = this.getCalendar(client);
const response = await calendar.calendarList.list();
return {
accountId: accountId2,
calendars: response.data.items || []
};
} catch (error) {
return {
accountId: accountId2,
calendars: []
};
}
})
);
const calendarMap = /* @__PURE__ */ new Map();
for (const { accountId: accountId2, calendars } of calendarsByAccount) {
for (const cal of calendars) {
if (!cal.id) continue;
const access = {
accountId: accountId2,
accessRole: cal.accessRole || "reader",
primary: cal.primary || false,
summary: cal.summary || cal.id,
summaryOverride: cal.summaryOverride ?? void 0
};
const existing = calendarMap.get(cal.id) || [];
existing.push(access);
calendarMap.set(cal.id, existing);
}
}
const unified = Array.from(calendarMap.entries()).map(([calendarId, accounts2]) => {
const sortedAccounts = [...accounts2].sort((a, b) => {
const rankA = PERMISSION_RANK[a.accessRole] || 0;
const rankB = PERMISSION_RANK[b.accessRole] || 0;
return rankB - rankA;
});
const preferredAccount = sortedAccounts[0].accountId;
const primaryAccess = accounts2.find((a) => a.primary);
const preferredAccess = sortedAccounts[0];
const displayName = primaryAccess?.summaryOverride || preferredAccess.summaryOverride || preferredAccess.summary;
return {
calendarId,
accounts: accounts2,
preferredAccount,
displayName
};
});
this.cache.set(cacheKey, {
data: unified,
timestamp: Date.now()
});
return unified;
}
/**
* Find which account to use for a specific calendar
* For write operations, returns account with highest permission
* For read operations, returns any account with access (prefers higher permission)
*/
async getAccountForCalendar(calendarId, accounts, operationType = "read") {
const unified = await this.getUnifiedCalendars(accounts);
const calendar = unified.find((c) => c.calendarId === calendarId);
if (!calendar) {
return null;
}
if (operationType === "write") {
const preferredAccess2 = calendar.accounts.find((a) => a.accountId === calendar.preferredAccount);
if (!preferredAccess2) return null;
if (preferredAccess2.accessRole === "owner" || preferredAccess2.accessRole === "writer") {
return {
accountId: preferredAccess2.accountId,
accessRole: preferredAccess2.accessRole
};
}
return null;
}
const preferredAccess = calendar.accounts.find((a) => a.accountId === calendar.preferredAccount);
if (!preferredAccess) return null;
return {
accountId: preferredAccess.accountId,
accessRole: preferredAccess.accessRole
};
}
/**
* Get all accounts that have access to a specific cale