Repository navigation
feat(mcp): MCP server and AI SDK guide #36
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
16 commits
Select commit
Hold shift + click to select a range
f43592c
feat(mcp): add an MCP server and AI SDK guide
vkpdeveloper 237d562
fix(mcp): list only the chosen account in list_calendars, even an emp…
vkpdeveloper 93045b8
fix(mcp): reject empty calendar lists in find_free_time
vkpdeveloper 35b636d
fix(mcp): cap find_free_time windows at 92 days so a search can't blo…
vkpdeveloper dab85b1
fix(mcp): stop calendar calls when the client cancels the tool request
vkpdeveloper 93c4360
fix(mcp): stop marking update_event as safe to retry
vkpdeveloper 40bc67e
fix(mcp): treat empty token variables as unset
vkpdeveloper 9a84bde
fix(mcp): time out token refreshes after 15 seconds
vkpdeveloper f220e06
test(mcp): cover token expiry and refresh token rotation
vkpdeveloper 6d11479
docs(mcp): say find_free_time spans accounts, not every calendar
vkpdeveloper ab85c28
docs(mcp): describe the calendars argument of find_free_time
vkpdeveloper de94448
docs(ai-sdk): page through events in the listEvents tool
vkpdeveloper a2ef64a
docs(ai-sdk): give the agent today's date in the user's time zone
vkpdeveloper 9649eb8
fix(examples): page through events in the ai-agent listEvents tool
vkpdeveloper 554e33b
fix(examples): seed the ai-agent mock on tomorrow in Berlin
vkpdeveloper 53fbd69
fix(examples): give the ai-agent today's date in Berlin
vkpdeveloper File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| '@cal-sdk/mcp': minor | ||
| --- | ||
|
|
||
| New package: an MCP server with tools to list calendars and events, find free time across accounts, and create, update and delete events on Google Calendar, Outlook, iCloud and CalDAV. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,113 @@ | ||
| --- | ||
| title: AI SDK tools | ||
| description: Let an agent built with the Vercel AI SDK read a user's calendar, find free time and book meetings, with each user's own token. | ||
| --- | ||
|
|
||
| An MCP server is the right fit when the calendar belongs to whoever runs the client. In your own product it usually belongs to the user talking to your agent, and you already hold their OAuth token. Then the simplest setup is a few AI SDK tools that call calsdk directly, built per request with that user's provider. | ||
|
|
||
| ```npm | ||
| npm install ai @ai-sdk/anthropic zod @cal-sdk/core @cal-sdk/google | ||
| ``` | ||
|
|
||
| ## Define the tools | ||
|
|
||
| ```ts title="calendar-tools.ts" | ||
| import { type CalendarProvider, createEvent, findFreeTime, listEvents } from '@cal-sdk/core'; | ||
| import { tool } from 'ai'; | ||
| import { z } from 'zod'; | ||
|
|
||
| const instant = z.string().describe('ISO 8601 date-time with an offset'); | ||
| const localTime = z.string().describe('Local date-time, e.g. 2026-11-09T14:00:00'); | ||
|
|
||
| export function calendarTools(calendar: CalendarProvider, timeZone: string) { | ||
| return { | ||
| listEvents: tool({ | ||
| description: | ||
| 'List events between two times, recurring events expanded. If nextPageToken is returned, call again with it as pageToken for the rest.', | ||
| inputSchema: z.object({ from: instant, to: instant, pageToken: z.string().optional() }), | ||
| execute: async ({ from, to, pageToken }) => { | ||
| const { events, nextPageToken } = await listEvents({ | ||
| calendar, | ||
| timeMin: from, | ||
| timeMax: to, | ||
| pageToken, | ||
| }); | ||
| return { | ||
| events: events.map(({ id, title, start, end }) => ({ id, title, start, end })), | ||
| nextPageToken, | ||
| }; | ||
| }, | ||
| }), | ||
|
|
||
| findFreeTime: tool({ | ||
| description: 'Find free slots of a given length during working hours.', | ||
| inputSchema: z.object({ from: instant, to: instant, minutes: z.number().int() }), | ||
| execute: async ({ from, to, minutes }) => { | ||
| const { slots } = await findFreeTime({ | ||
| calendars: [{ calendar }], | ||
| timeMin: from, | ||
| timeMax: to, | ||
| duration: minutes, | ||
| workingHours: { start: '09:00', end: '18:00', timeZone }, | ||
| }); | ||
| return slots.slice(0, 10); | ||
| }, | ||
| }), | ||
|
|
||
| createEvent: tool({ | ||
| description: 'Book an event and invite attendees.', | ||
| inputSchema: z.object({ | ||
| title: z.string(), | ||
| start: localTime, | ||
| end: localTime, | ||
| attendees: z.array(z.string()).optional(), | ||
| }), | ||
| execute: async ({ title, start, end, attendees }) => { | ||
| const { event } = await createEvent({ | ||
| calendar, | ||
| event: { | ||
| title, | ||
| start: { dateTime: start, timeZone }, | ||
| end: { dateTime: end, timeZone }, | ||
| attendees: attendees?.map((email) => ({ email })), | ||
| }, | ||
| sendUpdates: 'all', | ||
| }); | ||
| return { id: event.id, start: event.start, link: event.htmlLink }; | ||
| }, | ||
| }), | ||
| }; | ||
| } | ||
| ``` | ||
|
|
||
| Keep the outputs small. A model doesn't need etags or provider metadata, and every field you return costs tokens on each later step. | ||
|
|
||
| ## Run the agent | ||
|
|
||
| ```ts | ||
| import { anthropic } from '@ai-sdk/anthropic'; | ||
| import { google } from '@cal-sdk/google'; | ||
| import { generateText, isStepCount } from 'ai'; | ||
| import { calendarTools } from './calendar-tools'; | ||
|
|
||
| // "Tomorrow" depends on where the user is, not where the server is. | ||
| const today = new Date().toLocaleDateString('en-US', { timeZone: user.timeZone, dateStyle: 'full' }); | ||
|
|
||
| const { text } = await generateText({ | ||
| model: anthropic('claude-sonnet-5-5'), | ||
| tools: calendarTools(google({ accessToken: user.googleAccessToken }), user.timeZone), | ||
| stopWhen: isStepCount(6), | ||
| instructions: `You manage the user's calendar. Today is ${today}. All times are in ${user.timeZone}.`, | ||
| prompt: 'Find an hour tomorrow afternoon for a planning session with ana@example.com and book it.', | ||
| }); | ||
| ``` | ||
|
|
||
| Swap `google(...)` for `microsoft(...)` or `icloud(...)` and nothing else changes. To check every calendar the user connected before booking, pass them all to `findFreeTime` in `calendars`. | ||
|
|
||
| The full example, which runs on an in-memory calendar when no token is set, is in [examples/ai-agent](https://github.com/vkpdeveloper/calsdk/tree/main/examples/ai-agent). | ||
|
|
||
| ## Things worth deciding | ||
|
|
||
| - **Confirm before writing.** For anything that emails attendees, many apps let the model propose a slot and ask the user before calling `createEvent`. The AI SDK's tool approval support fits this well. | ||
| - **Time zones.** Give the model the user's zone in the instructions and have it pass local times for events, as above. calsdk turns them into the right offsets, including across daylight saving changes. | ||
| - **Read-only first.** If your agent only needs to answer "am I free on Thursday?", give it `listEvents` and `findFreeTime` and request a read-only scope. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,111 @@ | ||
| --- | ||
| title: MCP server | ||
| description: Give Claude, Cursor or any MCP client your Google, Outlook or iCloud calendar, with tools to read events, find free time and book meetings. | ||
| --- | ||
|
|
||
| `@cal-sdk/mcp` is a [Model Context Protocol](https://modelcontextprotocol.io) server built on calsdk. Point an MCP client at it and the model can see your schedule, find a slot that works across all your accounts and put a meeting in it. It runs on your machine and talks to the calendar APIs directly: no hosted service sits in between. | ||
|
|
||
| Try it first on two in-memory calendars, no account needed: | ||
|
|
||
| ```bash | ||
| npx -y @cal-sdk/mcp --demo | ||
| ``` | ||
|
|
||
| ## Tools | ||
|
|
||
| | Tool | What it does | | ||
| | --- | --- | | ||
| | `list_calendars` | Calendars on every connected account, with ids and access | | ||
| | `list_events` | Events in a window, recurring events expanded into occurrences | | ||
| | `find_free_time` | Slots when every chosen calendar is free, across accounts | | ||
| | `create_event` | A new event, optionally recurring, with attendees or a meeting link | | ||
| | `update_event` | Change some fields, for one occurrence or the whole series | | ||
| | `delete_event` | Remove an event, one occurrence or the rest of a series | | ||
|
|
||
| Connect more than one account and each tool takes an `account` argument (`google`, `microsoft`, `icloud` or `caldav`). `find_free_time` takes a `calendars` list instead, with an `account` and optional `calendarIds` in each entry. Left out, it checks the primary calendar of every account, and it fails instead of guessing when one can't be read, so the model never books over a meeting it couldn't see. | ||
|
|
||
| ## Add it to your client | ||
|
|
||
| <Tabs items={['Claude Code', 'Claude Desktop', 'Cursor']}> | ||
| <Tab value="Claude Code"> | ||
|
|
||
| ```bash | ||
| claude mcp add calendar \ | ||
| -e ICLOUD_APPLE_ID=me@icloud.com -e ICLOUD_APP_PASSWORD=xxxx-xxxx-xxxx-xxxx \ | ||
| -- npx -y @cal-sdk/mcp | ||
| ``` | ||
|
|
||
| </Tab> | ||
| <Tab value="Claude Desktop"> | ||
|
|
||
| Add this to `claude_desktop_config.json` and restart the app: | ||
|
|
||
| ```json | ||
| { | ||
| "mcpServers": { | ||
| "calendar": { | ||
| "command": "npx", | ||
| "args": ["-y", "@cal-sdk/mcp"], | ||
| "env": { | ||
| "GOOGLE_CLIENT_ID": "...", | ||
| "GOOGLE_CLIENT_SECRET": "...", | ||
| "GOOGLE_REFRESH_TOKEN": "..." | ||
| } | ||
| } | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| </Tab> | ||
| <Tab value="Cursor"> | ||
|
|
||
| Add this to `.cursor/mcp.json` in your project, or `~/.cursor/mcp.json` for every project: | ||
|
|
||
| ```json | ||
| { | ||
| "mcpServers": { | ||
| "calendar": { | ||
| "command": "npx", | ||
| "args": ["-y", "@cal-sdk/mcp"], | ||
| "env": { "MICROSOFT_CLIENT_ID": "...", "MICROSOFT_REFRESH_TOKEN": "..." } | ||
| } | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| </Tab> | ||
| </Tabs> | ||
|
|
||
| ## Connect your calendars | ||
|
|
||
| Set the variables for each calendar you want. Any mix works. | ||
|
|
||
| | Calendar | Variables | | ||
| | --- | --- | | ||
| | Google | `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `GOOGLE_REFRESH_TOKEN` (or a short-lived `GOOGLE_ACCESS_TOKEN`) | | ||
| | Outlook | `MICROSOFT_CLIENT_ID`, `MICROSOFT_REFRESH_TOKEN`, plus `MICROSOFT_CLIENT_SECRET` for confidential apps (or `MICROSOFT_ACCESS_TOKEN`) | | ||
| | iCloud | `ICLOUD_APPLE_ID`, `ICLOUD_APP_PASSWORD` | | ||
| | Any CalDAV server | `CALDAV_URL`, `CALDAV_USERNAME`, `CALDAV_PASSWORD` | | ||
|
|
||
| With a refresh token the server gets a new access token whenever the old one is about to expire, so it keeps working after the first hour. | ||
|
|
||
| **iCloud** is the quickest: make an app-specific password at [account.apple.com](https://account.apple.com) under Sign-In and Security, and use it with your Apple ID. | ||
|
|
||
| **Google** needs an OAuth client of your own. Create one in the Google Cloud console (type "Web application", with `https://developers.google.com/oauthplayground` as a redirect URI) and enable the Google Calendar API. Then open the [OAuth Playground](https://developers.google.com/oauthplayground), tick "Use your own OAuth credentials" in the settings, authorize the `https://www.googleapis.com/auth/calendar` scope and exchange the code for a refresh token. While the app's publishing status is "Testing", Google expires refresh tokens after 7 days, so publish it (it can stay unverified for your own account) to keep the token. | ||
|
|
||
| **Outlook** needs an app registration in Microsoft Entra with the delegated `Calendars.ReadWrite` permission. Get a refresh token with any OAuth tool that requests `offline_access Calendars.ReadWrite`. Set `MICROSOFT_TENANT` if the app is single-tenant, and `MICROSOFT_SCOPE` if you granted something other than `Calendars.ReadWrite`. | ||
|
|
||
| ## Use it from code | ||
|
|
||
| The server is also a function, so you can host it yourself, for example over HTTP with your users' tokens: | ||
|
|
||
| ```ts | ||
| import { google } from '@cal-sdk/google'; | ||
| import { createCalendarServer } from '@cal-sdk/mcp'; | ||
|
|
||
| const server = createCalendarServer({ | ||
| work: google({ accessToken: () => getTokenFor(userId) }), | ||
| }); | ||
| ``` | ||
|
|
||
| Building an agent with the AI SDK instead? You may not need MCP at all: see [AI SDK tools](/docs/guides/ai-sdk). |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,6 +8,8 @@ | |
| "free-time", | ||
| "sync", | ||
| "provider-options", | ||
| "testing" | ||
| "testing", | ||
| "mcp", | ||
| "ai-sdk" | ||
| ] | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.