Guide · SaaS Integration
MCP server Google Calendar integration
Google Calendar is a high-value MCP target — agents can schedule meetings, check availability, create recurring events, and manage attendees through natural conversation. The Google Calendar API v3 is well-designed, but has several MCP-specific considerations: OAuth2 token refresh in a long-lived server process, timezone handling, the freebusy endpoint for availability queries, and the difference between service accounts (for calendar resources) and user OAuth2 (for personal calendars).
TL;DR
Use the googleapis Node.js client. For single-user scenarios, store OAuth2 refresh tokens and refresh access tokens automatically via the google.auth.OAuth2 client. Use the freebusy.query endpoint for availability checks — it's faster and cheaper than scanning event lists. Always work in ISO 8601 with explicit timezone offsets for event times; never pass bare date strings expecting "today" to resolve correctly. Wire AliveMCP to monitor your MCP server separately from Google's status page — your server being down is a different failure from Google Calendar being down.
Setup and OAuth2 authentication
npm install googleapis zod
import { google } from "googleapis";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
// OAuth2 client — credentials from Google Cloud Console
const oauth2Client = new google.auth.OAuth2(
process.env.GOOGLE_CLIENT_ID,
process.env.GOOGLE_CLIENT_SECRET,
process.env.GOOGLE_REDIRECT_URI,
);
// Set refresh token — this is stored per-user in your database
// The client automatically refreshes the access token when expired
oauth2Client.setCredentials({
refresh_token: process.env.GOOGLE_REFRESH_TOKEN,
});
// Listen for token refresh to persist the new access token
oauth2Client.on("tokens", (tokens) => {
if (tokens.refresh_token) {
// A new refresh token was issued — save it (rare, only on first consent)
saveRefreshToken(tokens.refresh_token);
}
// Always save the new access token and its expiry
saveAccessToken(tokens.access_token!, tokens.expiry_date!);
});
const calendar = google.calendar({ version: "v3", auth: oauth2Client });
const server = new McpServer({ name: "calendar-tools", version: "1.0.0" });
Service accounts for resource calendars
For booking room resources (conference rooms, equipment) managed by Google Workspace, use a service account with domain-wide delegation rather than user OAuth2:
import { google } from "googleapis";
const serviceAuth = new google.auth.GoogleAuth({
keyFile: process.env.GOOGLE_SERVICE_ACCOUNT_KEY_FILE,
scopes: ["https://www.googleapis.com/auth/calendar"],
// Impersonate the admin user who manages resource calendars
clientOptions: { subject: process.env.GOOGLE_ADMIN_EMAIL },
});
const calendarForResources = google.calendar({ version: "v3", auth: serviceAuth });
Event creation tool
server.tool(
"create_calendar_event",
"Create a Google Calendar event with optional attendees and location",
{
summary: z.string().max(1024).describe("Event title"),
start_datetime: z.string().datetime().describe("Start time in ISO 8601 with timezone offset, e.g. 2026-07-10T14:00:00-07:00"),
end_datetime: z.string().datetime().describe("End time in ISO 8601 with timezone offset"),
timezone: z.string().default("UTC").describe("IANA timezone name, e.g. America/Los_Angeles"),
description: z.string().max(8192).optional().describe("Event description / agenda"),
location: z.string().max(1024).optional().describe("Location string or Google Maps URL"),
attendee_emails: z.array(z.string().email()).max(20).optional()
.describe("List of attendee email addresses — they'll receive invites"),
send_notifications: z.boolean().default(true)
.describe("Whether to send email notifications to attendees"),
calendar_id: z.string().default("primary")
.describe("Calendar ID; 'primary' for the authenticated user's main calendar"),
},
async ({ summary, start_datetime, end_datetime, timezone, description, location, attendee_emails, send_notifications, calendar_id }) => {
try {
const event = await calendar.events.insert({
calendarId: calendar_id,
sendNotifications: send_notifications,
requestBody: {
summary,
description,
location,
start: { dateTime: start_datetime, timeZone: timezone },
end: { dateTime: end_datetime, timeZone: timezone },
attendees: attendee_emails?.map(email => ({ email })),
reminders: { useDefault: true },
},
});
return {
content: [{
type: "text",
text: JSON.stringify({
id: event.data.id,
htmlLink: event.data.htmlLink,
summary: event.data.summary,
start: event.data.start?.dateTime,
end: event.data.end?.dateTime,
status: event.data.status,
attendees: event.data.attendees?.map(a => ({ email: a.email, status: a.responseStatus })),
}),
}],
};
} catch (err) {
return googleCalendarErrorToToolResult(err);
}
}
);
Always pass both the dateTime field (ISO 8601 with offset) and the timeZone field (IANA timezone name). Google Calendar uses the timezone for recurring event expansion and for displaying the event to users in different zones. If the LLM only knows a city name, use a lookup table to convert to IANA timezone — don't guess or abbreviate.
Freebusy availability queries
The freebusy.query endpoint is the right tool for availability checks — it returns busy time blocks for one or more calendars in a time range, without exposing event details:
server.tool(
"check_availability",
"Check calendar availability for one or more users in a time range",
{
emails: z.array(z.string().email()).min(1).max(10)
.describe("Email addresses to check availability for"),
time_min: z.string().datetime().describe("Start of availability window in ISO 8601"),
time_max: z.string().datetime().describe("End of availability window in ISO 8601"),
timezone: z.string().default("UTC").describe("IANA timezone for response interpretation"),
},
async ({ emails, time_min, time_max, timezone }) => {
try {
const result = await calendar.freebusy.query({
requestBody: {
timeMin: time_min,
timeMax: time_max,
timeZone: timezone,
items: emails.map(email => ({ id: email })),
},
});
const availability = emails.map(email => {
const busySlots = result.data.calendars?.[email]?.busy ?? [];
return {
email,
busy: busySlots.map(slot => ({
start: slot.start,
end: slot.end,
})),
is_available: busySlots.length === 0,
};
});
// Find common free slots (simple implementation: gaps in the union of all busy periods)
const commonFreeSlots = findFreeSlots(
emails.map(email => result.data.calendars?.[email]?.busy ?? []),
time_min,
time_max
);
return {
content: [{
type: "text",
text: JSON.stringify({ availability, suggested_free_slots: commonFreeSlots.slice(0, 5) }),
}],
};
} catch (err) {
return googleCalendarErrorToToolResult(err);
}
}
);
function findFreeSlots(
busyByPerson: Array>,
windowStart: string,
windowEnd: string
): Array<{ start: string; end: string }> {
// Merge all busy periods across all people
const allBusy = busyByPerson
.flat()
.filter(s => s.start && s.end)
.map(s => ({ start: new Date(s.start!).getTime(), end: new Date(s.end!).getTime() }))
.sort((a, b) => a.start - b.start);
const freeSlots: Array<{ start: string; end: string }> = [];
let cursor = new Date(windowStart).getTime();
const windowEndMs = new Date(windowEnd).getTime();
for (const busy of allBusy) {
if (busy.start > cursor && busy.start - cursor >= 30 * 60 * 1000) {
// Gap of at least 30 minutes
freeSlots.push({
start: new Date(cursor).toISOString(),
end: new Date(busy.start).toISOString(),
});
}
cursor = Math.max(cursor, busy.end);
}
if (windowEndMs - cursor >= 30 * 60 * 1000) {
freeSlots.push({
start: new Date(cursor).toISOString(),
end: new Date(windowEndMs).toISOString(),
});
}
return freeSlots;
}
The freebusy endpoint only shows busy/free status — not event details. Users whose calendars you query only see aggregated free/busy data in the Google Calendar UI; they don't see that you queried it. This is appropriate for scheduling use cases where you need availability without event privacy concerns.
Event listing and search
server.tool(
"list_calendar_events",
"List upcoming calendar events with optional text search",
{
time_min: z.string().datetime().describe("Start of the search window (ISO 8601)"),
time_max: z.string().datetime().describe("End of the search window (ISO 8601)"),
query: z.string().optional().describe("Text to search in event title, description, and location"),
max_results: z.number().int().min(1).max(50).default(10),
calendar_id: z.string().default("primary"),
},
async ({ time_min, time_max, query, max_results, calendar_id }) => {
try {
const response = await calendar.events.list({
calendarId: calendar_id,
timeMin: time_min,
timeMax: time_max,
q: query,
maxResults: max_results,
singleEvents: true, // expand recurring events into individual instances
orderBy: "startTime",
});
const events = (response.data.items ?? []).map(event => ({
id: event.id,
summary: event.summary ?? "(no title)",
start: event.start?.dateTime ?? event.start?.date,
end: event.end?.dateTime ?? event.end?.date,
location: event.location,
attendees: event.attendees?.length ?? 0,
htmlLink: event.htmlLink,
recurringEventId: event.recurringEventId, // set if this is a recurring instance
}));
return {
content: [{
type: "text",
text: JSON.stringify({ events, count: events.length }),
}],
};
} catch (err) {
return googleCalendarErrorToToolResult(err);
}
}
);
The singleEvents: true parameter expands recurring events into individual instances. Without it, recurring events appear once with their recurrence rule (RRULE string) and the LLM would need to compute occurrences manually. Use singleEvents: true for scheduling contexts; use false only when you need to modify the recurrence rule itself.
Recurring event patterns
Recurring events in Google Calendar are defined with RRULE strings. Creating them via API requires building the RRULE. For common patterns, use a helper:
function buildRRule(pattern: {
frequency: "DAILY" | "WEEKLY" | "MONTHLY" | "YEARLY";
interval?: number;
days_of_week?: Array<"MO" | "TU" | "WE" | "TH" | "FR" | "SA" | "SU">;
count?: number;
until?: string; // ISO 8601 date
}): string {
const parts: string[] = [`FREQ=${pattern.frequency}`];
if (pattern.interval && pattern.interval > 1) parts.push(`INTERVAL=${pattern.interval}`);
if (pattern.days_of_week?.length) parts.push(`BYDAY=${pattern.days_of_week.join(",")}`);
if (pattern.count) parts.push(`COUNT=${pattern.count}`);
if (pattern.until) parts.push(`UNTIL=${pattern.until.replace(/[-:]/g, "").split(".")[0]}Z`);
return `RRULE:${parts.join(";")}`;
}
// Usage: weekly standup on Mon/Wed/Fri for 12 weeks
const rule = buildRRule({
frequency: "WEEKLY",
days_of_week: ["MO", "WE", "FR"],
count: 36, // 12 weeks × 3 days
});
// Then pass to event.insert:
requestBody: {
summary: "Standup",
start: { dateTime: "2026-07-06T09:00:00-07:00", timeZone: "America/Los_Angeles" },
end: { dateTime: "2026-07-06T09:15:00-07:00", timeZone: "America/Los_Angeles" },
recurrence: [rule],
}
Modifying individual instances of a recurring event uses events.patch with the instance ID (which includes the recurringEventId plus a timestamp suffix). Modifying "this and all following" requires deleting from the instance forward and creating a new series — there's no API shortcut for that pattern.
Timezone handling
Timezone bugs are the most common source of wrong calendar events from agent tools. Three rules:
| Rule | Correct | Wrong |
|---|---|---|
| Always use ISO 8601 with explicit offset | 2026-07-10T14:00:00-07:00 | 2026-07-10T14:00:00 (ambiguous) |
| Pass IANA timezone name, not abbreviation | America/Los_Angeles | PST or PDT (ambiguous across DST) |
| All-day events use date strings, not datetimes | { date: "2026-07-10" } | { dateTime: "2026-07-10T00:00:00Z" } (interpreted as UTC midnight) |
When the user says "schedule a meeting for 2pm on Friday" without a timezone context, the MCP tool should ask for timezone confirmation before creating the event. An event created in UTC when the user meant Pacific time will show up 7–8 hours off — a reliable way to erode user trust in an agent.
// Get list of user's calendars to determine their primary timezone
const calList = await calendar.calendarList.get({ calendarId: "primary" });
const userTimezone = calList.data.timeZone; // e.g. "America/New_York"
Error handling and quota
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { GaxiosError } from "gaxios";
function googleCalendarErrorToToolResult(err: unknown): CallToolResult {
if (err instanceof GaxiosError) {
const status = err.response?.status;
const message = err.response?.data?.error?.message ?? err.message;
if (status === 401) {
return { isError: true, content: [{ type: "text", text: "Google Calendar authentication failed — OAuth2 token may need reauthorization" }] };
}
if (status === 403) {
const reason = err.response?.data?.error?.errors?.[0]?.reason;
if (reason === "rateLimitExceeded" || reason === "userRateLimitExceeded") {
return { isError: true, content: [{ type: "text", text: "Google Calendar quota exceeded — retry after a few seconds" }] };
}
return { isError: true, content: [{ type: "text", text: `Google Calendar permission denied: ${message}` }] };
}
if (status === 404) {
return { isError: true, content: [{ type: "text", text: "Calendar or event not found — verify the calendar ID and event ID" }] };
}
if (status === 409) {
return { isError: true, content: [{ type: "text", text: "Calendar event conflict — the event may already exist" }] };
}
return { isError: true, content: [{ type: "text", text: `Google Calendar API error (${status}): ${message}` }] };
}
throw err;
}
Google Calendar API quota is 1,000,000 queries per day per project and 500 queries per 100 seconds per user. These limits are effectively never hit by agent-driven tools unless the agent is running in a tight automation loop. If you do hit quota, the response will include reason: "rateLimitExceeded" in the error object — implement exponential backoff (starting at 1s) before retrying.
Frequently asked questions
Should I use a service account or user OAuth2 for a personal assistant MCP server?
User OAuth2. Service accounts work for calendars owned by a Google Workspace organization (like room booking systems), but they can't access personal Gmail/Google accounts or user-owned calendars without the user explicitly sharing them and granting delegated access — a significant setup burden. For a personal assistant that accesses the user's own calendar, the OAuth2 authorization code flow (the user authorizes once, you store the refresh token) is the right model. Service accounts make sense when you're managing resources on behalf of an organization, not accessing an individual user's data.
How do I update a single occurrence of a recurring event without breaking the series?
Call events.patch with the instance's specific event ID (visible in the event's id field when you list with singleEvents: true). Patching an instance only affects that occurrence; the series template is unchanged. To modify the series itself, patch the recurring event using its recurring event ID (the recurringEventId field on instances). Be careful: modifying the series template updates all future instances from the current date.
How do I handle the case where the agent schedules outside a user's working hours?
Either check the freebusy result before confirming the event (if the user is busy, suggest alternatives), or add a working-hours guard in your tool. A practical approach: query the user's Calendar Settings for their working hours via the calendarList.get API, or ask the user to declare their preferred hours in the system prompt. The freebusy API doesn't distinguish between "busy because of a meeting" and "busy because it's outside working hours" — both appear as blocked time.
Can I add Google Meet video links to events automatically?
Yes — add conferenceData: { createRequest: { requestId: unique-id, conferenceSolutionKey: { type: "hangoutsMeet" } } } to the event body and set conferenceDataVersion: 1 in the query params. Google will generate a Meet link and attach it to the event. The Meet link appears in event.conferenceData.entryPoints in the response. Each requestId must be unique per event creation to prevent duplicate Meet rooms.
What happens to event invites when I create an event with attendees?
With sendNotifications: true, Google sends email invitations to all attendees immediately when the event is created. Attendees see "Pending" status until they respond. If you're creating test events or drafts, set sendNotifications: false to suppress emails. Note that sendNotifications also applies to updates — use sendUpdates: "all" or "externalOnly" or "none" on events.patch to control notification behavior for updates.
Further reading
- MCP server OAuth2 — authorization code flow, refresh tokens, and multi-tenant patterns
- Secret management for MCP servers — refresh tokens and client credentials
- MCP server Notion integration — database queries and page creation
- MCP server Stripe integration — payment tools and idempotency
- Human-in-the-loop approval for sensitive MCP operations
- AliveMCP — production protocol monitoring for MCP servers