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..5cc49f2 --- /dev/null +++ b/apps/web/content/docs/guides/ai-sdk.mdx @@ -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. diff --git a/apps/web/content/docs/guides/mcp.mdx b/apps/web/content/docs/guides/mcp.mdx new file mode 100644 index 0000000..86a4267 --- /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 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 + + + + +```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..3452303 --- /dev/null +++ b/examples/ai-agent/src/index.ts @@ -0,0 +1,121 @@ +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. 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 (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'; +// 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 + ? 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().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.', +}); + +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..5910c96 --- /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 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: + +```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..5a2bd1d --- /dev/null +++ b/packages/mcp/src/accounts.ts @@ -0,0 +1,106 @@ +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 = {}; + // `||`, not `??`: an empty variable, as an MCP client config often leaves, counts as unset. + + 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..866bbf7 --- /dev/null +++ b/packages/mcp/src/refreshing-token.ts @@ -0,0 +1,65 @@ +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); + + // 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) { + 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..cf19a88 --- /dev/null +++ b/packages/mcp/src/server.test.ts @@ -0,0 +1,163 @@ +import { mockCalendar } from '@cal-sdk/mock'; +import { Client, InMemoryTransport } from '@modelcontextprotocol/client'; +import { describe, expect, it, onTestFinished, vi } from 'vitest'; +import { accountsFromEnv } from './accounts'; +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('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('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' }); + 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 () => { + 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', + refreshToken: 'original', + fetch, + }); + + 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 () => { + 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'); + }); +}); + +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']); + }); +}); diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts new file mode 100644 index 0000000..e8e419d --- /dev/null +++ b/packages/mcp/src/server.ts @@ -0,0 +1,301 @@ +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 DAY_MS = 86_400_000; +const MAX_FREE_TIME_DAYS = 92; + +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 }, ctx) => { + const chosen = account === undefined ? names : [account]; + const result = await Promise.all( + chosen.map(async (name) => { + const { calendars, warnings } = await listCalendars({ + calendar: calendarOf(name), + abortSignal: ctx.mcpReq.signal, + }); + 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 }, ctx) => { + const { events, nextPageToken, warnings } = await listEvents({ + calendar: calendarOf(account), + ...options, + pageSize: 100, + abortSignal: ctx.mcpReq.signal, + }); + 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, 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 + .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', + ), + 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 }, 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. + 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, + calendars: selected.map(({ account, calendarIds }) => ({ + calendar: calendarOf(account), + calendarIds, + })), + abortSignal: ctx.mcpReq.signal, + }); + 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 }, ctx) => { + const { event, warnings } = await createEvent({ + calendar: calendarOf(account), + ...options, + abortSignal: ctx.mcpReq.signal, + }); + 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, + }), + // Not idempotent: a retried createConference adds another meeting link. + }, + async ({ account, ...options }, ctx) => { + const { event, warnings } = await updateEvent({ + calendar: calendarOf(account), + ...options, + abortSignal: ctx.mcpReq.signal, + }); + 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 }, ctx) => { + const { warnings } = await deleteEvent({ + calendar: calendarOf(account), + ...options, + abortSignal: ctx.mcpReq.signal, + }); + 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, +});