From f43592cb84b9146188f95719709d17b0e1d31c71 Mon Sep 17 00:00:00 2001
From: vkpdeveloper
Date: Fri, 6 Nov 2026 01:19:34 +0530
Subject: [PATCH 01/16] feat(mcp): add an MCP server and AI SDK guide
@cal-sdk/mcp exposes list_calendars, list_events, find_free_time,
create_event, update_event and delete_event over stdio for Google,
Outlook, iCloud and CalDAV accounts configured with environment
variables, refreshing OAuth tokens as they expire. --demo runs on
in-memory calendars.
Also adds docs for the MCP server and for AI SDK tools, an AI SDK
agent example, and wraps long inline code on phones across the docs.
---
.changeset/mcp-server.md | 5 +
README.md | 5 +
apps/web/app/(home)/blog/[slug]/page.tsx | 2 +-
apps/web/app/global.css | 7 +-
apps/web/content/docs/guides/ai-sdk.mdx | 101 +++++++++
apps/web/content/docs/guides/mcp.mdx | 111 ++++++++++
apps/web/content/docs/guides/meta.json | 4 +-
biome.json | 7 +-
bun.lock | 78 +++++++
examples/ai-agent/README.md | 15 ++
examples/ai-agent/package.json | 16 ++
examples/ai-agent/src/index.ts | 112 ++++++++++
packages/mcp/README.md | 64 ++++++
packages/mcp/package.json | 65 ++++++
packages/mcp/server.json | 73 +++++++
packages/mcp/src/accounts.ts | 105 +++++++++
packages/mcp/src/cli.ts | 14 ++
packages/mcp/src/index.ts | 3 +
packages/mcp/src/refreshing-token.ts | 60 +++++
packages/mcp/src/server.test.ts | 111 ++++++++++
packages/mcp/src/server.ts | 266 +++++++++++++++++++++++
packages/mcp/tsdown.config.ts | 10 +
22 files changed, 1230 insertions(+), 4 deletions(-)
create mode 100644 .changeset/mcp-server.md
create mode 100644 apps/web/content/docs/guides/ai-sdk.mdx
create mode 100644 apps/web/content/docs/guides/mcp.mdx
create mode 100644 examples/ai-agent/README.md
create mode 100644 examples/ai-agent/package.json
create mode 100644 examples/ai-agent/src/index.ts
create mode 100644 packages/mcp/README.md
create mode 100644 packages/mcp/package.json
create mode 100644 packages/mcp/server.json
create mode 100644 packages/mcp/src/accounts.ts
create mode 100644 packages/mcp/src/cli.ts
create mode 100644 packages/mcp/src/index.ts
create mode 100644 packages/mcp/src/refreshing-token.ts
create mode 100644 packages/mcp/src/server.test.ts
create mode 100644 packages/mcp/src/server.ts
create mode 100644 packages/mcp/tsdown.config.ts
diff --git a/.changeset/mcp-server.md b/.changeset/mcp-server.md
new file mode 100644
index 0000000..83bbbd1
--- /dev/null
+++ b/.changeset/mcp-server.md
@@ -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.
diff --git a/README.md b/README.md
index 61df521..a7c9901 100644
--- a/README.md
+++ b/README.md
@@ -90,6 +90,11 @@ const calendar = google({ accessToken: () => getFreshAccessToken() });
- **Errors you can branch on.** Conflicts, rate limits, auth failures and missing events each have their own error class.
- **Types that follow the calendar.** Provider options are checked against the calendar you pass, so a misspelled option fails to compile.
+## For AI agents
+
+- **MCP server.** `npx -y @cal-sdk/mcp` gives Claude, Cursor or any MCP client tools to read events, find free time across calendars and book meetings. Add `--demo` to try it without an account. [Setup](https://www.cal-sdk.com/docs/guides/mcp).
+- **AI SDK tools.** A few lines turn calsdk functions into tools for an agent, using each user's own token. [Guide](https://www.cal-sdk.com/docs/guides/ai-sdk) and [example](examples/ai-agent).
+
## Good to know
- **It's a library, not a hosted service.** It runs in your app and calls each provider directly with tokens you pass in. Your users' calendar data doesn't pass through anyone else.
diff --git a/apps/web/app/(home)/blog/[slug]/page.tsx b/apps/web/app/(home)/blog/[slug]/page.tsx
index 1334036..9254803 100644
--- a/apps/web/app/(home)/blog/[slug]/page.tsx
+++ b/apps/web/app/(home)/blog/[slug]/page.tsx
@@ -35,7 +35,7 @@ export default async function Post(props: PageProps<'/blog/[slug]'>) {
ยท
{/* Long inline code, like a full property path, would push the page sideways on phones. */}
-
+
diff --git a/apps/web/app/global.css b/apps/web/app/global.css
index 11ab7bf..c7e19b2 100644
--- a/apps/web/app/global.css
+++ b/apps/web/app/global.css
@@ -145,12 +145,17 @@ html > body[data-scroll-locked] {
}
/* Inline code is short; splitting `google({ accessToken })` across lines makes it unreadable.
- Phones keep wrapping so a long span can't push the page sideways. */
+ Phones keep wrapping, anywhere if needed, so a long URL or name can't run off the screen. */
@media (min-width: 640px) {
.prose :not(pre) > code {
white-space: nowrap;
}
}
+@media (max-width: 639px) {
+ .prose :not(pre) > code {
+ overflow-wrap: anywhere;
+ }
+}
/* On phones, wide docs tables scroll inside their own box with the header row in view.
Short tables never reach the height cap. */
diff --git a/apps/web/content/docs/guides/ai-sdk.mdx b/apps/web/content/docs/guides/ai-sdk.mdx
new file mode 100644
index 0000000..bd5b48c
--- /dev/null
+++ b/apps/web/content/docs/guides/ai-sdk.mdx
@@ -0,0 +1,101 @@
+---
+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.',
+ inputSchema: z.object({ from: instant, to: instant }),
+ execute: async ({ from, to }) => {
+ const { events } = await listEvents({ calendar, timeMin: from, timeMax: to });
+ return events.map(({ id, title, start, end }) => ({ id, title, start, end }));
+ },
+ }),
+
+ 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';
+
+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 ${new Date().toDateString()}.`,
+ 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.
diff --git a/apps/web/content/docs/guides/mcp.mdx b/apps/web/content/docs/guides/mcp.mdx
new file mode 100644
index 0000000..9745f5b
--- /dev/null
+++ b/apps/web/content/docs/guides/mcp.mdx
@@ -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 calendars 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` checks all of them by default, and 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
+
+
+
+
+```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
+```
+
+
+
+
+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": "..."
+ }
+ }
+ }
+}
+```
+
+
+
+
+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": "..." }
+ }
+ }
+}
+```
+
+
+
+
+## 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).
diff --git a/apps/web/content/docs/guides/meta.json b/apps/web/content/docs/guides/meta.json
index d2c311e..08394d8 100644
--- a/apps/web/content/docs/guides/meta.json
+++ b/apps/web/content/docs/guides/meta.json
@@ -8,6 +8,8 @@
"free-time",
"sync",
"provider-options",
- "testing"
+ "testing",
+ "mcp",
+ "ai-sdk"
]
}
diff --git a/biome.json b/biome.json
index 3c4c75c..9844e9b 100644
--- a/biome.json
+++ b/biome.json
@@ -49,7 +49,12 @@
}
},
{
- "includes": ["packages/*/src/**", "!packages/provider-tests/**", "!**/*.test.ts"],
+ "includes": [
+ "packages/*/src/**",
+ "!packages/provider-tests/**",
+ "!**/*.test.ts",
+ "!packages/mcp/src/cli.ts"
+ ],
"linter": {
"rules": {
"correctness": {
diff --git a/bun.lock b/bun.lock
index e6c0266..08cfd28 100644
--- a/bun.lock
+++ b/bun.lock
@@ -37,6 +37,17 @@
"tailwindcss": "4.3.4",
},
},
+ "examples/ai-agent": {
+ "name": "calsdk-ai-agent",
+ "dependencies": {
+ "@ai-sdk/anthropic": "4.0.97",
+ "@cal-sdk/core": "workspace:*",
+ "@cal-sdk/google": "workspace:*",
+ "@cal-sdk/mock": "workspace:*",
+ "ai": "7.0.163",
+ "zod": "^4.6.7",
+ },
+ },
"examples/nextjs-booking": {
"name": "calsdk-nextjs-booking",
"dependencies": {
@@ -154,6 +165,25 @@
"zod": "^4.6.7",
},
},
+ "packages/mcp": {
+ "name": "@cal-sdk/mcp",
+ "version": "0.0.0",
+ "bin": {
+ "cal-sdk-mcp": "./dist/cli.js",
+ },
+ "dependencies": {
+ "@cal-sdk/caldav": "workspace:*",
+ "@cal-sdk/core": "workspace:*",
+ "@cal-sdk/google": "workspace:*",
+ "@cal-sdk/microsoft": "workspace:*",
+ "@cal-sdk/mock": "workspace:*",
+ "@modelcontextprotocol/server": "^2.4.0",
+ "zod": "^4.6.7",
+ },
+ "devDependencies": {
+ "@modelcontextprotocol/client": "^2.4.0",
+ },
+ },
"packages/microsoft": {
"name": "@cal-sdk/microsoft",
"version": "0.1.0",
@@ -221,6 +251,14 @@
},
},
"packages": {
+ "@ai-sdk/anthropic": ["@ai-sdk/anthropic@4.0.97", "", { "dependencies": { "@ai-sdk/provider": "4.0.36", "@ai-sdk/provider-utils": "5.0.75" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-LqCxnaKd4FsKChgBiS26Dgu1kC35jVeuH9OclhRJ6/Hg56kmJSbKlwu6Om6cfCmLgDUiIbn2r3w4um6bz7BivQ=="],
+
+ "@ai-sdk/gateway": ["@ai-sdk/gateway@4.0.136", "", { "dependencies": { "@ai-sdk/provider": "4.0.36", "@ai-sdk/provider-utils": "5.0.75", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-P851NL80P85Ee6MhblRuuCzNlQdOYU5rKmdt/Wuo5dMtkzvIHhyVZY9wtfKG3wBJUUoxHxWKsI2aR6O75o8diA=="],
+
+ "@ai-sdk/provider": ["@ai-sdk/provider@4.0.36", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-GqvWZSZdBelCGughlBdOoupT258rgM3YMbaLAS9WSZJ0vlt8c9vm3My+ZT31mYhiReMCMCF2qF2axnGbILpk1A=="],
+
+ "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@5.0.75", "", { "dependencies": { "@ai-sdk/provider": "4.0.36", "@standard-schema/spec": "^1.1.0", "@workflow/serde": "4.1.0", "eventsource-parser": "^3.0.8", "undici": "^7.29.1" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-k4yAsFliGZwoTpyTpm3ptxwIeaPvRh5vGh/UWoqDiLLXKoyz4zuBGS7p3ygf8to5Bh4fAOHynotLBsWLIYdu2A=="],
+
"@alloc/quick-lru": ["@alloc/quick-lru@5.3.0", "", {}, "sha512-U4+70Pc5ZS9osnCBCE5Jha/ciHM+Yp+CNMNC/7HvYbNRk1Ldd+f7qO65W5qfhu/TCv+/ozljlXXe9Nj8419DMA=="],
"@babel/runtime": ["@babel/runtime@8.1.0", "", {}, "sha512-MQye/pU7gzXerMzlhScFNH1u4Je6rR8cqJWSgAfD+rIwbbtwG8tVDegoI7sWfcXkfVRW09G7vk2vjxGIuKFfwg=="],
@@ -263,6 +301,8 @@
"@cal-sdk/ics": ["@cal-sdk/ics@workspace:packages/ics"],
+ "@cal-sdk/mcp": ["@cal-sdk/mcp@workspace:packages/mcp"],
+
"@cal-sdk/microsoft": ["@cal-sdk/microsoft@workspace:packages/microsoft"],
"@cal-sdk/mock": ["@cal-sdk/mock@workspace:packages/mock"],
@@ -451,6 +491,12 @@
"@mdx-js/mdx": ["@mdx-js/mdx@3.1.1", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdx": "^2.0.0", "acorn": "^8.0.0", "collapse-white-space": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-util-scope": "^1.0.0", "estree-walker": "^3.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "markdown-extensions": "^2.0.0", "recma-build-jsx": "^1.0.0", "recma-jsx": "^1.0.0", "recma-stringify": "^1.0.0", "rehype-recma": "^1.0.0", "remark-mdx": "^3.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "source-map": "^0.7.0", "unified": "^11.0.0", "unist-util-position-from-estree": "^2.0.0", "unist-util-stringify-position": "^4.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ=="],
+ "@modelcontextprotocol/client": ["@modelcontextprotocol/client@2.4.0", "", { "dependencies": { "@modelcontextprotocol/core": "2.4.0", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.8", "jose": "^6.1.3", "pkce-challenge": "^5.0.0", "zod": "^4.2.0" } }, "sha512-LgrD2OphHuG12vfKFKz6SOnq7bKHQmhGJIAYdD+MZBn2npHEyry6uD8zvktodtl5CQM85MO0anUyD9JwmUmxww=="],
+
+ "@modelcontextprotocol/core": ["@modelcontextprotocol/core@2.4.0", "", { "dependencies": { "zod": "^4.2.0" } }, "sha512-z8QBKqdfXLPzkLQuG4xp8hrVEMm1PZ1IgxMN2sMbJiFiAKOWXnAzogXn2W4aqIBlP1ea6U5yiqVquvUNM1Cj6Q=="],
+
+ "@modelcontextprotocol/server": ["@modelcontextprotocol/server@2.4.0", "", { "dependencies": { "@modelcontextprotocol/core": "2.4.0", "zod": "^4.2.0" } }, "sha512-rgR9yxSaAJ+bcsi/XroT/S2AfPiIWloXAnrdfYSCms9ivVju0ishu/wW0J21Ho6+ZOR1r7RyeRbjiRuF87ix5Q=="],
+
"@next/env": ["@next/env@16.4.5", "", {}, "sha512-JwSPwpdkZL5VMl2jA/q+JI8MZdweAL3xmw5/tb9y/TLtmS0YXZnmYDzSihwk8xTj1OL+WM53gTqaj96yuLp6Xw=="],
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.4.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-W5TniBzWKASbNNwHZ/S/f1MxQUXU/LdjklVMWA0Ujbfl7SZnUzFls45hO0i/sF7Bx1jJBWp7WWmoQajXtX/O8A=="],
@@ -627,10 +673,14 @@
"@ungap/structured-clone": ["@ungap/structured-clone@1.4.0", "", {}, "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ=="],
+ "@vercel/oidc": ["@vercel/oidc@3.2.0", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="],
+
"@vitest/mocker": ["@vitest/mocker@5.0.5", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.31", "@vitest/spy": "5.0.5", "estree-walker": "^3.0.3", "magic-string": "^1.4.3" }, "peerDependencies": { "msw": "^2.4.9 || ^3.0.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-xLL3lkDegJZw+MGoGaCCsFayDZthGgpAtr3Vziyfc4p7xH89K7KRPcRBBh/kEYm0Aqkt5DtTmkYpBtRV/FrHZg=="],
"@vitest/spy": ["@vitest/spy@5.0.5", "", {}, "sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw=="],
+ "@workflow/serde": ["@workflow/serde@4.1.0", "", {}, "sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ=="],
+
"@xmldom/xmldom": ["@xmldom/xmldom@0.9.12", "", {}, "sha512-5AXjrcMClTryPe9LgZrygpB1lj7s0S9E0+W+AHaVKAVyHanafK86iPSvG5xHVSp/jC+VH1UXu0TAEmY279xH7A=="],
"@yuku-core/binding-android-arm64": ["@yuku-core/binding-android-arm64@0.19.0", "", { "os": "android", "cpu": "arm64" }, "sha512-wdL2XmTiZixAOaHk9Z0JJZ8u9Qm8d333Zjpc6gaxXFX+UR5BlsfxZ7a2+2Rm71pamppsPYGUGFq2oVLbh6DISw=="],
@@ -663,6 +713,8 @@
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
+ "ai": ["ai@7.0.163", "", { "dependencies": { "@ai-sdk/gateway": "4.0.136", "@ai-sdk/provider": "4.0.36", "@ai-sdk/provider-utils": "5.0.75" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-nm8+qn36NQXO4Hjfj7SlbcNRhUyNXKtdoS9xhXr/z92lvdGFuPkzHmA2tFzhaUQy13IitNYrcw2QSNpEJC/iSA=="],
+
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
"astring": ["astring@1.9.0", "", { "bin": { "astring": "bin/astring" } }, "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg=="],
@@ -673,6 +725,8 @@
"cac": ["cac@7.1.0", "", {}, "sha512-LMDU/U2yA4IPIY8KT0NsNPWEYZvAMkbgyspxJ6btJaYV+WMDOHAZCmB0lsTObiHaRS1V/ztKAl7+TSkTqXyeoA=="],
+ "calsdk-ai-agent": ["calsdk-ai-agent@workspace:examples/ai-agent"],
+
"calsdk-nextjs-booking": ["calsdk-nextjs-booking@workspace:examples/nextjs-booking"],
"calsdk-playground": ["calsdk-playground@workspace:examples/playground"],
@@ -709,6 +763,8 @@
"compute-scroll-into-view": ["compute-scroll-into-view@3.1.1", "", {}, "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw=="],
+ "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
+
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
@@ -757,6 +813,10 @@
"estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
+ "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="],
+
+ "eventsource-parser": ["eventsource-parser@3.1.1", "", {}, "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ=="],
+
"expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="],
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
@@ -825,10 +885,16 @@
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
+ "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
+
"jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
"jju": ["jju@1.4.0", "", {}, "sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA=="],
+ "jose": ["jose@6.2.15", "", {}, "sha512-qdF6ewcZARtef63fJ9AKKZPgxw+t7USH6HLXYcD4CrdmSvDp79rpJSPFEKc90QBeGS63u1KQtN2MZQHC0yDIzA=="],
+
+ "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="],
+
"jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="],
"launch-editor": ["launch-editor@2.14.2", "", { "dependencies": { "picocolors": "^1.1.1", "shell-quote": "^1.10.0" } }, "sha512-NHJEb30MGBCkmZXLZwz25t37QR4aY03ZzPYUJXOJaOa92uWwExI9SBQUuIznBYc1xkD7cJNGX7oc3Nl3sq2kQQ=="],
@@ -997,10 +1063,14 @@
"parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
+ "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
+
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="],
+ "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
+
"postcss": ["postcss@8.5.32", "", { "dependencies": { "nanoid": "^3.3.20", "picocolors": "^1.1.1", "source-map-js": "^1.2.2" } }, "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g=="],
"property-information": ["property-information@7.2.0", "", {}, "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg=="],
@@ -1067,6 +1137,10 @@
"sharp": ["sharp@0.35.5", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.5", "@img/sharp-darwin-x64": "0.35.5", "@img/sharp-freebsd-wasm32": "0.35.5", "@img/sharp-libvips-darwin-arm64": "1.3.4", "@img/sharp-libvips-darwin-x64": "1.3.4", "@img/sharp-libvips-linux-arm": "1.3.4", "@img/sharp-libvips-linux-arm64": "1.3.4", "@img/sharp-libvips-linux-ppc64": "1.3.4", "@img/sharp-libvips-linux-riscv64": "1.3.4", "@img/sharp-libvips-linux-s390x": "1.3.4", "@img/sharp-libvips-linux-x64": "1.3.4", "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", "@img/sharp-libvips-linuxmusl-x64": "1.3.4", "@img/sharp-linux-arm": "0.35.5", "@img/sharp-linux-arm64": "0.35.5", "@img/sharp-linux-ppc64": "0.35.5", "@img/sharp-linux-riscv64": "0.35.5", "@img/sharp-linux-s390x": "0.35.5", "@img/sharp-linux-x64": "0.35.5", "@img/sharp-linuxmusl-arm64": "0.35.5", "@img/sharp-linuxmusl-x64": "0.35.5", "@img/sharp-webcontainers-wasm32": "0.35.5", "@img/sharp-win32-arm64": "0.35.5", "@img/sharp-win32-ia32": "0.35.5", "@img/sharp-win32-x64": "0.35.5" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g=="],
+ "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
+
+ "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
+
"shell-quote": ["shell-quote@1.12.0", "", {}, "sha512-PcByqNyT/38F2kDNi006HAMRJaULuBzq/FOsw3qdZvX/GA9W/jamDaRskgHjubHiftXK5sIFxLNkvrXUwcof6Q=="],
"shiki": ["shiki@4.6.0", "", { "dependencies": { "@shikijs/core": "4.6.0", "@shikijs/engine-javascript": "4.6.0", "@shikijs/engine-oniguruma": "4.6.0", "@shikijs/langs": "4.6.0", "@shikijs/themes": "4.6.0", "@shikijs/types": "4.6.0", "@shikijs/vscode-textmate": "^10.1.0", "@types/hast": "^3.0.5" } }, "sha512-imjLsVHgPuWhvpU3P+zOekvMYqnel7QmKsFC4Sn0fCuAvaaalBsxwKEsrU8Ko5BdFnZA6Go/8a0RzahS634UgA=="],
@@ -1117,6 +1191,8 @@
"unconfig-core": ["unconfig-core@7.5.0", "", { "dependencies": { "@quansync/fs": "^1.0.0", "quansync": "^1.0.0" } }, "sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w=="],
+ "undici": ["undici@7.30.2", "", {}, "sha512-mLyPQnHwUxaM/rideoK/+KL8DaAJLRl4aBCfmX444aCSLnIaomqPLtNRe2jdCYcsk5wZzXrDIQC8ZeYNRAYyIA=="],
+
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="],
@@ -1153,6 +1229,8 @@
"web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="],
+ "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
+
"why-is-node-running": ["why-is-node-running@3.2.1", "", { "bin": { "why-is-node-running": "cli.js" } }, "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ=="],
"yaml": ["yaml@2.9.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw=="],
diff --git a/examples/ai-agent/README.md b/examples/ai-agent/README.md
new file mode 100644
index 0000000..2612501
--- /dev/null
+++ b/examples/ai-agent/README.md
@@ -0,0 +1,15 @@
+# calsdk AI agent example
+
+A scheduling agent built with the [AI SDK](https://ai-sdk.dev): it reads a calendar, finds a free hour and books it, through three small tools in [`src/index.ts`](src/index.ts).
+
+```bash
+bun install
+bun run build # from the repo root, once
+export ANTHROPIC_API_KEY=...
+bun run start # in this folder
+bun run start "What does my Thursday look like?"
+```
+
+Without `GOOGLE_ACCESS_TOKEN` it runs on an in-memory calendar with a busy tomorrow. Set the token to use your real Google Calendar.
+
+Guide: [cal-sdk.com/docs/guides/ai-sdk](https://www.cal-sdk.com/docs/guides/ai-sdk)
diff --git a/examples/ai-agent/package.json b/examples/ai-agent/package.json
new file mode 100644
index 0000000..06d341d
--- /dev/null
+++ b/examples/ai-agent/package.json
@@ -0,0 +1,16 @@
+{
+ "name": "calsdk-ai-agent",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "start": "bun --conditions=@cal-sdk/source src/index.ts"
+ },
+ "dependencies": {
+ "@ai-sdk/anthropic": "4.0.97",
+ "@cal-sdk/core": "workspace:*",
+ "@cal-sdk/google": "workspace:*",
+ "@cal-sdk/mock": "workspace:*",
+ "ai": "7.0.163",
+ "zod": "^4.6.7"
+ }
+}
diff --git a/examples/ai-agent/src/index.ts b/examples/ai-agent/src/index.ts
new file mode 100644
index 0000000..c1f93a4
--- /dev/null
+++ b/examples/ai-agent/src/index.ts
@@ -0,0 +1,112 @@
+import { anthropic } from '@ai-sdk/anthropic';
+import { type CalendarProvider, createEvent, findFreeTime, listEvents } from '@cal-sdk/core';
+import { google } from '@cal-sdk/google';
+import { mockCalendar } from '@cal-sdk/mock';
+import { generateText, isStepCount, tool } from 'ai';
+import { z } from 'zod';
+
+const localTime = z
+ .string()
+ .describe('Local date-time without an offset, e.g. 2026-11-09T14:00:00');
+const instant = z
+ .string()
+ .describe('ISO 8601 date-time with an offset, e.g. 2026-11-09T14:00:00+01:00');
+
+/**
+ * Calendar tools for one user. Build them per request with that user's provider, so an
+ * agent only ever sees the calendar of the person it's talking to.
+ */
+function calendarTools(calendar: CalendarProvider, timeZone: string) {
+ return {
+ listEvents: tool({
+ description: 'List events between two times, recurring events expanded.',
+ inputSchema: z.object({ from: instant, to: instant }),
+ execute: async ({ from, to }) => {
+ const { events } = await listEvents({
+ calendar,
+ timeMin: from,
+ timeMax: to,
+ });
+ return events.map(({ id, title, start, end }) => ({ id, title, start, end }));
+ },
+ }),
+
+ findFreeTime: tool({
+ description: 'Find free slots of a given length during working hours (09:00 to 18:00).',
+ inputSchema: z.object({
+ from: instant,
+ to: instant,
+ minutes: z.number().int().positive(),
+ }),
+ 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 },
+ slotInterval: 30,
+ });
+ 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().describe('Email address')).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, title: event.title, start: event.start, link: event.htmlLink };
+ },
+ }),
+ };
+}
+
+const timeZone = 'Europe/Berlin';
+const tomorrow = new Date(Date.now() + 86_400_000).toISOString().slice(0, 10);
+
+// A real calendar when a token is set, otherwise an in-memory one with a busy tomorrow.
+const calendar = process.env.GOOGLE_ACCESS_TOKEN
+ ? google({ accessToken: process.env.GOOGLE_ACCESS_TOKEN })
+ : mockCalendar({
+ events: [
+ {
+ title: 'Design review',
+ start: { dateTime: `${tomorrow}T13:00:00`, timeZone },
+ end: { dateTime: `${tomorrow}T15:00:00`, timeZone },
+ },
+ {
+ title: 'Customer call',
+ start: { dateTime: `${tomorrow}T15:30:00`, timeZone },
+ end: { dateTime: `${tomorrow}T16:30:00`, timeZone },
+ },
+ ],
+ });
+
+const { text, steps } = await generateText({
+ model: anthropic('claude-sonnet-5-5'),
+ tools: calendarTools(calendar, timeZone),
+ stopWhen: isStepCount(6),
+ instructions: `You manage the user's calendar. Today is ${new Date().toDateString()}. All times are in ${timeZone}.`,
+ prompt:
+ process.argv[2] ??
+ 'Find an hour tomorrow afternoon for a planning session with ana@example.com and book it.',
+});
+
+for (const step of steps)
+ for (const call of step.toolCalls) console.log(`> ${call.toolName}`, JSON.stringify(call.input));
+console.log(`\n${text}`);
diff --git a/packages/mcp/README.md b/packages/mcp/README.md
new file mode 100644
index 0000000..dc7640e
--- /dev/null
+++ b/packages/mcp/README.md
@@ -0,0 +1,64 @@
+# @cal-sdk/mcp
+
+An MCP server that gives Claude, Cursor or any [Model Context Protocol](https://modelcontextprotocol.io) client your Google Calendar, Outlook, iCloud or CalDAV calendars. The model can list events, find a free slot across every calendar and book, move or cancel meetings. It runs locally and calls the calendar APIs directly, built on [calsdk](https://www.cal-sdk.com).
+
+Try it on two in-memory calendars, no account needed:
+
+```bash
+npx -y @cal-sdk/mcp --demo
+```
+
+## Tools
+
+- `list_calendars`: calendars on every connected account
+- `list_events`: events in a window, recurring events expanded
+- `find_free_time`: slots when every chosen calendar is free, across accounts
+- `create_event`, `update_event`, `delete_event`: including single occurrences of a series
+
+## Setup
+
+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
+```
+
+Claude Desktop, Cursor and other clients:
+
+```json
+{
+ "mcpServers": {
+ "calendar": {
+ "command": "npx",
+ "args": ["-y", "@cal-sdk/mcp"],
+ "env": {
+ "GOOGLE_CLIENT_ID": "...",
+ "GOOGLE_CLIENT_SECRET": "...",
+ "GOOGLE_REFRESH_TOKEN": "..."
+ }
+ }
+ }
+}
+```
+
+| Calendar | Variables |
+| -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Google | `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `GOOGLE_REFRESH_TOKEN` (or `GOOGLE_ACCESS_TOKEN`) |
+| Outlook | `MICROSOFT_CLIENT_ID`, `MICROSOFT_REFRESH_TOKEN`, optional `MICROSOFT_CLIENT_SECRET`, `MICROSOFT_TENANT`, `MICROSOFT_SCOPE` (or `MICROSOFT_ACCESS_TOKEN`) |
+| iCloud | `ICLOUD_APPLE_ID`, `ICLOUD_APP_PASSWORD` |
+| CalDAV | `CALDAV_URL`, `CALDAV_USERNAME`, `CALDAV_PASSWORD` |
+
+How to get each token: [cal-sdk.com/docs/guides/mcp](https://www.cal-sdk.com/docs/guides/mcp).
+
+## From code
+
+```ts
+import { google } from '@cal-sdk/google';
+import { createCalendarServer } from '@cal-sdk/mcp';
+
+const server = createCalendarServer({ work: google({ accessToken: () => getToken() }) });
+```
+
+MIT licensed.
diff --git a/packages/mcp/package.json b/packages/mcp/package.json
new file mode 100644
index 0000000..4a2f889
--- /dev/null
+++ b/packages/mcp/package.json
@@ -0,0 +1,65 @@
+{
+ "name": "@cal-sdk/mcp",
+ "version": "0.0.0",
+ "description": "MCP server for Google Calendar, Outlook, iCloud and CalDAV: let AI agents read events, find free time and book meetings.",
+ "keywords": [
+ "mcp",
+ "mcp-server",
+ "model-context-protocol",
+ "ai-agents",
+ "calendar",
+ "google-calendar",
+ "outlook",
+ "icloud",
+ "caldav",
+ "scheduling",
+ "free-busy",
+ "calsdk"
+ ],
+ "license": "MIT",
+ "homepage": "https://www.cal-sdk.com/docs/guides/mcp",
+ "mcpName": "io.github.vkpdeveloper/calsdk",
+ "type": "module",
+ "sideEffects": false,
+ "bin": {
+ "cal-sdk-mcp": "./dist/cli.js"
+ },
+ "exports": {
+ ".": {
+ "@cal-sdk/source": "./src/index.ts",
+ "types": "./dist/index.d.ts",
+ "default": "./dist/index.js"
+ },
+ "./package.json": "./package.json"
+ },
+ "types": "./dist/index.d.ts",
+ "files": [
+ "dist"
+ ],
+ "scripts": {
+ "build": "tsdown"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/vkpdeveloper/calsdk.git",
+ "directory": "packages/mcp"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "publishConfig": {
+ "access": "public"
+ },
+ "dependencies": {
+ "@cal-sdk/caldav": "workspace:*",
+ "@cal-sdk/core": "workspace:*",
+ "@cal-sdk/google": "workspace:*",
+ "@cal-sdk/microsoft": "workspace:*",
+ "@cal-sdk/mock": "workspace:*",
+ "@modelcontextprotocol/server": "^2.4.0",
+ "zod": "^4.6.7"
+ },
+ "devDependencies": {
+ "@modelcontextprotocol/client": "^2.4.0"
+ }
+}
diff --git a/packages/mcp/server.json b/packages/mcp/server.json
new file mode 100644
index 0000000..108bdec
--- /dev/null
+++ b/packages/mcp/server.json
@@ -0,0 +1,73 @@
+{
+ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
+ "name": "io.github.vkpdeveloper/calsdk",
+ "title": "calsdk calendars",
+ "description": "Google Calendar, Outlook, iCloud and CalDAV for agents: list events, find free time, book.",
+ "websiteUrl": "https://www.cal-sdk.com/docs/guides/mcp",
+ "repository": {
+ "url": "https://github.com/vkpdeveloper/calsdk",
+ "source": "github",
+ "subfolder": "packages/mcp"
+ },
+ "version": "0.1.0",
+ "packages": [
+ {
+ "registryType": "npm",
+ "registryBaseUrl": "https://registry.npmjs.org",
+ "identifier": "@cal-sdk/mcp",
+ "version": "0.1.0",
+ "runtimeHint": "npx",
+ "transport": { "type": "stdio" },
+ "environmentVariables": [
+ {
+ "name": "GOOGLE_ACCESS_TOKEN",
+ "description": "Google OAuth access token with a Calendar scope",
+ "isSecret": true
+ },
+ {
+ "name": "GOOGLE_REFRESH_TOKEN",
+ "description": "Google refresh token, used with the client id and secret to stay signed in",
+ "isSecret": true
+ },
+ { "name": "GOOGLE_CLIENT_ID", "description": "Google OAuth client id" },
+ {
+ "name": "GOOGLE_CLIENT_SECRET",
+ "description": "Google OAuth client secret",
+ "isSecret": true
+ },
+ {
+ "name": "MICROSOFT_ACCESS_TOKEN",
+ "description": "Microsoft Graph access token with Calendars.ReadWrite",
+ "isSecret": true
+ },
+ {
+ "name": "MICROSOFT_REFRESH_TOKEN",
+ "description": "Microsoft refresh token, used with the client id to stay signed in",
+ "isSecret": true
+ },
+ { "name": "MICROSOFT_CLIENT_ID", "description": "Microsoft Entra app (client) id" },
+ {
+ "name": "MICROSOFT_CLIENT_SECRET",
+ "description": "Microsoft client secret, for confidential apps",
+ "isSecret": true
+ },
+ { "name": "ICLOUD_APPLE_ID", "description": "Apple ID email for iCloud Calendar" },
+ {
+ "name": "ICLOUD_APP_PASSWORD",
+ "description": "App-specific password from account.apple.com",
+ "isSecret": true
+ },
+ {
+ "name": "CALDAV_URL",
+ "description": "CalDAV server address, e.g. https://caldav.fastmail.com/dav/"
+ },
+ { "name": "CALDAV_USERNAME", "description": "CalDAV username" },
+ {
+ "name": "CALDAV_PASSWORD",
+ "description": "CalDAV password or app password",
+ "isSecret": true
+ }
+ ]
+ }
+ ]
+}
diff --git a/packages/mcp/src/accounts.ts b/packages/mcp/src/accounts.ts
new file mode 100644
index 0000000..9766979
--- /dev/null
+++ b/packages/mcp/src/accounts.ts
@@ -0,0 +1,105 @@
+import { caldav, icloud } from '@cal-sdk/caldav';
+import type { CalendarProvider } from '@cal-sdk/core';
+import { google } from '@cal-sdk/google';
+import { microsoft } from '@cal-sdk/microsoft';
+import { mockCalendar } from '@cal-sdk/mock';
+import { refreshingToken } from './refreshing-token';
+
+type Env = Record;
+
+/** Help text listing the variables `accountsFromEnv` reads. */
+export const envHelp = `Connect at least one calendar with environment variables:
+
+ Google GOOGLE_ACCESS_TOKEN, or GOOGLE_REFRESH_TOKEN + GOOGLE_CLIENT_ID + GOOGLE_CLIENT_SECRET
+ Outlook MICROSOFT_ACCESS_TOKEN, or MICROSOFT_REFRESH_TOKEN + MICROSOFT_CLIENT_ID
+ (+ MICROSOFT_CLIENT_SECRET for confidential apps, MICROSOFT_TENANT, MICROSOFT_SCOPE)
+ iCloud ICLOUD_APPLE_ID + ICLOUD_APP_PASSWORD
+ CalDAV CALDAV_URL + CALDAV_USERNAME + CALDAV_PASSWORD
+
+Or run with --demo to try it on an in-memory calendar.
+Docs: https://www.cal-sdk.com/docs/guides/mcp`;
+
+/**
+ * Builds the connected accounts from environment variables, keyed by the name tools use
+ * to pick one (`google`, `microsoft`, `icloud`, `caldav`). Unset providers are skipped.
+ */
+export function accountsFromEnv(env: Env): Record {
+ const accounts: Record = {};
+
+ const googleToken =
+ env.GOOGLE_ACCESS_TOKEN ??
+ refreshFromEnv(env, 'GOOGLE', { tokenUrl: 'https://oauth2.googleapis.com/token' });
+ if (googleToken) accounts.google = google({ accessToken: googleToken });
+
+ const tenant = env.MICROSOFT_TENANT ?? 'common';
+ const microsoftToken =
+ env.MICROSOFT_ACCESS_TOKEN ??
+ refreshFromEnv(env, 'MICROSOFT', {
+ tokenUrl: `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/token`,
+ // Microsoft wants the scopes again on refresh.
+ scope: env.MICROSOFT_SCOPE ?? 'offline_access Calendars.ReadWrite',
+ });
+ if (microsoftToken) accounts.microsoft = microsoft({ accessToken: microsoftToken });
+
+ if (env.ICLOUD_APPLE_ID && env.ICLOUD_APP_PASSWORD) {
+ accounts.icloud = icloud({
+ appleId: env.ICLOUD_APPLE_ID,
+ appPassword: env.ICLOUD_APP_PASSWORD,
+ });
+ }
+
+ if (env.CALDAV_URL && env.CALDAV_USERNAME && env.CALDAV_PASSWORD) {
+ accounts.caldav = caldav({
+ serverUrl: env.CALDAV_URL,
+ username: env.CALDAV_USERNAME,
+ password: env.CALDAV_PASSWORD,
+ });
+ }
+
+ return accounts;
+}
+
+function refreshFromEnv(
+ env: Env,
+ prefix: 'GOOGLE' | 'MICROSOFT',
+ options: { tokenUrl: string; scope?: string },
+) {
+ const refreshToken = env[`${prefix}_REFRESH_TOKEN`];
+ const clientId = env[`${prefix}_CLIENT_ID`];
+ if (!refreshToken || !clientId) return undefined;
+ return refreshingToken({
+ ...options,
+ refreshToken,
+ clientId,
+ clientSecret: env[`${prefix}_CLIENT_SECRET`],
+ });
+}
+
+/** Two in-memory calendars with a busy week ahead, for `--demo`. */
+export function demoAccounts(now = new Date()): Record {
+ const day = (offset: number, time: string) => {
+ const date = new Date(now);
+ date.setUTCDate(date.getUTCDate() + offset);
+ return { dateTime: `${date.toISOString().slice(0, 10)}T${time}:00Z`, timeZone: 'UTC' };
+ };
+
+ return {
+ work: mockCalendar({
+ calendars: [{ id: 'work', name: 'Work', timeZone: 'UTC' }],
+ events: [
+ {
+ title: 'Standup',
+ start: day(1, '09:30'),
+ end: day(1, '09:45'),
+ recurrence: ['RRULE:FREQ=DAILY;COUNT=10'],
+ },
+ { title: 'Design review', start: day(1, '14:00'), end: day(1, '15:30') },
+ { title: 'Customer call', start: day(2, '11:00'), end: day(2, '12:00') },
+ ],
+ }),
+ personal: mockCalendar({
+ calendars: [{ id: 'personal', name: 'Personal', timeZone: 'UTC' }],
+ events: [{ title: 'Dentist', start: day(2, '15:00'), end: day(2, '16:00') }],
+ }),
+ };
+}
diff --git a/packages/mcp/src/cli.ts b/packages/mcp/src/cli.ts
new file mode 100644
index 0000000..8e3a08a
--- /dev/null
+++ b/packages/mcp/src/cli.ts
@@ -0,0 +1,14 @@
+#!/usr/bin/env node
+import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';
+import { accountsFromEnv, demoAccounts, envHelp } from './accounts';
+import { createCalendarServer } from './server';
+
+const accounts = process.argv.includes('--demo') ? demoAccounts() : accountsFromEnv(process.env);
+
+if (Object.keys(accounts).length === 0) {
+ // stdout carries the protocol, so messages go to stderr.
+ console.error(`calsdk MCP server: no calendar connected.\n\n${envHelp}`);
+ process.exit(1);
+}
+
+await createCalendarServer(accounts).connect(new StdioServerTransport());
diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts
new file mode 100644
index 0000000..eefc915
--- /dev/null
+++ b/packages/mcp/src/index.ts
@@ -0,0 +1,3 @@
+export { accountsFromEnv, demoAccounts } from './accounts';
+export { refreshingToken } from './refreshing-token';
+export { createCalendarServer } from './server';
diff --git a/packages/mcp/src/refreshing-token.ts b/packages/mcp/src/refreshing-token.ts
new file mode 100644
index 0000000..a1f465f
--- /dev/null
+++ b/packages/mcp/src/refreshing-token.ts
@@ -0,0 +1,60 @@
+import { z } from 'zod';
+
+const tokenResponse = z.object({
+ access_token: z.string(),
+ expires_in: z.number().optional(),
+ refresh_token: z.string().optional(),
+});
+
+/**
+ * Trades an OAuth refresh token for access tokens, caching each one until a minute
+ * before it expires. Concurrent calls share one refresh.
+ */
+export function refreshingToken(options: {
+ tokenUrl: string;
+ clientId: string;
+ clientSecret?: string;
+ refreshToken: string;
+ scope?: string;
+ fetch?: typeof fetch;
+}): () => Promise {
+ const fetchToken = options.fetch ?? fetch;
+ // Microsoft can rotate the refresh token; keep the newest one.
+ let refreshToken = options.refreshToken;
+ let cached: { token: string; expiresAt: number } | undefined;
+ let pending: Promise | undefined;
+
+ async function refresh() {
+ const body = new URLSearchParams({
+ grant_type: 'refresh_token',
+ refresh_token: refreshToken,
+ client_id: options.clientId,
+ });
+ if (options.clientSecret) body.set('client_secret', options.clientSecret);
+ if (options.scope) body.set('scope', options.scope);
+
+ const response = await fetchToken(options.tokenUrl, { method: 'POST', body });
+ const json: unknown = await response.json().catch(() => undefined);
+ const parsed = tokenResponse.safeParse(json);
+ if (!response.ok || !parsed.success) {
+ const reason = z.object({ error_description: z.string() }).safeParse(json);
+ throw new Error(
+ `Refreshing the access token at ${options.tokenUrl} failed (HTTP ${response.status})` +
+ (reason.success ? `: ${reason.data.error_description}` : ''),
+ );
+ }
+
+ if (parsed.data.refresh_token) refreshToken = parsed.data.refresh_token;
+ const lifetimeMs = (parsed.data.expires_in ?? 3600) * 1000;
+ cached = { token: parsed.data.access_token, expiresAt: Date.now() + lifetimeMs - 60_000 };
+ return cached.token;
+ }
+
+ return () => {
+ if (cached && Date.now() < cached.expiresAt) return Promise.resolve(cached.token);
+ pending ??= refresh().finally(() => {
+ pending = undefined;
+ });
+ return pending;
+ };
+}
diff --git a/packages/mcp/src/server.test.ts b/packages/mcp/src/server.test.ts
new file mode 100644
index 0000000..b965521
--- /dev/null
+++ b/packages/mcp/src/server.test.ts
@@ -0,0 +1,111 @@
+import { mockCalendar } from '@cal-sdk/mock';
+import { Client, InMemoryTransport } from '@modelcontextprotocol/client';
+import { describe, expect, it, vi } from 'vitest';
+import { refreshingToken } from './refreshing-token';
+import { createCalendarServer } from './server';
+
+const start = { dateTime: '2026-11-09T10:00:00Z', timeZone: 'UTC' };
+const end = { dateTime: '2026-11-09T11:00:00Z', timeZone: 'UTC' };
+
+async function connect() {
+ const server = createCalendarServer({
+ work: mockCalendar({
+ calendars: [{ id: 'work' }],
+ events: [{ title: 'Standup', start, end }],
+ }),
+ home: mockCalendar({
+ calendars: [{ id: 'home' }],
+ events: [
+ {
+ title: 'Dentist',
+ start: { dateTime: '2026-11-09T12:00:00Z', timeZone: 'UTC' },
+ end: { dateTime: '2026-11-09T13:00:00Z', timeZone: 'UTC' },
+ },
+ ],
+ }),
+ });
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
+ const client = new Client({ name: 'test', version: '1.0.0' });
+ await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]);
+
+ return async (name: string, args: Record) => {
+ const result = await client.callTool({ name, arguments: args });
+ const [content] = result.content as { type: string; text: string }[];
+ return { isError: result.isError ?? false, text: content?.text ?? '' };
+ };
+}
+
+describe('createCalendarServer', () => {
+ it('creates an event on the chosen account and lists it back', async () => {
+ const call = await connect();
+ const created = await call('create_event', {
+ account: 'home',
+ event: { title: 'Call mom', start, end },
+ });
+ expect(created.isError).toBe(false);
+
+ const listed = await call('list_events', {
+ account: 'home',
+ timeMin: '2026-11-09',
+ timeMax: '2026-11-10',
+ });
+ const titles = JSON.parse(listed.text).events.map((event: { title: string }) => event.title);
+ expect(titles).toEqual(['Call mom', 'Dentist']);
+ });
+
+ it('finds free time across every account by default', async () => {
+ const call = await connect();
+ const { text } = await call('find_free_time', {
+ timeMin: '2026-11-09T09:00:00Z',
+ timeMax: '2026-11-09T14:00:00Z',
+ duration: 60,
+ slotInterval: 60,
+ });
+ expect(JSON.parse(text).slots).toEqual([
+ { start: '2026-11-09T09:00:00.000Z', end: '2026-11-09T10:00:00.000Z' },
+ { start: '2026-11-09T11:00:00.000Z', end: '2026-11-09T12:00:00.000Z' },
+ { start: '2026-11-09T13:00:00.000Z', end: '2026-11-09T14:00:00.000Z' },
+ ]);
+ });
+
+ it('reports calendar errors to the model instead of crashing', async () => {
+ const call = await connect();
+ const result = await call('delete_event', { eventId: 'missing' });
+ expect(result.isError).toBe(true);
+ expect(result.text).toMatch(/not found/);
+ });
+});
+
+describe('refreshingToken', () => {
+ it('refreshes once for concurrent calls and reuses the token until it expires', async () => {
+ const fetch = vi.fn(async () =>
+ Response.json({ access_token: 'fresh', expires_in: 3600, refresh_token: 'rotated' }),
+ );
+ const token = refreshingToken({
+ tokenUrl: 'https://auth.example/token',
+ clientId: 'client',
+ refreshToken: 'original',
+ fetch,
+ });
+
+ expect(await Promise.all([token(), token()])).toEqual(['fresh', 'fresh']);
+ expect(await token()).toBe('fresh');
+ expect(fetch).toHaveBeenCalledTimes(1);
+ });
+
+ it('includes the provider error in the message', async () => {
+ const fetch = vi.fn(async () =>
+ Response.json(
+ { error: 'invalid_grant', error_description: 'Token revoked' },
+ { status: 400 },
+ ),
+ );
+ const token = refreshingToken({
+ tokenUrl: 'https://auth.example/token',
+ clientId: 'client',
+ refreshToken: 'revoked',
+ fetch,
+ });
+ await expect(token()).rejects.toThrow('Token revoked');
+ });
+});
diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts
new file mode 100644
index 0000000..aa07243
--- /dev/null
+++ b/packages/mcp/src/server.ts
@@ -0,0 +1,266 @@
+import {
+ type CalendarProvider,
+ createEvent,
+ deleteEvent,
+ findFreeTime,
+ listCalendars,
+ listEvents,
+ updateEvent,
+} from '@cal-sdk/core';
+import { McpServer } from '@modelcontextprotocol/server';
+import { z } from 'zod';
+import packageJson from '../package.json' with { type: 'json' };
+
+const time = z.union([
+ z.object({
+ dateTime: z
+ .string()
+ .describe('ISO 8601 date-time, with or without an offset, e.g. 2026-11-03T10:00:00'),
+ timeZone: z.string().describe('IANA time zone, e.g. Europe/Berlin'),
+ }),
+ z.object({
+ date: z.string().describe('YYYY-MM-DD for all-day events. For end, the day after the last day'),
+ }),
+]);
+const attendees = z.array(
+ z.object({ email: z.string(), name: z.string().optional(), optional: z.boolean().optional() }),
+);
+const recurrence = z
+ .array(z.string())
+ .describe('RFC 5545 lines, e.g. ["RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=10"]');
+const showAs = z.enum(['busy', 'free']);
+const visibility = z.enum(['default', 'public', 'private']);
+const scope = z
+ .enum(['this', 'all', 'thisAndFollowing'])
+ .optional()
+ .describe(
+ 'For an instance of a recurring event: this one (default), the series, or this and later ones',
+ );
+const sendUpdates = z
+ .enum(['all', 'externalOnly', 'none'])
+ .optional()
+ .describe('Whether attendees are emailed. Provider default when omitted');
+const calendarId = z
+ .string()
+ .optional()
+ .describe('Calendar id from list_calendars. Defaults to the primary calendar');
+const timeBound = (edge: string) =>
+ z
+ .string()
+ .describe(`${edge} of the window: ISO 8601 with an offset, e.g. 2026-11-09T09:00:00+01:00`);
+
+const eventInput = z.object({
+ title: z.string().optional(),
+ description: z.string().optional(),
+ location: z.string().optional(),
+ start: time,
+ end: time,
+ recurrence: recurrence.optional(),
+ attendees: attendees.optional(),
+ createConference: z.boolean().optional().describe('Add a Google Meet or Teams link'),
+ showAs: showAs.optional(),
+ visibility: visibility.optional(),
+});
+const eventPatch = z.object({
+ title: z.string().nullable().optional(),
+ description: z.string().nullable().optional(),
+ location: z.string().nullable().optional(),
+ start: time.optional(),
+ end: time.optional(),
+ recurrence: recurrence.nullable().optional(),
+ attendees: attendees.nullable().optional(),
+ createConference: z.boolean().nullable().optional(),
+ showAs: showAs.nullable().optional(),
+ visibility: visibility.nullable().optional(),
+});
+
+/** A tool result carrying `value` as JSON text. */
+function json(value: unknown) {
+ return { content: [{ type: 'text' as const, text: JSON.stringify(value, null, 2) }] };
+}
+
+/**
+ * Creates an MCP server exposing calendar tools over the given accounts, keyed by the
+ * name the model uses to pick one. The first account is the default.
+ *
+ * @example
+ * const server = createCalendarServer({ work: google({ accessToken }), home: icloud(...) });
+ * await server.connect(new StdioServerTransport());
+ */
+export function createCalendarServer(accounts: Record): McpServer {
+ const [defaultName, ...otherNames] = Object.keys(accounts);
+ if (defaultName === undefined) throw new Error('createCalendarServer needs at least one account');
+ const names = [defaultName, ...otherNames] as const;
+
+ const accountName = z
+ .enum(names)
+ .optional()
+ .describe(`Connected account. Defaults to "${defaultName}"`);
+
+ function calendarOf(name: string = names[0]) {
+ const calendar = accounts[name];
+ if (!calendar) throw new Error(`Unknown account "${name}". Connected: ${names.join(', ')}`);
+ return calendar;
+ }
+
+ const server = new McpServer({
+ name: 'calsdk',
+ title: 'calsdk calendars',
+ version: packageJson.version,
+ websiteUrl: 'https://www.cal-sdk.com/docs/guides/mcp',
+ });
+
+ server.registerTool(
+ 'list_calendars',
+ {
+ title: 'List calendars',
+ description: `List calendars with their ids, names, time zones and access. Connected accounts: ${names.join(', ')}.`,
+ inputSchema: z.object({ account: accountName.describe('Omit to list every account') }),
+ annotations: { readOnlyHint: true },
+ },
+ async ({ account }) => {
+ const chosen = account ? [account] : names;
+ const result = await Promise.all(
+ chosen.map(async (name) => {
+ const { calendars, warnings } = await listCalendars({ calendar: calendarOf(name) });
+ return { account: name, calendars, warnings };
+ }),
+ );
+ return json(result);
+ },
+ );
+
+ server.registerTool(
+ 'list_events',
+ {
+ title: 'List events',
+ description:
+ 'List events in a time window, with recurring events expanded into their occurrences, ordered by start.',
+ inputSchema: z.object({
+ account: accountName,
+ calendarId,
+ timeMin: timeBound('Start'),
+ timeMax: timeBound('End'),
+ query: z.string().optional().describe('Free-text search, where the provider supports it'),
+ pageToken: z.string().optional().describe('nextPageToken from a previous call'),
+ }),
+ annotations: { readOnlyHint: true },
+ },
+ async ({ account, ...options }) => {
+ const { events, nextPageToken, warnings } = await listEvents({
+ calendar: calendarOf(account),
+ ...options,
+ pageSize: 100,
+ });
+ return json({ events, nextPageToken, warnings });
+ },
+ );
+
+ server.registerTool(
+ 'find_free_time',
+ {
+ title: 'Find free time',
+ description:
+ 'Find meeting slots when every listed calendar is free, across accounts and providers. Fails instead of guessing if a calendar cannot be read.',
+ inputSchema: z.object({
+ calendars: z
+ .array(z.object({ account: z.enum(names), calendarIds: z.array(z.string()).optional() }))
+ .optional()
+ .describe(
+ 'Calendars that must be free. Defaults to the primary calendar of every account',
+ ),
+ timeMin: timeBound('Start'),
+ timeMax: timeBound('End'),
+ duration: z.number().int().positive().describe('Meeting length in minutes'),
+ workingHours: z
+ .object({
+ start: z.string().describe('HH:mm'),
+ end: z.string().describe('HH:mm'),
+ timeZone: z.string().describe('IANA time zone'),
+ days: z
+ .array(z.number().int().min(0).max(6))
+ .optional()
+ .describe('0 is Sunday. Defaults to Monday to Friday'),
+ })
+ .optional(),
+ slotInterval: z
+ .number()
+ .int()
+ .positive()
+ .optional()
+ .describe('Minutes between slot starts. Defaults to 15'),
+ maxSlots: z.number().int().positive().optional().describe('Defaults to 20'),
+ }),
+ annotations: { readOnlyHint: true },
+ },
+ async ({ calendars, maxSlots = 20, ...options }) => {
+ const selected = calendars ?? names.map((account) => ({ account, calendarIds: undefined }));
+ const { slots, warnings } = await findFreeTime({
+ ...options,
+ calendars: selected.map(({ account, calendarIds }) => ({
+ calendar: calendarOf(account),
+ calendarIds,
+ })),
+ });
+ return json({ slots: slots.slice(0, maxSlots), totalSlots: slots.length, warnings });
+ },
+ );
+
+ server.registerTool(
+ 'create_event',
+ {
+ title: 'Create event',
+ description:
+ 'Create an event, optionally recurring or with attendees, and return it as stored.',
+ inputSchema: z.object({ account: accountName, calendarId, event: eventInput, sendUpdates }),
+ },
+ async ({ account, ...options }) => {
+ const { event, warnings } = await createEvent({ calendar: calendarOf(account), ...options });
+ return json({ event, warnings });
+ },
+ );
+
+ server.registerTool(
+ 'update_event',
+ {
+ title: 'Update event',
+ description:
+ 'Change some fields of an event. Omitted fields stay the same and null clears a field.',
+ inputSchema: z.object({
+ account: accountName,
+ calendarId,
+ eventId: z.string(),
+ patch: eventPatch,
+ scope,
+ sendUpdates,
+ }),
+ annotations: { idempotentHint: true },
+ },
+ async ({ account, ...options }) => {
+ const { event, warnings } = await updateEvent({ calendar: calendarOf(account), ...options });
+ return json({ event, warnings });
+ },
+ );
+
+ server.registerTool(
+ 'delete_event',
+ {
+ title: 'Delete event',
+ description: 'Delete an event, one instance of a recurring event, or part of a series.',
+ inputSchema: z.object({
+ account: accountName,
+ calendarId,
+ eventId: z.string(),
+ scope,
+ sendUpdates,
+ }),
+ annotations: { destructiveHint: true, idempotentHint: true },
+ },
+ async ({ account, ...options }) => {
+ const { warnings } = await deleteEvent({ calendar: calendarOf(account), ...options });
+ return json({ deleted: true, warnings });
+ },
+ );
+
+ return server;
+}
diff --git a/packages/mcp/tsdown.config.ts b/packages/mcp/tsdown.config.ts
new file mode 100644
index 0000000..46336b9
--- /dev/null
+++ b/packages/mcp/tsdown.config.ts
@@ -0,0 +1,10 @@
+import { defineConfig } from 'tsdown';
+
+// Unlike the other packages this one is a Node program: it reads stdin and ships a CLI.
+export default defineConfig({
+ entry: ['src/index.ts', 'src/cli.ts'],
+ format: 'esm',
+ platform: 'node',
+ dts: true,
+ fixedExtension: false,
+});
From 237d562a2fe77a9001af36fb803515624bad5ed3 Mon Sep 17 00:00:00 2001
From: vkpdeveloper
Date: Fri, 6 Nov 2026 02:11:15 +0530
Subject: [PATCH 02/16] fix(mcp): list only the chosen account in
list_calendars, even an empty-named one
---
packages/mcp/src/server.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts
index aa07243..eb490d7 100644
--- a/packages/mcp/src/server.ts
+++ b/packages/mcp/src/server.ts
@@ -119,7 +119,7 @@ export function createCalendarServer(accounts: Record)
annotations: { readOnlyHint: true },
},
async ({ account }) => {
- const chosen = account ? [account] : names;
+ const chosen = account === undefined ? names : [account];
const result = await Promise.all(
chosen.map(async (name) => {
const { calendars, warnings } = await listCalendars({ calendar: calendarOf(name) });
From 93045b86f2edcfb07ddefbb7d73d644e973b9584 Mon Sep 17 00:00:00 2001
From: vkpdeveloper
Date: Fri, 6 Nov 2026 02:11:38 +0530
Subject: [PATCH 03/16] fix(mcp): reject empty calendar lists in find_free_time
---
packages/mcp/src/server.test.ts | 13 +++++++++++++
packages/mcp/src/server.ts | 9 ++++++++-
2 files changed, 21 insertions(+), 1 deletion(-)
diff --git a/packages/mcp/src/server.test.ts b/packages/mcp/src/server.test.ts
index b965521..86384fd 100644
--- a/packages/mcp/src/server.test.ts
+++ b/packages/mcp/src/server.test.ts
@@ -68,6 +68,19 @@ describe('createCalendarServer', () => {
]);
});
+ it('rejects an empty calendar list instead of calling every slot free', async () => {
+ const call = await connect();
+ const window = { timeMin: '2026-11-09T09:00:00Z', timeMax: '2026-11-09T14:00:00Z' };
+ const noCalendars = await call('find_free_time', { ...window, duration: 60, calendars: [] });
+ expect(noCalendars.isError).toBe(true);
+ const noIds = await call('find_free_time', {
+ ...window,
+ duration: 60,
+ calendars: [{ account: 'work', calendarIds: [] }],
+ });
+ expect(noIds.isError).toBe(true);
+ });
+
it('reports calendar errors to the model instead of crashing', async () => {
const call = await connect();
const result = await call('delete_event', { eventId: 'missing' });
diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts
index eb490d7..4985f58 100644
--- a/packages/mcp/src/server.ts
+++ b/packages/mcp/src/server.ts
@@ -163,8 +163,15 @@ export function createCalendarServer(accounts: Record)
description:
'Find meeting slots when every listed calendar is free, across accounts and providers. Fails instead of guessing if a calendar cannot be read.',
inputSchema: z.object({
+ // An empty list would read no calendar and call every slot free.
calendars: z
- .array(z.object({ account: z.enum(names), calendarIds: z.array(z.string()).optional() }))
+ .array(
+ z.object({
+ account: z.enum(names),
+ calendarIds: z.array(z.string()).min(1).optional(),
+ }),
+ )
+ .min(1)
.optional()
.describe(
'Calendars that must be free. Defaults to the primary calendar of every account',
From 35b636dfb1366b626776f922067d7617ef1433ab Mon Sep 17 00:00:00 2001
From: vkpdeveloper
Date: Fri, 6 Nov 2026 02:12:03 +0530
Subject: [PATCH 04/16] fix(mcp): cap find_free_time windows at 92 days so a
search can't block the server
---
packages/mcp/src/server.test.ts | 12 ++++++++++++
packages/mcp/src/server.ts | 15 +++++++++++++--
2 files changed, 25 insertions(+), 2 deletions(-)
diff --git a/packages/mcp/src/server.test.ts b/packages/mcp/src/server.test.ts
index 86384fd..c8e246e 100644
--- a/packages/mcp/src/server.test.ts
+++ b/packages/mcp/src/server.test.ts
@@ -81,6 +81,18 @@ describe('createCalendarServer', () => {
expect(noIds.isError).toBe(true);
});
+ it('refuses a free-time window too long to search', async () => {
+ const call = await connect();
+ const result = await call('find_free_time', {
+ timeMin: '2026-01-01T00:00:00Z',
+ timeMax: '2036-01-01T00:00:00Z',
+ duration: 30,
+ slotInterval: 1,
+ });
+ expect(result.isError).toBe(true);
+ expect(result.text).toMatch(/at most 92 days/);
+ });
+
it('reports calendar errors to the model instead of crashing', async () => {
const call = await connect();
const result = await call('delete_event', { eventId: 'missing' });
diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts
index 4985f58..e86ef0c 100644
--- a/packages/mcp/src/server.ts
+++ b/packages/mcp/src/server.ts
@@ -49,6 +49,9 @@ const timeBound = (edge: string) =>
.string()
.describe(`${edge} of the window: ISO 8601 with an offset, e.g. 2026-11-09T09:00:00+01:00`);
+const DAY_MS = 86_400_000;
+const MAX_FREE_TIME_DAYS = 92;
+
const eventInput = z.object({
title: z.string().optional(),
description: z.string().optional(),
@@ -160,8 +163,7 @@ export function createCalendarServer(accounts: Record)
'find_free_time',
{
title: 'Find free time',
- description:
- 'Find meeting slots when every listed calendar is free, across accounts and providers. Fails instead of guessing if a calendar cannot be read.',
+ description: `Find meeting slots when every listed calendar is free, across accounts and providers, in a window of up to ${MAX_FREE_TIME_DAYS} days. Fails instead of guessing if a calendar cannot be read.`,
inputSchema: z.object({
// An empty list would read no calendar and call every slot free.
calendars: z
@@ -201,6 +203,15 @@ export function createCalendarServer(accounts: Record)
annotations: { readOnlyHint: true },
},
async ({ calendars, maxSlots = 20, ...options }) => {
+ // findFreeTime builds every slot in the window before maxSlots applies, so a long
+ // window with a short interval would block the server. Unparseable bounds are left
+ // to findFreeTime, which rejects them.
+ const windowMs = Date.parse(options.timeMax) - Date.parse(options.timeMin);
+ if (windowMs > MAX_FREE_TIME_DAYS * DAY_MS) {
+ throw new Error(
+ `find_free_time searches at most ${MAX_FREE_TIME_DAYS} days at a time. Split the window.`,
+ );
+ }
const selected = calendars ?? names.map((account) => ({ account, calendarIds: undefined }));
const { slots, warnings } = await findFreeTime({
...options,
From dab85b1b8614d3ed5ebf7f6f5fa73ac1d7a7a8cc Mon Sep 17 00:00:00 2001
From: vkpdeveloper
Date: Fri, 6 Nov 2026 02:12:28 +0530
Subject: [PATCH 05/16] fix(mcp): stop calendar calls when the client cancels
the tool request
---
packages/mcp/src/server.ts | 37 +++++++++++++++++++++++++++----------
1 file changed, 27 insertions(+), 10 deletions(-)
diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts
index e86ef0c..8bbe1e1 100644
--- a/packages/mcp/src/server.ts
+++ b/packages/mcp/src/server.ts
@@ -121,11 +121,14 @@ export function createCalendarServer(accounts: Record)
inputSchema: z.object({ account: accountName.describe('Omit to list every account') }),
annotations: { readOnlyHint: true },
},
- async ({ account }) => {
+ async ({ account }, ctx) => {
const chosen = account === undefined ? names : [account];
const result = await Promise.all(
chosen.map(async (name) => {
- const { calendars, warnings } = await listCalendars({ calendar: calendarOf(name) });
+ const { calendars, warnings } = await listCalendars({
+ calendar: calendarOf(name),
+ abortSignal: ctx.mcpReq.signal,
+ });
return { account: name, calendars, warnings };
}),
);
@@ -149,11 +152,12 @@ export function createCalendarServer(accounts: Record)
}),
annotations: { readOnlyHint: true },
},
- async ({ account, ...options }) => {
+ async ({ account, ...options }, ctx) => {
const { events, nextPageToken, warnings } = await listEvents({
calendar: calendarOf(account),
...options,
pageSize: 100,
+ abortSignal: ctx.mcpReq.signal,
});
return json({ events, nextPageToken, warnings });
},
@@ -202,7 +206,7 @@ export function createCalendarServer(accounts: Record)
}),
annotations: { readOnlyHint: true },
},
- async ({ calendars, maxSlots = 20, ...options }) => {
+ async ({ calendars, maxSlots = 20, ...options }, ctx) => {
// findFreeTime builds every slot in the window before maxSlots applies, so a long
// window with a short interval would block the server. Unparseable bounds are left
// to findFreeTime, which rejects them.
@@ -219,6 +223,7 @@ export function createCalendarServer(accounts: Record)
calendar: calendarOf(account),
calendarIds,
})),
+ abortSignal: ctx.mcpReq.signal,
});
return json({ slots: slots.slice(0, maxSlots), totalSlots: slots.length, warnings });
},
@@ -232,8 +237,12 @@ export function createCalendarServer(accounts: Record)
'Create an event, optionally recurring or with attendees, and return it as stored.',
inputSchema: z.object({ account: accountName, calendarId, event: eventInput, sendUpdates }),
},
- async ({ account, ...options }) => {
- const { event, warnings } = await createEvent({ calendar: calendarOf(account), ...options });
+ async ({ account, ...options }, ctx) => {
+ const { event, warnings } = await createEvent({
+ calendar: calendarOf(account),
+ ...options,
+ abortSignal: ctx.mcpReq.signal,
+ });
return json({ event, warnings });
},
);
@@ -254,8 +263,12 @@ export function createCalendarServer(accounts: Record)
}),
annotations: { idempotentHint: true },
},
- async ({ account, ...options }) => {
- const { event, warnings } = await updateEvent({ calendar: calendarOf(account), ...options });
+ async ({ account, ...options }, ctx) => {
+ const { event, warnings } = await updateEvent({
+ calendar: calendarOf(account),
+ ...options,
+ abortSignal: ctx.mcpReq.signal,
+ });
return json({ event, warnings });
},
);
@@ -274,8 +287,12 @@ export function createCalendarServer(accounts: Record)
}),
annotations: { destructiveHint: true, idempotentHint: true },
},
- async ({ account, ...options }) => {
- const { warnings } = await deleteEvent({ calendar: calendarOf(account), ...options });
+ async ({ account, ...options }, ctx) => {
+ const { warnings } = await deleteEvent({
+ calendar: calendarOf(account),
+ ...options,
+ abortSignal: ctx.mcpReq.signal,
+ });
return json({ deleted: true, warnings });
},
);
From 93c4360bb92225ef90570fda696e2d91d85c124b Mon Sep 17 00:00:00 2001
From: vkpdeveloper
Date: Fri, 6 Nov 2026 02:12:34 +0530
Subject: [PATCH 06/16] fix(mcp): stop marking update_event as safe to retry
---
packages/mcp/src/server.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts
index 8bbe1e1..e8e419d 100644
--- a/packages/mcp/src/server.ts
+++ b/packages/mcp/src/server.ts
@@ -261,7 +261,7 @@ export function createCalendarServer(accounts: Record)
scope,
sendUpdates,
}),
- annotations: { idempotentHint: true },
+ // Not idempotent: a retried createConference adds another meeting link.
},
async ({ account, ...options }, ctx) => {
const { event, warnings } = await updateEvent({
From 40bc67e7e8ac1fc76810af28c2685746c04a4589 Mon Sep 17 00:00:00 2001
From: vkpdeveloper
Date: Fri, 6 Nov 2026 02:14:56 +0530
Subject: [PATCH 07/16] fix(mcp): treat empty token variables as unset
---
packages/mcp/src/accounts.ts | 9 +++++----
packages/mcp/src/server.test.ts | 12 ++++++++++++
2 files changed, 17 insertions(+), 4 deletions(-)
diff --git a/packages/mcp/src/accounts.ts b/packages/mcp/src/accounts.ts
index 9766979..5a2bd1d 100644
--- a/packages/mcp/src/accounts.ts
+++ b/packages/mcp/src/accounts.ts
@@ -25,19 +25,20 @@ Docs: https://www.cal-sdk.com/docs/guides/mcp`;
*/
export function accountsFromEnv(env: Env): Record {
const accounts: Record = {};
+ // `||`, not `??`: an empty variable, as an MCP client config often leaves, counts as unset.
const googleToken =
- env.GOOGLE_ACCESS_TOKEN ??
+ env.GOOGLE_ACCESS_TOKEN ||
refreshFromEnv(env, 'GOOGLE', { tokenUrl: 'https://oauth2.googleapis.com/token' });
if (googleToken) accounts.google = google({ accessToken: googleToken });
- const tenant = env.MICROSOFT_TENANT ?? 'common';
+ const tenant = env.MICROSOFT_TENANT || 'common';
const microsoftToken =
- env.MICROSOFT_ACCESS_TOKEN ??
+ env.MICROSOFT_ACCESS_TOKEN ||
refreshFromEnv(env, 'MICROSOFT', {
tokenUrl: `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/token`,
// Microsoft wants the scopes again on refresh.
- scope: env.MICROSOFT_SCOPE ?? 'offline_access Calendars.ReadWrite',
+ scope: env.MICROSOFT_SCOPE || 'offline_access Calendars.ReadWrite',
});
if (microsoftToken) accounts.microsoft = microsoft({ accessToken: microsoftToken });
diff --git a/packages/mcp/src/server.test.ts b/packages/mcp/src/server.test.ts
index c8e246e..12e6289 100644
--- a/packages/mcp/src/server.test.ts
+++ b/packages/mcp/src/server.test.ts
@@ -1,6 +1,7 @@
import { mockCalendar } from '@cal-sdk/mock';
import { Client, InMemoryTransport } from '@modelcontextprotocol/client';
import { describe, expect, it, vi } from 'vitest';
+import { accountsFromEnv } from './accounts';
import { refreshingToken } from './refreshing-token';
import { createCalendarServer } from './server';
@@ -134,3 +135,14 @@ describe('refreshingToken', () => {
await expect(token()).rejects.toThrow('Token revoked');
});
});
+
+describe('accountsFromEnv', () => {
+ it('treats an empty access token as unset and falls back to the refresh token', () => {
+ const accounts = accountsFromEnv({
+ GOOGLE_ACCESS_TOKEN: '',
+ GOOGLE_REFRESH_TOKEN: 'refresh',
+ GOOGLE_CLIENT_ID: 'client',
+ });
+ expect(Object.keys(accounts)).toEqual(['google']);
+ });
+});
From 9a84bde08b89526c514b36958ef9e66e5dc586a4 Mon Sep 17 00:00:00 2001
From: vkpdeveloper
Date: Fri, 6 Nov 2026 02:15:04 +0530
Subject: [PATCH 08/16] fix(mcp): time out token refreshes after 15 seconds
---
packages/mcp/src/refreshing-token.ts | 7 ++++++-
1 file changed, 6 insertions(+), 1 deletion(-)
diff --git a/packages/mcp/src/refreshing-token.ts b/packages/mcp/src/refreshing-token.ts
index a1f465f..866bbf7 100644
--- a/packages/mcp/src/refreshing-token.ts
+++ b/packages/mcp/src/refreshing-token.ts
@@ -33,7 +33,12 @@ export function refreshingToken(options: {
if (options.clientSecret) body.set('client_secret', options.clientSecret);
if (options.scope) body.set('scope', options.scope);
- const response = await fetchToken(options.tokenUrl, { method: 'POST', body });
+ // Every caller waits on this one request, so a stalled token endpoint must not hold them.
+ const response = await fetchToken(options.tokenUrl, {
+ method: 'POST',
+ body,
+ signal: AbortSignal.timeout(15_000),
+ });
const json: unknown = await response.json().catch(() => undefined);
const parsed = tokenResponse.safeParse(json);
if (!response.ok || !parsed.success) {
From f220e064d552a8df3725f360d2b3d4c0f999812f Mon Sep 17 00:00:00 2001
From: vkpdeveloper
Date: Fri, 6 Nov 2026 02:15:27 +0530
Subject: [PATCH 09/16] test(mcp): cover token expiry and refresh token
rotation
---
packages/mcp/src/server.test.ts | 27 +++++++++++++++++++++------
1 file changed, 21 insertions(+), 6 deletions(-)
diff --git a/packages/mcp/src/server.test.ts b/packages/mcp/src/server.test.ts
index 12e6289..cf19a88 100644
--- a/packages/mcp/src/server.test.ts
+++ b/packages/mcp/src/server.test.ts
@@ -1,6 +1,6 @@
import { mockCalendar } from '@cal-sdk/mock';
import { Client, InMemoryTransport } from '@modelcontextprotocol/client';
-import { describe, expect, it, vi } from 'vitest';
+import { describe, expect, it, onTestFinished, vi } from 'vitest';
import { accountsFromEnv } from './accounts';
import { refreshingToken } from './refreshing-token';
import { createCalendarServer } from './server';
@@ -104,9 +104,19 @@ describe('createCalendarServer', () => {
describe('refreshingToken', () => {
it('refreshes once for concurrent calls and reuses the token until it expires', async () => {
- const fetch = vi.fn(async () =>
- Response.json({ access_token: 'fresh', expires_in: 3600, refresh_token: 'rotated' }),
- );
+ vi.useFakeTimers({ toFake: ['Date'] });
+ onTestFinished(() => {
+ vi.useRealTimers();
+ });
+ const grants: (string | null)[] = [];
+ const fetch = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => {
+ grants.push(new URLSearchParams(String(init?.body)).get('refresh_token'));
+ return Response.json({
+ access_token: `token-${grants.length}`,
+ expires_in: 3600,
+ refresh_token: 'rotated',
+ });
+ });
const token = refreshingToken({
tokenUrl: 'https://auth.example/token',
clientId: 'client',
@@ -114,9 +124,14 @@ describe('refreshingToken', () => {
fetch,
});
- expect(await Promise.all([token(), token()])).toEqual(['fresh', 'fresh']);
- expect(await token()).toBe('fresh');
+ expect(await Promise.all([token(), token()])).toEqual(['token-1', 'token-1']);
+ expect(await token()).toBe('token-1');
expect(fetch).toHaveBeenCalledTimes(1);
+
+ // Tokens are renewed a minute before they expire, with the rotated refresh token.
+ vi.advanceTimersByTime(3600_000 - 60_000);
+ expect(await token()).toBe('token-2');
+ expect(grants).toEqual(['original', 'rotated']);
});
it('includes the provider error in the message', async () => {
From 6d11479cb74b31065e047ca37bbd8a74befc6f14 Mon Sep 17 00:00:00 2001
From: vkpdeveloper
Date: Fri, 6 Nov 2026 02:15:35 +0530
Subject: [PATCH 10/16] docs(mcp): say find_free_time spans accounts, not every
calendar
---
apps/web/content/docs/guides/mcp.mdx | 2 +-
packages/mcp/README.md | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/apps/web/content/docs/guides/mcp.mdx b/apps/web/content/docs/guides/mcp.mdx
index 9745f5b..5dea05f 100644
--- a/apps/web/content/docs/guides/mcp.mdx
+++ b/apps/web/content/docs/guides/mcp.mdx
@@ -3,7 +3,7 @@ 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 calendars and put a meeting in it. It runs on your machine and talks to the calendar APIs directly: no hosted service sits in between.
+`@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:
diff --git a/packages/mcp/README.md b/packages/mcp/README.md
index dc7640e..5910c96 100644
--- a/packages/mcp/README.md
+++ b/packages/mcp/README.md
@@ -1,6 +1,6 @@
# @cal-sdk/mcp
-An MCP server that gives Claude, Cursor or any [Model Context Protocol](https://modelcontextprotocol.io) client your Google Calendar, Outlook, iCloud or CalDAV calendars. The model can list events, find a free slot across every calendar and book, move or cancel meetings. It runs locally and calls the calendar APIs directly, built on [calsdk](https://www.cal-sdk.com).
+An MCP server that gives Claude, Cursor or any [Model Context Protocol](https://modelcontextprotocol.io) client your Google Calendar, Outlook, iCloud or CalDAV calendars. The model can list events, find a free slot across all your accounts and book, move or cancel meetings. It runs locally and calls the calendar APIs directly, built on [calsdk](https://www.cal-sdk.com).
Try it on two in-memory calendars, no account needed:
From ab85c2892d671f004c168fafcac49b3f97b0d331 Mon Sep 17 00:00:00 2001
From: vkpdeveloper
Date: Fri, 6 Nov 2026 02:15:43 +0530
Subject: [PATCH 11/16] docs(mcp): describe the calendars argument of
find_free_time
---
apps/web/content/docs/guides/mcp.mdx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/web/content/docs/guides/mcp.mdx b/apps/web/content/docs/guides/mcp.mdx
index 5dea05f..86a4267 100644
--- a/apps/web/content/docs/guides/mcp.mdx
+++ b/apps/web/content/docs/guides/mcp.mdx
@@ -22,7 +22,7 @@ npx -y @cal-sdk/mcp --demo
| `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` checks all of them by default, and fails instead of guessing when one can't be read, so the model never books over a meeting it couldn't see.
+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
From de9444848aed136aac005ca11f9add5e9368e08d Mon Sep 17 00:00:00 2001
From: vkpdeveloper
Date: Fri, 6 Nov 2026 02:15:49 +0530
Subject: [PATCH 12/16] docs(ai-sdk): page through events in the listEvents
tool
---
apps/web/content/docs/guides/ai-sdk.mdx | 19 ++++++++++++++-----
1 file changed, 14 insertions(+), 5 deletions(-)
diff --git a/apps/web/content/docs/guides/ai-sdk.mdx b/apps/web/content/docs/guides/ai-sdk.mdx
index bd5b48c..fba5900 100644
--- a/apps/web/content/docs/guides/ai-sdk.mdx
+++ b/apps/web/content/docs/guides/ai-sdk.mdx
@@ -22,11 +22,20 @@ 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.',
- inputSchema: z.object({ from: instant, to: instant }),
- execute: async ({ from, to }) => {
- const { events } = await listEvents({ calendar, timeMin: from, timeMax: to });
- return events.map(({ id, title, start, end }) => ({ id, title, start, end }));
+ 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,
+ };
},
}),
From a2ef64adefb953b35906274c882d4f819967c3a8 Mon Sep 17 00:00:00 2001
From: vkpdeveloper
Date: Fri, 6 Nov 2026 02:15:55 +0530
Subject: [PATCH 13/16] docs(ai-sdk): give the agent today's date in the user's
time zone
---
apps/web/content/docs/guides/ai-sdk.mdx | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/apps/web/content/docs/guides/ai-sdk.mdx b/apps/web/content/docs/guides/ai-sdk.mdx
index fba5900..5cc49f2 100644
--- a/apps/web/content/docs/guides/ai-sdk.mdx
+++ b/apps/web/content/docs/guides/ai-sdk.mdx
@@ -90,11 +90,14 @@ 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 ${new Date().toDateString()}.`,
+ 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.',
});
```
From 9649eb8a57c38bdd8ee3f136724e6840846b5c01 Mon Sep 17 00:00:00 2001
From: vkpdeveloper
Date: Fri, 6 Nov 2026 02:16:04 +0530
Subject: [PATCH 14/16] fix(examples): page through events in the ai-agent
listEvents tool
---
examples/ai-agent/src/index.ts | 15 ++++++++++-----
1 file changed, 10 insertions(+), 5 deletions(-)
diff --git a/examples/ai-agent/src/index.ts b/examples/ai-agent/src/index.ts
index c1f93a4..c8fa9b6 100644
--- a/examples/ai-agent/src/index.ts
+++ b/examples/ai-agent/src/index.ts
@@ -19,15 +19,20 @@ const instant = z
function calendarTools(calendar: CalendarProvider, timeZone: string) {
return {
listEvents: tool({
- description: 'List events between two times, recurring events expanded.',
- inputSchema: z.object({ from: instant, to: instant }),
- execute: async ({ from, to }) => {
- const { events } = await listEvents({
+ 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.map(({ id, title, start, end }) => ({ id, title, start, end }));
+ return {
+ events: events.map(({ id, title, start, end }) => ({ id, title, start, end })),
+ nextPageToken,
+ };
},
}),
From 554e33bbced8e9469cba306e741d098157a8fca1 Mon Sep 17 00:00:00 2001
From: vkpdeveloper
Date: Fri, 6 Nov 2026 02:16:13 +0530
Subject: [PATCH 15/16] fix(examples): seed the ai-agent mock on tomorrow in
Berlin
---
examples/ai-agent/src/index.ts | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/examples/ai-agent/src/index.ts b/examples/ai-agent/src/index.ts
index c8fa9b6..0379894 100644
--- a/examples/ai-agent/src/index.ts
+++ b/examples/ai-agent/src/index.ts
@@ -82,7 +82,11 @@ function calendarTools(calendar: CalendarProvider, timeZone: string) {
}
const timeZone = 'Europe/Berlin';
-const tomorrow = new Date(Date.now() + 86_400_000).toISOString().slice(0, 10);
+// Tomorrow in Berlin, not in UTC: just after midnight there, UTC is still on the day before.
+const berlinToday = new Date().toLocaleDateString('en-CA', { timeZone }); // YYYY-MM-DD
+const nextDay = new Date(`${berlinToday}T00:00:00Z`);
+nextDay.setUTCDate(nextDay.getUTCDate() + 1);
+const tomorrow = nextDay.toISOString().slice(0, 10);
// A real calendar when a token is set, otherwise an in-memory one with a busy tomorrow.
const calendar = process.env.GOOGLE_ACCESS_TOKEN
From 53fbd69207d287c08c9152b39454efa4a432ae21 Mon Sep 17 00:00:00 2001
From: vkpdeveloper
Date: Fri, 6 Nov 2026 02:16:19 +0530
Subject: [PATCH 16/16] fix(examples): give the ai-agent today's date in Berlin
---
examples/ai-agent/src/index.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/examples/ai-agent/src/index.ts b/examples/ai-agent/src/index.ts
index 0379894..3452303 100644
--- a/examples/ai-agent/src/index.ts
+++ b/examples/ai-agent/src/index.ts
@@ -110,7 +110,7 @@ const { text, steps } = await generateText({
model: anthropic('claude-sonnet-5-5'),
tools: calendarTools(calendar, timeZone),
stopWhen: isStepCount(6),
- instructions: `You manage the user's calendar. Today is ${new Date().toDateString()}. All times are in ${timeZone}.`,
+ instructions: `You manage the user's calendar. Today is ${new Date().toLocaleDateString('en-US', { timeZone, dateStyle: 'full' })}. All times are in ${timeZone}.`,
prompt:
process.argv[2] ??
'Find an hour tomorrow afternoon for a planning session with ana@example.com and book it.',