MCP Server for Calendar Access: Scheduling Through Agents
Why calendars are the perfect first MCP server to build
Every AI engineer who has tried to wire an LLM into a real workflow eventually hits the same wall: the model is great at reasoning about time, but it has no idea what is actually on your calendar. Ask ChatGPT to "find 30 minutes next week for a call with Priya" and it will happily hallucinate an available slot, because it has no ground truth. It cannot see your meetings, your working hours, or the fact that you already double-booked Thursday afternoon.
This is exactly the gap Model Context Protocol was built to close. MCP gives an LLM a standardized way to call out to external tools and pull in live data, instead of guessing. A calendar MCP server is one of the most practical things you can build because it forces you to deal with almost every real-world MCP concern in a small, contained surface area: authentication against a third-party API, read versus write tool boundaries, timezone handling, idempotency, and human-in-the-loop confirmation before anything gets sent to another person's inbox.
If you are learning agent engineering, calendar access is a fantastic training ground. It is bounded enough to finish in a weekend, but it touches OAuth flows, structured tool schemas, error handling, and the safety question every scheduling agent eventually runs into: should the agent be allowed to send a calendar invite without asking you first? By the end of this article you will understand how to design, build, and harden an MCP server that lets agents read events, check availability, and create or modify meetings — and where the real engineering tradeoffs live.
What "calendar access through MCP" actually means
Before writing code, it helps to be precise about what problem you are solving. There are three distinct capabilities that people lump together under "calendar MCP":
- Read access — listing events, checking free/busy status, fetching a specific event's details.
- Availability reasoning — given a set of participants and a duration, computing candidate time slots that do not conflict with existing commitments.
- Write access — creating, updating, or deleting events, and responding to invitations on someone's behalf.
Each of these carries a different risk profile. Read access is low-stakes: worst case, the agent misreads your schedule. Write access is high-stakes: a bad tool call can send a meeting invite to twenty people, delete a recurring event, or double-book a room. A well-designed MCP server treats these as separate tool boundaries rather than one giant manage_calendar hammer, because the client (the LLM host, like Claude Desktop or Claude Code) and the human operator both need to reason about what each tool is allowed to do.
This is also where MCP earns its keep over a bespoke API integration. Because MCP standardizes tool discovery and invocation, the same calendar server can be plugged into Claude, into a custom agent built with the Claude Agent SDK, or into any other MCP-compatible host, without rewriting the integration layer each time.
Anatomy of a calendar MCP server
At its core, an MCP server is just a process that exposes a set of tools (and optionally resources and prompts) over a JSON-RPC-style protocol, usually transported over stdio for local servers or HTTP/SSE for remote ones. For a calendar server, the tool surface typically looks like this:
list_calendars— enumerate the calendars the authenticated user has access to.list_events— fetch events in a date range, optionally filtered by calendar.get_event— fetch full details of a single event by ID.suggest_time— given participants, duration, and a search window, return open slots.create_event— create a new event, optionally with attendees and a video link.update_event— modify an existing event (time, attendees, title).delete_event— remove an event.respond_to_event— accept, decline, or tentatively accept an invitation.
Notice the split: list_calendars, list_events, get_event, and suggest_time are read-only and safe to expose with minimal friction. create_event, update_event, delete_event, and respond_to_event are mutating and should be treated as privileged operations. This distinction should show up directly in your tool schema naming and descriptions, because the LLM uses those descriptions to decide when and how to call each tool — vague descriptions lead to the model calling create_event when it only needed to check availability.
Building the server: a minimal working example
Let's build a stripped-down calendar MCP server in TypeScript using the official MCP SDK. This example wraps a generic calendar API (the pattern applies whether you are hitting Google Calendar, Microsoft Graph, or a self-hosted CalDAV server) and exposes three tools: listing events, suggesting a time, and creating an event.
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const server = new McpServer({
name: "calendar-mcp",
version: "1.0.0",
});
// Read-only tool: list events in a window
server.registerTool(
"list_events",
{
title: "List calendar events",
description:
"Returns events between startDate and endDate (ISO 8601). Read-only.",
inputSchema: {
startDate: z.string().describe("ISO 8601 start of range"),
endDate: z.string().describe("ISO 8601 end of range"),
calendarId: z.string().optional().describe("Defaults to primary calendar"),
},
},
async ({ startDate, endDate, calendarId }) => {
const events = await fetchEventsFromProvider({
calendarId: calendarId ?? "primary",
startDate,
endDate,
});
return {
content: [
{
type: "text",
text: JSON.stringify(events, null, 2),
},
],
};
}
);
// Read-only reasoning tool: suggest open slots
server.registerTool(
"suggest_time",
{
title: "Suggest meeting time",
description:
"Finds open slots of the given duration within a search window, avoiding conflicts for all attendees.",
inputSchema: {
attendees: z.array(z.string()).describe("Attendee email addresses"),
durationMinutes: z.number().int().positive(),
searchStart: z.string().describe("ISO 8601 window start"),
searchEnd: z.string().describe("ISO 8601 window end"),
},
},
async ({ attendees, durationMinutes, searchStart, searchEnd }) => {
const busyBlocks = await fetchFreeBusy(attendees, searchStart, searchEnd);
const slots = computeOpenSlots(busyBlocks, durationMinutes, searchStart, searchEnd);
return {
content: [
{ type: "text", text: JSON.stringify({ candidateSlots: slots }, null, 2) },
],
};
}
);
// Mutating tool: create an event, gated behind explicit confirmation upstream
server.registerTool(
"create_event",
{
title: "Create calendar event",
description:
"Creates a new calendar event and sends invites to attendees. This is a WRITE operation that notifies other people — only call after the user has confirmed the exact time and attendee list.",
inputSchema: {
title: z.string(),
startTime: z.string().describe("ISO 8601 start"),
endTime: z.string().describe("ISO 8601 end"),
attendees: z.array(z.string()).default([]),
description: z.string().optional(),
},
},
async ({ title, startTime, endTime, attendees, description }) => {
const event = await createEventInProvider({
title,
startTime,
endTime,
attendees,
description,
});
return {
content: [
{
type: "text",
text: `Created event "${event.title}" (${event.id}) from ${event.start} to ${event.end}, invited ${attendees.length} attendee(s).`,
},
],
};
}
);
const transport = new StdioServerTransport();
await server.connect(transport);A few details matter more than they look. The description field on create_event explicitly tells the model this is a write operation that notifies other humans, and instructs it to confirm details first — this is a prompt-engineering lever baked directly into your tool schema, and it is one of the highest-leverage places to reduce accidental invites. The inputSchema uses Zod so malformed calls fail fast with a clear validation error instead of silently hitting your calendar provider with garbage data.
Handling authentication without leaking credentials to the model
The single most common mistake in early calendar MCP servers is threading OAuth tokens through the LLM's context window. Don't do this. The model never needs to see an access token — it only needs to invoke tools by name with structured arguments. Your MCP server process holds the credentials; the model just calls list_events.
The practical pattern:
- Run the OAuth flow (Google Calendar, Microsoft Graph, or whatever provider you're targeting) once, outside the MCP session, and store a refresh token in an OS keychain or an encrypted local file.
- On server startup, load the refresh token, exchange it for a short-lived access token, and keep that in memory for the life of the process.
- Refresh proactively before expiry rather than reactively on a 401, so a mid-conversation tool call doesn't fail because the token aged out.
- Scope the OAuth grant as narrowly as the provider allows. Google Calendar, for example, lets you request
calendar.readonlyseparately fromcalendar.events— if you are building a read-only assistant, don't request write scopes at all. That way even a compromised or buggy agent physically cannot mutate the calendar, because the token itself doesn't have the permission.
async function getAccessToken(): Promise<string> {
if (cachedToken && cachedToken.expiresAt > Date.now() + 60_000) {
return cachedToken.value;
}
const refreshed = await oauthClient.refreshToken(storedRefreshToken);
cachedToken = {
value: refreshed.access_token,
expiresAt: Date.now() + refreshed.expires_in * 1000,
};
return cachedToken.value;
}This separation — model calls tools by name, server holds secrets, tools talk to the provider — is the whole reason MCP is safer than giving an LLM raw API keys in its system prompt. The protocol boundary is also a trust boundary.
Designing the human-in-the-loop confirmation layer
Reading a calendar is safe to fully automate. Writing to one usually isn't, because a mistake doesn't just corrupt local state — it sends an email to another human being. The right pattern for a production calendar agent is a two-step commit:
- The agent calls
suggest_timeorlist_eventsto gather context and proposes a specific time and attendee list back to the user in natural language. - Only after the user explicitly confirms ("yes, book it") does the agent call
create_event.
You can enforce this at three different layers, and the more of them you stack, the safer the system:
- Prompt layer: instruct the agent (in its system prompt or in the tool description itself) to never call mutating tools without explicit user confirmation in the current turn.
- Client layer: many MCP hosts, including Claude Code and Claude Desktop, support per-tool approval prompts — you can mark
create_event,update_event, anddelete_eventas tools that always require a human click-through, whilelist_eventsandsuggest_timerun automatically. - Server layer: the most robust option. Your MCP server itself can require a confirmation token, returned from a prior
suggest_timecall, beforecreate_eventwill execute. This means even if the model somehow decides to skip the confirmation step, the server rejects the call.
server.registerTool(
"create_event",
{
title: "Create calendar event",
description:
"Creates an event. Requires a confirmationToken previously issued by suggest_time for this exact slot.",
inputSchema: {
confirmationToken: z.string(),
title: z.string(),
startTime: z.string(),
endTime: z.string(),
attendees: z.array(z.string()).default([]),
},
},
async (args) => {
if (!isValidConfirmationToken(args.confirmationToken, args)) {
return {
isError: true,
content: [
{
type: "text",
text: "Confirmation token invalid or expired. Call suggest_time again for this slot before creating the event.",
},
],
};
}
// proceed with creation...
}
);This kind of defensive design feels like overkill until you've watched an agent, mid-conversation, decide to "helpfully" book three tentative meetings because the user mentioned being busy next week. Treat every mutating tool as if it will eventually be called with the wrong arguments, because eventually it will.
Timezones, recurrence, and the details that break demos
Calendar systems are notoriously fiddly, and this is where MCP calendar servers go from toy demo to production tool. A few issues come up in almost every real deployment:
- Timezone normalization. Always store and pass timestamps as ISO 8601 with explicit UTC offsets, and convert to the user's local timezone only at the presentation layer. If your
suggest_timetool returns "2pm" without a timezone, you will eventually schedule a call three hours off for someone traveling. - Recurring events. Deciding whether an update applies to a single occurrence, this-and-future occurrences, or the entire series is a genuinely hard UX problem even for humans using a calendar UI directly. Your
update_eventtool should require an explicitscopeparameter (this_event,this_and_following,all_events) rather than guessing. - Free/busy versus full detail. When checking availability for people outside your organization, you often only get free/busy blocks, not event titles. Don't let the model assume it knows why someone is busy —
suggest_timeshould return opaque busy blocks, not fabricate reasons. - Working hours and holidays. A naive
suggest_timeimplementation will happily propose a meeting at 11pm or on a public holiday. Bake working-hours constraints into the slot-computation function itself, not into the prompt, since prompts get ignored under context pressure but code doesn't.
function computeOpenSlots(
busyBlocks: Array<{ start: string; end: string }>,
durationMinutes: number,
searchStart: string,
searchEnd: string,
workingHours = { startHour: 9, endHour: 18 }
) {
const slots: Array<{ start: string; end: string }> = [];
let cursor = new Date(searchStart);
const end = new Date(searchEnd);
while (cursor < end) {
const hour = cursor.getUTCHours();
const withinWorkingHours =
hour >= workingHours.startHour && hour < workingHours.endHour;
const slotEnd = new Date(cursor.getTime() + durationMinutes * 60_000);
const conflicts = busyBlocks.some(
(b) => cursor < new Date(b.end) && slotEnd > new Date(b.start)
);
if (withinWorkingHours && !conflicts && slotEnd <= end) {
slots.push({ start: cursor.toISOString(), end: slotEnd.toISOString() });
}
cursor = new Date(cursor.getTime() + 30 * 60_000); // 30-min granularity
}
return slots.slice(0, 5);
}Testing the server like you'd test any other integration
Because MCP servers are just processes speaking a defined protocol, you can test them without a live LLM in the loop at all. Use the MCP Inspector (a standalone dev tool that speaks the protocol directly) to call each tool manually with edge-case inputs: an end time before the start time, an empty attendee list, a date range spanning a daylight-saving transition. Then layer in agent-level tests where you actually run the model against a scripted conversation and assert on which tools it calls and in what order.
A useful discipline is to write a small harness that fakes your calendar provider entirely, so your tests are deterministic:
const fakeProvider = {
events: [
{ id: "1", title: "Standup", start: "2026-07-06T09:00:00Z", end: "2026-07-06T09:15:00Z" },
],
async listEvents() {
return this.events;
},
async createEvent(input: NewEvent) {
const event = { id: crypto.randomUUID(), ...input };
this.events.push(event);
return event;
},
};Run your test suite against both the fake provider (fast, deterministic, runs in CI) and, periodically, against a real sandbox calendar account (slower, catches provider quirks like rate limits or field-naming differences between the API docs and what actually comes back).
It is also worth writing a handful of adversarial prompts specifically for the agent layer, not just the tool layer. Try conversations like "cancel all my meetings tomorrow" or "move everything on my calendar back by an hour" and watch what the model actually does before it reaches your server. Does it call list_events first to see what it's about to touch, or does it guess event IDs? Does it ask for confirmation before calling delete_event in a loop, or does it fire off five deletions back to back? These are the failure modes that don't show up in unit tests of the tool functions themselves, because the functions are correct — the problem is the sequence of calls the model chooses to make. Capturing a small library of these transcripts and re-running them whenever you change a tool description is one of the cheapest regression tests you can build, and it catches the exact class of bug that ships silently otherwise.
Common mistakes teams make in their first calendar server
A handful of mistakes show up so often in early implementations that they're worth calling out directly, since each one is easy to avoid once you know to look for it.
- One giant `manage_calendar` tool. Cramming list, create, update, and delete behind a single
actionparameter makes the model's job harder, not easier — it has to infer intent from a string instead of picking the obviously-named tool. Split tools by capability and let the names do the work. - Returning raw provider JSON verbatim. Google Calendar's event objects, for instance, carry dozens of fields the model doesn't need — conference data blobs, iCal UIDs, HTML-escaped descriptions. Shape your tool responses down to what's actually useful for reasoning, or you burn context window and invite the model to latch onto irrelevant fields.
- Trusting the model's date math. LLMs are unreliable at computing "next Tuesday" or "in three business days" from a reference date, especially near month boundaries or daylight-saving transitions. Do that arithmetic in your server code and pass the model resolved ISO timestamps, not relative phrases.
- No dry-run mode. Especially while developing, it's worth adding an environment flag that makes every mutating tool log what it *would* do instead of calling the provider. This lets you iterate on tool descriptions and prompt behavior without spamming real calendars with test invites while you debug.
Deploying and connecting the server to an agent host
Once the server works locally over stdio, wiring it into a host is mostly configuration. For Claude Desktop or Claude Code, you register the server in the MCP config with the command to launch it:
{
"mcpServers": {
"calendar": {
"command": "node",
"args": ["/path/to/calendar-mcp/dist/index.js"],
"env": {
"CALENDAR_REFRESH_TOKEN_PATH": "/secure/path/refresh-token.json"
}
}
}
}If you need the server reachable by multiple users or remote agents rather than a single local process, move the transport from stdio to streamable HTTP, put it behind normal auth (API keys or OAuth at the transport layer, separate from the calendar provider's own OAuth), and run it as a regular service. The tool logic doesn't change — only the transport and how you isolate credentials per user session, which becomes important the moment more than one person's calendar is in play.
One more operational detail worth planning for: logging. Log every mutating tool call (who requested it, what arguments, what the provider returned) separately from your general application logs, because "why did the agent book this meeting" is a question you will eventually need to answer at 2am, and a clean audit trail turns that into a five-minute lookup instead of an afternoon of guessing.
Where this fits in the bigger agent-engineering picture
A calendar MCP server looks like a narrow utility, but building one properly touches nearly every skill that matters for production agent work: scoped authentication, tool schema design that shapes model behavior, human-in-the-loop gating for irreversible actions, and defensive server-side validation that doesn't trust the model to always do the right thing. Get those right for calendars, and the same patterns transfer directly to MCP servers for email, CRM records, payment systems, or anything else where an agent's mistake has a blast radius beyond a chat window.
If you want a structured, hands-on path through exactly this — designing tool boundaries, wiring OAuth safely, building the confirmation layer, and shipping a real MCP server end to end — that's the focus of our course on Building & Integrating MCP Servers, where we walk through calendar, email, and data-source integrations as worked projects rather than toy snippets.
BootcampA 30-day guided bootcamp: build, harden and ship a production autonomous agent from scratch.
AI AgentsUnderstand how AI agents really work: the loop, the tools, the memory, and why most agent projects fail.