mcp-google-calendar-plus
Version:
The ONLY MCP server for full Google Calendar AND Contacts management (create/update/delete) in Claude, Cursor, Windsurf
1,383 lines (1,362 loc) • 102 kB
JavaScript
#!/usr/bin/env node
// src/index.ts
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
ListToolsRequestSchema,
CallToolRequestSchema
} from "@modelcontextprotocol/sdk/types.js";
import { fileURLToPath as fileURLToPath2 } from "url";
import { readFileSync } from "fs";
import { join as join2, dirname as dirname3 } from "path";
// src/auth/client.ts
import { OAuth2Client } from "google-auth-library";
import * as fs from "fs/promises";
// src/auth/utils.ts
import * as path from "path";
import * as os from "os";
import { fileURLToPath } from "url";
function getProjectRoot() {
const __dirname2 = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.join(__dirname2, "..");
return path.resolve(projectRoot);
}
function getSecureTokenPath() {
const customTokenPath = process.env.GOOGLE_CALENDAR_MCP_TOKEN_PATH;
if (customTokenPath) {
return path.resolve(customTokenPath);
}
const configHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
const tokenDir = path.join(configHome, "google-calendar-mcp");
return path.join(tokenDir, "tokens.json");
}
function getLegacyTokenPath() {
const projectRoot = getProjectRoot();
return path.join(projectRoot, ".gcp-saved-tokens.json");
}
function getKeysFilePath() {
const envCredentialsPath = process.env.GOOGLE_OAUTH_CREDENTIALS;
if (envCredentialsPath) {
return path.resolve(envCredentialsPath);
}
const projectRoot = getProjectRoot();
const keysPath = path.join(projectRoot, "gcp-oauth.keys.json");
return keysPath;
}
function generateCredentialsErrorMessage() {
return `
OAuth credentials not found. Please provide credentials using one of these methods:
1. Direct environment variables (simplest - no JSON file needed!):
Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET:
export GOOGLE_CLIENT_ID="your-client-id.apps.googleusercontent.com"
export GOOGLE_CLIENT_SECRET="your-client-secret"
2. Credentials file via environment variable:
Set GOOGLE_OAUTH_CREDENTIALS to the path of your credentials file:
export GOOGLE_OAUTH_CREDENTIALS="/path/to/gcp-oauth.keys.json"
3. Default file path:
Place your gcp-oauth.keys.json file in the package root directory.
Token storage:
- Tokens are saved to: ${getSecureTokenPath()}
- 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 (Desktop app type)
5. Copy the Client ID and Client Secret
`.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() {
const clientId = process.env.GOOGLE_CLIENT_ID;
const clientSecret = process.env.GOOGLE_CLIENT_SECRET;
if (clientId && clientSecret) {
return {
client_id: clientId,
client_secret: clientSecret,
redirect_uris: ["http://localhost:3000/oauth2callback"]
};
}
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();
const oauth2Client2 = new OAuth2Client({
clientId: credentials.client_id,
clientSecret: credentials.client_secret,
redirectUri: credentials.redirect_uris[0]
});
return oauth2Client2;
} 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 express from "express";
import { OAuth2Client as OAuth2Client2 } from "google-auth-library";
// src/auth/tokenManager.ts
import * as fs2 from "fs/promises";
import * as path2 from "path";
import { GaxiosError } from "gaxios";
var TokenManager = class {
oauth2Client;
tokenPath;
constructor(oauth2Client2) {
this.oauth2Client = oauth2Client2;
this.tokenPath = getSecureTokenPath();
this.setupTokenRefresh();
}
// Method to expose the token path
getTokenPath() {
return this.tokenPath;
}
async ensureTokenDirectoryExists() {
try {
const dir = path2.dirname(this.tokenPath);
await fs2.mkdir(dir, { recursive: true });
} catch (error) {
if (error instanceof Error && "code" in error && error.code !== "EEXIST") {
console.error("Failed to create token directory:", error);
throw error;
}
}
}
setupTokenRefresh() {
this.oauth2Client.on("tokens", async (newTokens) => {
try {
await this.ensureTokenDirectoryExists();
const currentTokens = JSON.parse(await fs2.readFile(this.tokenPath, "utf-8"));
const updatedTokens = {
...currentTokens,
...newTokens,
refresh_token: newTokens.refresh_token || currentTokens.refresh_token
};
await fs2.writeFile(this.tokenPath, JSON.stringify(updatedTokens, null, 2), {
mode: 384
});
console.error("Tokens updated and saved");
} catch (error) {
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
try {
await fs2.writeFile(this.tokenPath, JSON.stringify(newTokens, null, 2), { mode: 384 });
console.error("New tokens saved");
} catch (writeError) {
console.error("Error saving initial tokens:", writeError);
}
} else {
console.error("Error saving updated tokens:", error);
}
}
});
}
async migrateLegacyTokens() {
const legacyPath = getLegacyTokenPath();
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") {
console.error("Invalid legacy token format, skipping migration");
return false;
}
await this.ensureTokenDirectoryExists();
await fs2.writeFile(this.tokenPath, JSON.stringify(legacyTokens, null, 2), {
mode: 384
});
console.error("Migrated tokens from legacy location:", legacyPath, "to:", this.tokenPath);
try {
await fs2.unlink(legacyPath);
console.error("Removed legacy token file");
} catch (unlinkErr) {
console.error("Warning: Could not remove legacy token file:", unlinkErr);
}
return true;
} catch (error) {
console.error("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) {
console.error("No token file found at:", this.tokenPath);
return false;
}
}
const tokens = JSON.parse(await fs2.readFile(this.tokenPath, "utf-8"));
if (!tokens || typeof tokens !== "object") {
console.error("Invalid token format in file:", this.tokenPath);
return false;
}
this.oauth2Client.setCredentials(tokens);
return true;
} catch (error) {
console.error("Error loading tokens:", error);
if (error instanceof Error && "code" in error && error.code !== "ENOENT") {
try {
await fs2.unlink(this.tokenPath);
console.error("Removed potentially corrupted token file");
} 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) {
console.error("Auth token expired or nearing expiry, 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);
console.error("Token refreshed successfully");
return true;
} catch (refreshError) {
if (refreshError instanceof GaxiosError && refreshError.response?.data?.error === "invalid_grant") {
console.error("Error refreshing auth token: Invalid grant. Token likely expired or revoked. Please re-authenticate.");
return false;
} else {
console.error("Error refreshing auth token:", refreshError);
return false;
}
}
} else if (!this.oauth2Client.credentials.access_token && !this.oauth2Client.credentials.refresh_token) {
console.error("No access or refresh token available. Please re-authenticate.");
return false;
} else {
return true;
}
}
async validateTokens() {
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;
}
}
return this.refreshTokensIfNeeded();
}
async saveTokens(tokens) {
try {
await this.ensureTokenDirectoryExists();
await fs2.writeFile(this.tokenPath, JSON.stringify(tokens, null, 2), { mode: 384 });
this.oauth2Client.setCredentials(tokens);
console.error("Tokens saved successfully to:", this.tokenPath);
} catch (error) {
console.error("Error saving tokens:", error);
throw error;
}
}
async clearTokens() {
try {
this.oauth2Client.setCredentials({});
await fs2.unlink(this.tokenPath);
console.error("Tokens cleared successfully");
} catch (error) {
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
console.error("Token file already deleted");
} else {
console.error("Error clearing tokens:", error);
}
}
}
};
// src/auth/server.ts
import open from "open";
var AuthServer = class {
baseOAuth2Client;
// Used by TokenManager for validation/refresh
flowOAuth2Client = null;
// Used specifically for the auth code flow
app;
server = null;
tokenManager;
portRange;
authCompletedSuccessfully = false;
// Flag for standalone script
constructor(oauth2Client2) {
this.baseOAuth2Client = oauth2Client2;
this.tokenManager = new TokenManager(oauth2Client2);
this.app = express();
this.portRange = { start: 3e3, end: 3004 };
this.setupRoutes();
}
setupRoutes() {
this.app.get("/", (req, res) => {
const clientForUrl = this.flowOAuth2Client || this.baseOAuth2Client;
const scopes = [
"https://www.googleapis.com/auth/calendar",
"https://www.googleapis.com/auth/contacts"
];
const authUrl = clientForUrl.generateAuthUrl({
access_type: "offline",
scope: scopes,
prompt: "consent"
});
res.send(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Google Calendar & Contacts MCP Authentication</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; display: flex; justify-content: center; align-items: center; min-height: 100vh; background-color: #f5f5f5; margin: 0; padding: 20px; }
.container { text-align: center; padding: 2.5em; background-color: #fff; border-radius: 12px; box-shadow: 0 4px 6px rgba(0,0,0,0.1); max-width: 500px; }
h1 { color: #1a73e8; margin-bottom: 0.5em; }
h2 { color: #333; font-weight: normal; font-size: 1.2em; margin-bottom: 1.5em; }
p { color: #666; line-height: 1.6; margin-bottom: 1.5em; }
.btn { display: inline-block; background-color: #1a73e8; color: white; padding: 12px 32px; text-decoration: none; border-radius: 6px; font-size: 16px; font-weight: 500; transition: background-color 0.2s; }
.btn:hover { background-color: #1557b0; }
.permissions { background-color: #f8f9fa; padding: 1em; border-radius: 8px; margin: 1.5em 0; text-align: left; }
.permissions h3 { margin: 0 0 0.5em 0; font-size: 1em; color: #333; }
.permissions ul { margin: 0; padding-left: 1.5em; color: #666; }
.permissions li { margin: 0.3em 0; }
.footer { margin-top: 2em; font-size: 0.9em; color: #999; }
</style>
</head>
<body>
<div class="container">
<h1>\u{1F5D3}\uFE0F Google Calendar & Contacts MCP</h1>
<h2>Authentication Required</h2>
<p>Claude Desktop needs permission to access your Google Calendar and Contacts.</p>
<div class="permissions">
<h3>This will allow Claude to:</h3>
<ul>
<li>View your calendar events</li>
<li>Create new calendar events</li>
<li>Update existing events</li>
<li>Delete events</li>
<li>Check your availability</li>
<li>View and manage your contacts</li>
<li>Create new contacts</li>
<li>Update existing contacts</li>
<li>Delete contacts</li>
</ul>
</div>
<a href="${authUrl}" class="btn">Connect Google Calendar & Contacts</a>
<p class="footer">You'll be redirected to Google to sign in securely.<br>Your credentials are never stored by this application.</p>
</div>
</body>
</html>
`);
});
this.app.get("/oauth2callback", async (req, res) => {
const code = req.query.code;
if (!code) {
res.status(400).send("Authorization code missing");
return;
}
if (!this.flowOAuth2Client) {
res.status(500).send("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();
res.send(`
<!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; }
</style>
</head>
<body>
<div class="container">
<h1>Authentication Successful!</h1>
<p>Your authentication tokens have been saved successfully 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";
res.status(500).send(`
<!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>
`);
}
});
}
async start(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;
}
if (openBrowser) {
const authorizeUrl = this.flowOAuth2Client.generateAuthUrl({
access_type: "offline",
scope: [
"https://www.googleapis.com/auth/calendar",
"https://www.googleapis.com/auth/contacts"
],
prompt: "consent"
});
await open(authorizeUrl);
}
return true;
}
async startServerOnAvailablePort() {
for (let port = this.portRange.start; port <= this.portRange.end; port++) {
try {
await new Promise((resolve2, reject) => {
const testServer = this.app.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) {
this.server.close((err) => {
if (err) {
reject(err);
} else {
this.server = null;
resolve2();
}
});
} else {
resolve2();
}
});
}
};
// src/handlers/listTools.ts
var remindersInputProperty = {
type: "object",
description: "Reminder settings for the event",
properties: {
useDefault: {
type: "boolean",
description: "Whether to use the default reminders"
},
overrides: {
type: "array",
description: "Custom reminders (uses popup notifications by default unless email is specified)",
items: {
type: "object",
properties: {
method: {
type: "string",
enum: ["email", "popup"],
description: "Reminder method (defaults to popup unless email is specified)",
default: "popup"
},
minutes: {
type: "number",
description: "Minutes before the event to trigger the reminder"
}
},
required: ["minutes"]
}
}
},
required: ["useDefault"]
};
function getToolDefinitions() {
return {
tools: [
{
name: "list-calendars",
description: "List all available calendars",
inputSchema: {
type: "object",
properties: {},
// No arguments needed
required: []
}
},
{
name: "list-events",
description: "List events from one or more calendars",
inputSchema: {
type: "object",
properties: {
calendarId: {
oneOf: [
{
type: "string",
description: "ID of a single calendar"
},
{
type: "array",
description: "Array of calendar IDs",
items: {
type: "string"
},
minItems: 1,
maxItems: 50
}
],
description: "ID of the calendar(s) to list events from (use 'primary' for the main calendar)"
},
timeMin: {
type: "string",
format: "date-time",
description: "Start time in ISO format with timezone required (e.g., 2024-01-01T00:00:00Z or 2024-01-01T00:00:00+00:00). Date-time must end with Z (UTC) or +/-HH:MM offset."
},
timeMax: {
type: "string",
format: "date-time",
description: "End time in ISO format with timezone required (e.g., 2024-12-31T23:59:59Z or 2024-12-31T23:59:59+00:00). Date-time must end with Z (UTC) or +/-HH:MM offset."
}
},
required: ["calendarId"]
}
},
{
name: "search-events",
description: "Search for events in a calendar by text query",
inputSchema: {
type: "object",
properties: {
calendarId: {
type: "string",
description: "ID of the calendar to search events in (use 'primary' for the main calendar)"
},
query: {
type: "string",
description: "Free text search query (searches summary, description, location, attendees, etc.)"
},
timeMin: {
type: "string",
format: "date-time",
description: "Start time boundary in ISO format with timezone required (e.g., 2024-01-01T00:00:00Z or 2024-01-01T00:00:00+00:00). Date-time must end with Z (UTC) or +/-HH:MM offset."
},
timeMax: {
type: "string",
format: "date-time",
description: "End time boundary in ISO format with timezone required (e.g., 2024-12-31T23:59:59Z or 2024-12-31T23:59:59+00:00). Date-time must end with Z (UTC) or +/-HH:MM offset."
}
},
required: ["calendarId", "query"]
}
},
{
name: "list-colors",
description: "List available color IDs and their meanings for calendar events",
inputSchema: {
type: "object",
properties: {},
// No arguments needed
required: []
}
},
{
name: "create-event",
description: "Create a new calendar event",
inputSchema: {
type: "object",
properties: {
calendarId: {
type: "string",
description: "ID of the calendar to create the event in (use 'primary' for the main calendar)"
},
summary: {
type: "string",
description: "Title of the event"
},
description: {
type: "string",
description: "Description/notes for the event (optional)"
},
start: {
type: "string",
format: "date-time",
description: "Start time in ISO format with timezone required (e.g., 2024-08-15T10:00:00Z or 2024-08-15T10:00:00-07:00). Date-time must end with Z (UTC) or +/-HH:MM offset."
},
end: {
type: "string",
format: "date-time",
description: "End time in ISO format with timezone required (e.g., 2024-08-15T11:00:00Z or 2024-08-15T11:00:00-07:00). Date-time must end with Z (UTC) or +/-HH:MM offset."
},
timeZone: {
type: "string",
description: "Timezone of the event start/end times, formatted as an IANA Time Zone Database name (e.g., America/Los_Angeles). Required if start/end times are specified, especially for recurring events."
},
location: {
type: "string",
description: "Location of the event (optional)"
},
attendees: {
type: "array",
description: "List of attendee email addresses (optional)",
items: {
type: "object",
properties: {
email: {
type: "string",
format: "email",
description: "Email address of the attendee"
}
},
required: ["email"]
}
},
colorId: {
type: "string",
description: "Color ID for the event (optional, use list-colors to see available IDs)"
},
reminders: remindersInputProperty,
recurrence: {
type: "array",
description: 'List of recurrence rules (RRULE, EXRULE, RDATE, EXDATE) in RFC5545 format (optional). Example: ["RRULE:FREQ=WEEKLY;COUNT=5"]',
items: {
type: "string"
}
}
},
required: ["calendarId", "summary", "start", "end", "timeZone"]
}
},
{
name: "update-event",
description: "Update an existing calendar event with recurring event modification scope support",
inputSchema: {
type: "object",
properties: {
calendarId: {
type: "string",
description: "ID of the calendar containing the event"
},
eventId: {
type: "string",
description: "ID of the event to update"
},
summary: {
type: "string",
description: "New title for the event (optional)"
},
description: {
type: "string",
description: "New description for the event (optional)"
},
start: {
type: "string",
format: "date-time",
description: "New start time in ISO format with timezone required (e.g., 2024-08-15T10:00:00Z or 2024-08-15T10:00:00-07:00). Date-time must end with Z (UTC) or +/-HH:MM offset."
},
end: {
type: "string",
format: "date-time",
description: "New end time in ISO format with timezone required (e.g., 2024-08-15T11:00:00Z or 2024-08-15T11:00:00-07:00). Date-time must end with Z (UTC) or +/-HH:MM offset."
},
timeZone: {
type: "string",
description: "Timezone for the start/end times (IANA format, e.g., America/Los_Angeles). Required if modifying start/end, or for recurring events."
},
location: {
type: "string",
description: "New location for the event (optional)"
},
colorId: {
type: "string",
description: "New color ID for the event (optional)"
},
attendees: {
type: "array",
description: "New list of attendee email addresses (optional, replaces existing attendees)",
items: {
type: "object",
properties: {
email: {
type: "string",
format: "email",
description: "Email address of the attendee"
}
},
required: ["email"]
}
},
reminders: {
...remindersInputProperty,
description: "New reminder settings for the event (optional)"
},
recurrence: {
type: "array",
description: 'New list of recurrence rules (RFC5545 format, optional, replaces existing rules). Example: ["RRULE:FREQ=DAILY;COUNT=10"]',
items: {
type: "string"
}
},
modificationScope: {
type: "string",
enum: ["single", "all", "future"],
default: "all",
description: "Scope of modification for recurring events: 'single' (one instance), 'all' (entire series), 'future' (this and future instances). Defaults to 'all' for backward compatibility."
},
originalStartTime: {
type: "string",
format: "date-time",
description: "Required when modificationScope is 'single'. Original start time of the specific instance to modify in ISO format with timezone (e.g., 2024-08-15T10:00:00-07:00)."
},
futureStartDate: {
type: "string",
format: "date-time",
description: "Required when modificationScope is 'future'. Start date for future modifications in ISO format with timezone (e.g., 2024-08-20T10:00:00-07:00). Must be a future date."
}
},
required: ["calendarId", "eventId", "timeZone"],
// timeZone is technically required for PATCH
allOf: [
{
if: {
properties: {
modificationScope: { const: "single" }
}
},
then: {
required: ["originalStartTime"]
}
},
{
if: {
properties: {
modificationScope: { const: "future" }
}
},
then: {
required: ["futureStartDate"]
}
}
]
}
},
{
name: "delete-event",
description: "Delete a calendar event",
inputSchema: {
type: "object",
properties: {
calendarId: {
type: "string",
description: "ID of the calendar containing the event"
},
eventId: {
type: "string",
description: "ID of the event to delete"
}
},
required: ["calendarId", "eventId"]
}
},
{
name: "get-freebusy",
description: "Retrieve free/busy information for one or more calendars within a time range",
inputSchema: {
type: "object",
properties: {
timeMin: {
type: "string",
description: "The start of the interval in RFC3339 format"
},
timeMax: {
type: "string",
description: "The end of the interval in RFC3339 format"
},
timeZone: {
type: "string",
description: "Optional. Time zone used in the response (default is UTC)"
},
groupExpansionMax: {
type: "integer",
description: "Optional. Maximum number of calendar identifiers to expand per group (max 100)"
},
calendarExpansionMax: {
type: "integer",
description: "Optional. Maximum number of calendars to expand (max 50)"
},
items: {
type: "array",
description: "List of calendar or group identifiers to check for availability",
items: {
type: "object",
properties: {
id: {
type: "string",
description: "The identifier of a calendar or group, it usually is a mail format"
}
},
required: ["id"]
}
}
},
required: ["timeMin", "timeMax", "items"]
}
},
{
name: "list-contacts",
description: "List contacts from Google Contacts",
inputSchema: {
type: "object",
properties: {
pageSize: {
type: "number",
description: "Maximum number of contacts to return (default: 100, max: 2000)"
},
pageToken: {
type: "string",
description: "Token for pagination to get the next page of results"
},
query: {
type: "string",
description: "Optional search query to filter contacts"
},
personFields: {
type: "array",
description: "Fields to include in the response (default: names,emailAddresses,phoneNumbers,addresses,organizations,biographies,photos)",
items: {
type: "string",
enum: ["addresses", "ageRanges", "biographies", "birthdays", "calendarUrls", "clientData", "coverPhotos", "emailAddresses", "events", "externalIds", "genders", "imClients", "interests", "locales", "locations", "memberships", "metadata", "miscKeywords", "names", "nicknames", "occupations", "organizations", "phoneNumbers", "photos", "relations", "sipAddresses", "skills", "urls", "userDefined"]
}
},
sources: {
type: "array",
description: "Sources to get contacts from (default: READ_SOURCE_TYPE_CONTACT)",
items: {
type: "string",
enum: ["READ_SOURCE_TYPE_CONTACT", "READ_SOURCE_TYPE_PROFILE", "READ_SOURCE_TYPE_DOMAIN_PROFILE", "READ_SOURCE_TYPE_OTHER_CONTACT"]
}
}
},
required: []
}
},
{
name: "get-contact",
description: "Get details of a specific contact",
inputSchema: {
type: "object",
properties: {
resourceName: {
type: "string",
description: "Resource name of the contact (e.g., 'people/c1234567890')"
},
personFields: {
type: "array",
description: "Fields to include in the response",
items: {
type: "string",
enum: ["addresses", "ageRanges", "biographies", "birthdays", "calendarUrls", "clientData", "coverPhotos", "emailAddresses", "events", "externalIds", "genders", "imClients", "interests", "locales", "locations", "memberships", "metadata", "miscKeywords", "names", "nicknames", "occupations", "organizations", "phoneNumbers", "photos", "relations", "sipAddresses", "skills", "urls", "userDefined"]
}
}
},
required: ["resourceName"]
}
},
{
name: "create-contact",
description: "Create a new contact in Google Contacts",
inputSchema: {
type: "object",
properties: {
givenName: {
type: "string",
description: "First name of the contact"
},
familyName: {
type: "string",
description: "Last name of the contact"
},
middleName: {
type: "string",
description: "Middle name of the contact"
},
displayName: {
type: "string",
description: "Display name (defaults to 'givenName familyName' if not provided)"
},
emailAddresses: {
type: "array",
description: "Email addresses for the contact",
items: {
type: "object",
properties: {
value: {
type: "string",
format: "email",
description: "Email address"
},
type: {
type: "string",
enum: ["home", "work", "other"],
description: "Type of email address (default: home)"
}
},
required: ["value"]
}
},
phoneNumbers: {
type: "array",
description: "Phone numbers for the contact",
items: {
type: "object",
properties: {
value: {
type: "string",
description: "Phone number"
},
type: {
type: "string",
enum: ["home", "work", "mobile", "homeFax", "workFax", "otherFax", "pager", "workMobile", "workPager", "main", "googleVoice", "other"],
description: "Type of phone number (default: home)"
}
},
required: ["value"]
}
},
addresses: {
type: "array",
description: "Physical addresses for the contact",
items: {
type: "object",
properties: {
streetAddress: {
type: "string",
description: "Street address"
},
city: {
type: "string",
description: "City"
},
region: {
type: "string",
description: "State or region"
},
postalCode: {
type: "string",
description: "Postal or ZIP code"
},
country: {
type: "string",
description: "Country"
},
type: {
type: "string",
enum: ["home", "work", "other"],
description: "Type of address (default: home)"
}
}
}
},
organizations: {
type: "array",
description: "Organizations/companies for the contact",
items: {
type: "object",
properties: {
name: {
type: "string",
description: "Organization name"
},
title: {
type: "string",
description: "Job title"
},
department: {
type: "string",
description: "Department"
},
type: {
type: "string",
enum: ["work", "school", "other"],
description: "Type of organization (default: work)"
}
}
}
},
biographies: {
type: "array",
description: "Biographical information",
items: {
type: "object",
properties: {
value: {
type: "string",
description: "Biography text"
},
contentType: {
type: "string",
enum: ["TEXT_PLAIN", "TEXT_HTML"],
description: "Content type (default: TEXT_PLAIN)"
}
},
required: ["value"]
}
},
notes: {
type: "string",
description: "Notes about the contact (will be added as a biography if biographies not provided)"
}
},
required: []
}
},
{
name: "update-contact",
description: "Update an existing contact",
inputSchema: {
type: "object",
properties: {
resourceName: {
type: "string",
description: "Resource name of the contact to update (e.g., 'people/c1234567890')"
},
updatePersonFields: {
type: "array",
description: "Fields to update (must specify which fields are being updated)",
items: {
type: "string",
enum: ["names", "emailAddresses", "phoneNumbers", "addresses", "organizations", "biographies"]
}
},
givenName: {
type: "string",
description: "First name (required if updating names)"
},
familyName: {
type: "string",
description: "Last name (required if updating names)"
},
middleName: {
type: "string",
description: "Middle name"
},
displayName: {
type: "string",
description: "Display name"
},
emailAddresses: {
type: "array",
description: "Email addresses (replaces all existing if updating)",
items: {
type: "object",
properties: {
value: {
type: "string",
format: "email",
description: "Email address"
},
type: {
type: "string",
enum: ["home", "work", "other"],
description: "Type of email address"
}
},
required: ["value"]
}
},
phoneNumbers: {
type: "array",
description: "Phone numbers (replaces all existing if updating)",
items: {
type: "object",
properties: {
value: {
type: "string",
description: "Phone number"
},
type: {
type: "string",
enum: ["home", "work", "mobile", "homeFax", "workFax", "otherFax", "pager", "workMobile", "workPager", "main", "googleVoice", "other"],
description: "Type of phone number"
}
},
required: ["value"]
}
},
addresses: {
type: "array",
description: "Physical addresses (replaces all existing if updating)",
items: {
type: "object",
properties: {
streetAddress: {
type: "string",
description: "Street address"
},
city: {
type: "string",
description: "City"
},
region: {
type: "string",
description: "State or region"
},
postalCode: {
type: "string",
description: "Postal or ZIP code"
},
country: {
type: "string",
description: "Country"
},
type: {
type: "string",
enum: ["home", "work", "other"],
description: "Type of address"
}
}
}
},
organizations: {
type: "array",
description: "Organizations (replaces all existing if updating)",
items: {
type: "object",
properties: {
name: {
type: "string",
description: "Organization name"
},
title: {
type: "string",
description: "Job title"
},
department: {
type: "string",
description: "Department"
},
type: {
type: "string",
enum: ["work", "school", "other"],
description: "Type of organization"
}
}
}
},
biographies: {
type: "array",
description: "Biographical information (replaces all existing if updating)",
items: {
type: "object",
properties: {
value: {
type: "string",
description: "Biography text"
},
contentType: {
type: "string",
enum: ["TEXT_PLAIN", "TEXT_HTML"],
description: "Content type"
}
},
required: ["value"]
}
}
},
required: ["resourceName", "updatePersonFields"]
}
},
{
name: "delete-contact",
description: "Delete a contact",
inputSchema: {
type: "object",
properties: {
resourceName: {
type: "string",
description: "Resource name of the contact to delete (e.g., 'people/c1234567890')"
}
},
required: ["resourceName"]
}
}
]
};
}
// src/handlers/core/BaseToolHandler.ts
import { GaxiosError as GaxiosError2 } from "gaxios";
import { google } from "googleapis";
var BaseToolHandler = class {
handleGoogleApiError(error) {
if (error instanceof GaxiosError2 && error.response?.data?.error === "invalid_grant") {
throw new Error(
"Google API Error: Authentication token is invalid or expired. Please re-run the authentication process (e.g., `npm run auth`)."
);
}
throw error;
}
getCalendar(auth) {
return google.calendar({ version: "v3", auth });
}
};
// src/handlers/core/ListCalendarsHandler.ts
var ListCalendarsHandler = class extends BaseToolHandler {
async runTool(_, oauth2Client2) {
const calendars = await this.listCalendars(oauth2Client2);
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.
*/
formatCalendarList(calendars) {
return calendars.map((cal) => `${cal.summary || "Untitled"} (${cal.id || "no-id"})`).join("\n");
}
};
// src/schemas/validators.ts
import { z } from "zod";
var ReminderSchema = z.object({
method: z.enum(["email", "popup"]).default("popup"),
minutes: z.number()
});
var RemindersSchema = z.object({
useDefault: z.boolean(),
overrides: z.array(ReminderSchema).optional()
});
var isoDateTimeWithTimezone = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?(Z|[+-]\d{2}:\d{2})$/;
var ListEventsArgumentsSchema = z.object({
calendarId: z.preprocess(
(val) => {
if (typeof val === "string" && val.startsWith("[") && val.endsWith("]")) {
try {
return JSON.parse(val);
} catch {
return val;
}
}
return val;
},
z.union([
z.string().min(1, "Calendar ID cannot be empty"),
z.array(z.string().min(