apple-calendar-mcp
by cykriz
README.md
# apple-calendar-mcp
A small, local MCP server for macOS. It lets Claude Desktop read your Apple Calendar and create, update and delete events. It talks to the macOS calendar store through EventKit, Apple's calendar framework; Calendar.app itself is not controlled.
MCP stands for Model Context Protocol. It is a standard through which Claude can use additional tools on your computer. This server provides five such tools.
## Contents
1. [Status and Warning](#status-and-warning)
2. [Requirements](#requirements)
3. [Installation](#installation)
4. [Configuration](#configuration)
5. [Tools Reference](#tools-reference)
6. [How It Works](#how-it-works)
7. [Known Limitations](#known-limitations)
8. [Troubleshooting](#troubleshooting)
9. [Security](#security)
10. [Outlook](#outlook)
11. [License](#license)
## Status and Warning
> **Warning: test with an unimportant calendar first.**
>
> * Tested automatically: the server starts and reports its tools correctly, and the tools reject wrong input, filter by `query`, map `osascript` errors and stop `osascript` on cancellation (against a stand-in `osascript`, not EventKit). This runs with Bun on macOS (`bun run test`); the start was checked once with Node on Linux.
> * The calendar part (EventKit) is tested only by hand, with the checklist below.
>
> Ideally create a separate calendar "MCP Test".
### Checklist for the first test run
Work through the items in order in a new chat in Claude Desktop. After each step, check in Calendar.app that the result is correct.
- [ ] The server shows up in Claude Desktop under "Local MCP servers" as "Running".
- [ ] macOS asks for Calendars access, and you granted full access.
- [ ] "List my calendars" returns all calendars with name and `writable`.
- [ ] "What events do I have tomorrow?" returns the right events with the right times.
- [ ] "Which events with 'dentist' in the title do I have in the next three months?" returns only matching events, quickly.
- [ ] Create a timed event in the test calendar. Does it appear at the right time?
- [ ] Create an event without an end time. Does it last exactly one hour?
- [ ] Create an event with an alert (e.g. 15 minutes before). Is the alert set in Calendar.app?
- [ ] Create an all-day event over two days (e.g. Monday to Tuesday). Does it span exactly those two days?
- [ ] Change the title of a test event.
- [ ] Move the start time of a test event. Does the duration stay the same?
- [ ] Delete a test event. Is it gone in Calendar.app?
- [ ] Deliberately use a wrong calendar name. Do you get the message "Calendar not found"?
- [ ] Ask for a day on which a weekly event that started months ago recurs. Does it appear with `recurring: true`?
- [ ] Ask for a range with an all-day event. Is its `end` the day after the last day, 00:00?
- [ ] Ask Claude to move a recurring event. Do you get the error "Recurring event: start/end would move the whole series"?
- [ ] Ask Claude to delete one occurrence of a recurring test event. Does it ask first, and is only that occurrence gone in Calendar.app?
<!-- load-bearing -->
## Requirements
* A Mac with macOS. The calendars must be visible in Calendar.app (iCloud, Exchange, subscriptions); the server reads the same store through EventKit.
* Full Calendars access for the app that starts the server (see [7. Allow calendar access](#7-allow-calendar-access)).
* Claude Desktop (the desktop app, not the web version and not the mobile app).
* [Bun](https://bun.sh) as the runtime. Bun runs JavaScript files, similar to Node.
* Internet only once, to get the code and run `bun install`. After that everything runs locally.
The server itself consists of these files:
| File | Content |
|---|---|
| `index.mjs` | The entire server. |
| `package.json` | Name, version and dependencies. The project uses ES modules (`"type": "module"`). |
All other files (tests, lint and type configuration, `.claude/`, `scripts/`) are only for development. See `CLAUDE.md`.
Dependencies (installed with `bun install`):
| Package | Version | Purpose |
|---|---|---|
| `@modelcontextprotocol/sdk` | `^1.32.0` | The official MCP library. |
| `zod` | `^3.25.0` | Describes and validates the tool parameters. |
| `@biomejs/biome`, `typescript`, `@types/bun` | dev only | Lint, format and typecheck during development. Not needed at runtime. See `CLAUDE.md`. |
## Installation
### 1. Check Bun
Open the terminal and enter:
```bash
bun --version
```
If a version number appears, Bun is installed. Then show the exact path:
```bash
which bun
```
Expected: `/opt/homebrew/bin/bun`. You need this path later in the configuration.
If Bun is missing, install it following the instructions on [bun.sh](https://bun.sh).
### 2. Get the code and install dependencies
Clone the repository into a folder of your choice; the URL is on the repository page under **Code**. Use `git clone`, not the ZIP download: `bun install` needs the git folder to set up the development hooks.
```bash
git clone <repository-url> /path/to/apple-calendar
cd /path/to/apple-calendar
bun install
```
`/path/to/apple-calendar` stands for the project folder (the one that contains `index.mjs`); `pwd` inside it prints the full path.
Afterwards there is a `node_modules` directory in the folder.
### 3. Trial run
```bash
bun index.mjs
```
**Deliberately, nothing is printed.** The server now waits for input from Claude Desktop. That is correct. Stop it with `Ctrl + C`.
If an error message appears instead, step 2 was usually forgotten.
### 4. Register in Claude Desktop
1. Open Claude Desktop.
2. Open the settings, then the "Local MCP servers" section.
3. Click "Edit configuration". The file `claude_desktop_config.json` opens.
4. Add the entry from the [Configuration](#configuration) section under `"mcpServers"`.
5. Save the file.
### 5. Restart Claude Desktop
Quit Claude Desktop **completely** with `Cmd + Q`. Closing only the window is not enough. Then start it again.
### 6. Check that the server is running
In the settings under "Local MCP servers", `apple-calendar` should be listed with the status "Running".
If not: click "Show logs" and see the [Troubleshooting](#troubleshooting) section.
### 7. Allow calendar access
On first access, macOS asks whether Claude Desktop may access your **Calendars**. Grant **full access**, not "Add Events Only".
Every tool needs full access. Without it, each tool returns the error "No full Calendars access. …". Change it under System Settings, then "Privacy & Security", then "Calendars": set the app that starts the server (Claude Desktop) to full access, then quit it with `Cmd + Q` and start it again.
No Automation permission for Calendar.app is needed.
### 8. Test
Start a new chat in Claude Desktop and write:
> List my calendars.
Then create a test event in an unimportant calendar, change it and delete it again. The detailed [checklist](#checklist-for-the-first-test-run) helps with this.
<!-- load-bearing -->
## Configuration
Entry for `claude_desktop_config.json`:
```json
"apple-calendar": {
"command": "/opt/homebrew/bin/bun",
"args": ["run", "/path/to/apple-calendar/index.mjs"]
}
```
This is the whole file if there are no other servers yet:
```json
{
"mcpServers": {
"apple-calendar": {
"command": "/opt/homebrew/bin/bun",
"args": ["run", "/path/to/apple-calendar/index.mjs"]
}
}
}
```
Important notes:
* **Full path to bun.** Claude Desktop does not know your terminal environment. A plain `"command": "bun"` is therefore often not found.
* **Full path to index.mjs.** Replace `/path/to/apple-calendar` with the project folder from step 2.
* **Comma between entries.** If there are already other servers under `"mcpServers"`, the entries must be separated by a comma.
* **Straight quotes.** Use only `"`, no typographic quotes such as „ or ". These easily appear when copying from word processors.
* **Do not change existing entries.**
### Alternative: Node instead of Bun
If Bun causes problems, the same code can run with Node. The code does not change. Find the Node path with `which node`. Example:
```json
"apple-calendar": {
"command": "/usr/local/bin/node",
"args": ["/path/to/apple-calendar/index.mjs"]
}
```
Replace `/usr/local/bin/node` with the output of `which node`. The `node_modules` folder from step 2 must exist.
<!-- load-bearing -->
## Tools Reference
Claude picks the tools itself. You just write in normal language what you want.
Every tool rejects parameters it does not know. `list_calendars` and `list_events` are marked read-only for Claude; `delete_event` is marked destructive.
<!-- load-bearing -->
### Date Rules
These apply to all tools.
| Rule | Explanation |
|---|---|
| Formats | `YYYY-MM-DD` (date only) or `YYYY-MM-DDTHH:MM` (date and time), e.g. `2026-11-08` or `2026-11-08T10:00`. |
| Time zone | Always the Mac's **local time**. There is no time zone parameter. |
| Date only | `YYYY-MM-DD` means midnight (00:00) on that day. |
| `list_events` | `end` is **exclusive**. The range ends just before `end`. For "only November 8" use `start: 2026-11-08`, `end: 2026-11-09`. |
| `create_event` and `update_event` for all-day events | `end` is the **last day, inclusive**. An event from Monday to Tuesday gets Tuesday as `end`. |
| Output | Times are always returned as `YYYY-MM-DDTHH:MM`. For all-day events, `end` holds the day **after** the last day, 00:00 (EventKit stores 23:59:59 of the last day; the server turns that into the following midnight). |
Values in another format and impossible dates such as `2026-02-30` are rejected before the calendar is accessed ("Input validation error").
### The event object
`list_events`, `create_event`, `update_event` and `delete_event` always return events in this shape:
| Field | Meaning |
|---|---|
| `uid` | Unique identifier of the event. Needed for updating and deleting. |
| `title` | Title. |
| `start` | Start, `YYYY-MM-DDTHH:MM`. |
| `end` | End, `YYYY-MM-DDTHH:MM`. |
| `allDay` | `true` for all-day events, otherwise `false`. |
| `location` | Location, otherwise empty text. |
| `notes` | Notes, otherwise empty text. |
| `calendar` | Name of the calendar. |
| `recurring` | `true` if the event belongs to a recurring series. All occurrences share the series `uid`; `update_event` changes the whole series, `delete_event` deletes one occurrence (see there). |
| `warning` | Only from `create_event`, only when the alert could not be set: the reason. |
Example:
```json
{
"uid": "A1B2C3D4-...",
"title": "Dentist",
"start": "2026-11-08T10:00",
"end": "2026-11-08T11:00",
"allDay": false,
"location": "",
"notes": "",
"calendar": "Private",
"recurring": false
}
```
### list_calendars
**Purpose:** Lists all calendars (the same ones Calendar.app shows). Should be called before `create_event` to know the exact calendar name.
**Parameters:** none.
**Returns:** a list with one object per calendar:
```json
[
{ "name": "Private", "writable": true },
{ "name": "Holidays", "writable": false }
]
```
`writable` shows whether you may write to this calendar. Subscribed calendars are usually not writable.
**Example:** "Which calendars do I have?"
### list_events
**Purpose:** Lists events in a time range. It finds all events that touch the range. An event that starts before `start` and ends after it is included as well. A recurring event appears once per occurrence in the range, each with `recurring: true` and the same series `uid`.
| Parameter | Required | Meaning |
|---|---|---|
| `start` | yes | Start of the range. |
| `end` | yes | End of the range, **exclusive**. |
| `calendar` | no | Search only this calendar. Without it: all calendars. |
| `query` | no | Keep only events whose title contains this text (case-insensitive), e.g. `dentist`. At least one character. |
| `limit` | no | Maximum number of events returned, 1 to 500. Default: 100. |
The range may span at most 366 days. A longer range returns the error "Range too long: at most 366 days per call. Split the query."
**Returns:** an object with the event objects sorted by start (`events`) and `truncated`. If more events (after `query`) exist than `limit`, only the first ones are returned, `truncated` is `true` and `note` says how many there were:
```json
{
"events": [{ "uid": "...", "title": "Dentist", "...": "..." }],
"truncated": true,
"note": "Showing the first 100 of 140 events. Narrow the range, pass calendar or query, or raise limit (max 500)."
}
```
**Examples:**
* "What's in my Work calendar next week?"
* "When is my next dentist appointment this year?" (uses `query`)
### create_event
**Purpose:** Creates a new event.
| Parameter | Required | Meaning |
|---|---|---|
| `title` | yes | Title. |
| `start` | yes | Start. For all-day events `YYYY-MM-DD`. |
| `calendar` | yes | Exact name of the target calendar (get it first with `list_calendars`). |
| `end` | no | End. Without it: start plus one hour. For all-day events: last day inclusive; without it a single day. |
| `allDay` | no | `true` for an all-day event. Default: `false`. |
| `location` | no | Location. |
| `notes` | no | Notes. |
| `alarmMinutes` | no | Alert this many minutes before the start. Whole number from 0 to 10080 (one week). |
**Returns:** the event object of the new event.
**Note:** If the alert cannot be set, the event is still created, and the result carries a `warning` field with the reason.
**Examples:**
* "Add 'Dentist' to my Private calendar on November 8 from 10 to 11, with an alert 30 minutes before."
* "Create an all-day event 'Vacation' from November 10 to 14 in the Family calendar."
### update_event
**Purpose:** Changes an existing event. Only the given fields are changed.
| Parameter | Required | Meaning |
|---|---|---|
| `uid` | yes | Identifier of the event (from `list_events`). |
| `title` | no | New title. |
| `start` | no | New start. |
| `end` | no | New end. For all-day events: last day inclusive. |
| `location` | no | New location. Empty text clears the location. |
| `notes` | no | New notes. Empty text clears the notes. |
**Behaviour when moving:**
* Only `start` given: the event is moved, the duration stays the same.
* Only `end` given: only the end changes.
* Both given: both are set.
**Recurring events** (`recurring: true`): `title`, `location` and `notes` change the whole series. `start` and `end` are rejected with "Recurring event: start/end would move the whole series. Change the time in Calendar.app." If the `uid` only resolves to a single changed occurrence instead of the series, the change is rejected with "Recurring event: cannot address the whole series. Change it in Calendar.app."
**Not changeable** with this tool: calendar, all-day yes or no, alerts.
**Returns:** the event object after the change.
**Example:** "Move the dentist appointment on November 8 to 2 pm."
### delete_event
**Purpose:** Deletes an event. **Deletion is permanent.** The tool description asks Claude to check with you first.
| Parameter | Required | Meaning |
|---|---|---|
| `uid` | yes | Identifier of the event (from `list_events`). |
| `occurrence` | no | Recurring events only: `start` of the single occurrence to delete (from `list_events`). |
**Recurring events** (`recurring: true`): without `occurrence` the call is rejected, and Claude asks you whether to delete only that occurrence. With `occurrence` exactly that one occurrence is deleted. A whole series can only be deleted in Calendar.app.
**Returns:** the deleted event as it was before deletion:
```json
{ "deleted": { "uid": "...", "title": "Dentist", "...": "..." } }
```
**Example:** "Delete the test event from this afternoon."
<!-- load-bearing -->
## How It Works
### 1. MCP server over stdio
Claude Desktop starts the server as a separate process. The two talk over stdio. stdio means: Claude writes JSON messages to the process's standard input (stdin), and the server replies via standard output (stdout). There is no network port.
### 2. Each tool runs a JXA script
JXA means "JavaScript for Automation". Through its ObjC bridge, a JXA script can call macOS frameworks such as EventKit, Apple's calendar framework.
Every tool calls the function `jxa()`. It builds a script from three parts and runs it with `osascript -l JavaScript -e <script>`:
1. **HELPERS:** a fixed block of helper functions, the same for all tools. Among them:
* `parse()` reads a date (`YYYY-MM-DD` becomes local midnight).
* `fmt()` outputs a date as `YYYY-MM-DDTHH:MM`.
* `dayAfter()` returns the following day (for all-day events).
* `ekStore()` opens the EventKit store and checks for full Calendars access; without it the tool fails with "No full Calendars access. …".
* `findCal()` looks up a calendar by name (first match).
* `findEvent()` looks up an event directly by `uid` (`calendarItemsWithExternalIdentifier`), without searching the calendars one by one; a series resolves to its first occurrence.
* `toEvent()` builds the event object; `ekToEvent()` fills it from an EventKit event.
* `ekEvents()` reads the events of a range for `list_events`. EventKit expands recurring events into single occurrences.
* `ekSave()` and `ekRemove()` write a change and turn a refusal into the error "Calendar refused the change: …".
* `ekDeleteOccurrence()` deletes one occurrence of a recurring event for `delete_event`.
* `setRange()` sets a new start and end for `update_event` (keeps the duration if only `start` is given).
2. **Parameters:** the values from Claude, written as JSON into the constant `P`, e.g. `const P = {"title":"Dentist", ...};`
3. **Body:** the part specific to each tool.
Limits per call: at most 60 seconds runtime and at most 10 MB output.
### 3. Result and errors
The script returns its result as JSON via stdout. The server reads it and passes it to Claude as formatted text.
If something goes wrong (e.g. calendar not found, timeout, missing permission), the response is marked with `isError`. The text is then the error message from `osascript`, or a short message of the server if `osascript` gives none: timeout, output over the size limit, stopped by a signal, or the exit code. The script itself never appears in the message.
If Claude cancels a request, the running `osascript` is stopped.
### 4. Why parameters are embedded as JSON
The values from Claude (title, notes, location) are not pasted into the script code as text. They are written into the constant `P` with `JSON.stringify`. JSON escapes quotes and special characters correctly by itself.
The benefit: a title like `Meeting "Project" ");` cannot break the script or inject foreign code (injection). It always stays plain text.
## Known Limitations
* **macOS and Claude Desktop only.** The mobile app and Claude on the web do not see local MCP servers.
* **The Mac must be running.** The server reads the Mac's local calendar store. If the Mac is off, there is no access.
* **Recurring events:** A single occurrence can be deleted but not changed; a whole series can be renamed but neither moved nor deleted (see [The event object](#the-event-object)).
* **Time zones:** The Mac's local system time is used. Time zones cannot be specified.
* **Alerts:** If setting the alert fails, the event exists without an alert; the result then carries a `warning` field.
* **Speed:** Measured on a Mac with 14 calendars: every tool call took under 0.4 s, a full year across all calendars included (310 events, 120 KB). Very long ranges with many events can still hit `limit` or the limits from [How It Works](#how-it-works); then pass `calendar` or `query`.
* **Deletion is permanent.** `delete_event` deletes without a trash.
* **Duplicate calendar names:** If two calendars have the same name (e.g. "Calendar" in iCloud and locally), the first one is always used.
* **update_event** can neither change the calendar nor make an event all-day (or the reverse) nor change alerts.
* **System texts:** the reason in "Calendar refused the change: …" comes from macOS and is in the system language.
* **Same event in several calendars:** if an invitation exists in two accounts with the same `uid`, the tools act on the first one EventKit returns.
<!-- load-bearing -->
## Troubleshooting
| Problem | Cause | Fix |
|---|---|---|
| Server is not "Running" in Claude Desktop | Wrong path to `bun` or to `index.mjs` | Check the paths with `which bun` and `ls /path/to/apple-calendar/index.mjs`. Always use full paths. |
| Server is not "Running" | Error in the JSON file (missing comma, typographic quotes) | Check `claude_desktop_config.json`. Commas between entries, only straight `"`. |
| Server is not "Running", log reports missing packages | `bun install` forgotten | Run `bun install` in the project folder, then restart Claude Desktop with `Cmd + Q`. |
| "No full Calendars access. …" | Calendars access is missing, declined or only "Add Events Only" | See [7. Allow calendar access](#7-allow-calendar-access): full access for Claude Desktop, then restart it with `Cmd + Q`. |
| "Calendar not found: …" | The name does not match exactly (case, spaces) | First run "List my calendars" and use the exact name. |
| "Event not found: …" | The `uid` is wrong or the event was already deleted | Find the event again with `list_events` and use the new `uid`. |
| Very slow, or "osascript timed out after 60 s" / "osascript output exceeded 10 MB" | Range too large or too many calendars (limits: see [How It Works](#how-it-works)) | Shrink the range, query only one calendar (pass `calendar`). |
| "Range too long: at most 366 days per call" | `list_events` was asked for more than 366 days | Split the query into several ranges. |
| "Input validation error: …" | A date not in the format `YYYY-MM-DD` / `YYYY-MM-DDTHH:MM` or an impossible date, a parameter the tool does not know, or a value out of range | Rephrase the request; see [Date Rules](#date-rules) and the tool's parameter table. |
| "osascript was stopped (…)", "osascript failed with exit code …" or "spawn osascript ENOENT" | `osascript` was ended from outside, failed without a message, or is missing (not macOS) | Try again; if it repeats, check the Calendars access (see [7. Allow calendar access](#7-allow-calendar-access)). |
| "Recurring event: ask the user whether to delete only one occurrence, …" | `delete_event` without `occurrence` on an event with `recurring: true` | Confirm the single occurrence; delete a whole series in Calendar.app. |
| "Occurrence not found: …" | `occurrence` does not match the occurrence's `start` | Use `start` from a fresh `list_events`. |
| "Not a recurring event: delete it without occurrence." | `occurrence` passed for a single event | Delete without `occurrence`. |
| "Calendar refused the change: …" | EventKit rejected the save or deletion, e.g. read-only calendar or end before start; the reason follows in the system language | Check `writable` with `list_calendars` and the dates; see [Date Rules](#date-rules). |
| "Recurring event: start/end would move the whole series. …" | `update_event` with `start`/`end` on an event with `recurring: true` | Change the time in Calendar.app; title, location and notes can still be changed. |
| "Recurring event: cannot address the whole series. …" | The `uid` resolved to a single changed occurrence, not to the series | Change the series in Calendar.app. |
| Result of `create_event` carries `warning: "Alarm not set: …"` | EventKit refused the alert | Set the alert by hand in Calendar.app. |
| Problems that only occur with Bun | Bun behaves differently from Node in some respect | Switch `command` to Node in the configuration (see [Alternative: Node instead of Bun](#alternative-node-instead-of-bun)). The code does not change. |
## Security
* The server runs locally with your user rights. Through EventKit it has **full access to all calendars** of the Mac: read, create, update, delete.
* **Calendar data is sent to Claude.** Everything a tool returns (titles, locations, notes) ends up in the chat. For calendars with company data, follow your IT guidelines.
* **No credentials** are needed. The server makes **no network access** and opens **no port**.
## Outlook
Ideas without commitment:
* **Single occurrences and series:** EventKit could also change a single occurrence (`EKSpanThisEvent`), move or delete a whole series, and create recurring events. A Swift helper instead of JXA would make that code typed and testable.
* **Mobile use:** From a phone this would only work through a hosted remote MCP server (e.g. via CalDAV with OAuth sign-in) or through a bridge such as Google Calendar. That is not part of this project.
## License
MIT, see `LICENSE`.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues