@cocal/google-calendar-mcp
Version:
Google Calendar MCP Server with extensive support for calendar management
1,461 lines (1,426 loc) • 98.2 kB
JavaScript
#!/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