apple-calendar-mcp
Reads and manages the local Apple Calendar store through EventKit, letting the assistant list calendars, query events in a date range (with title filtering), and create, update, or delete events — including timed, all-day, recurring and alerted events.
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@apple-calendar-mcpWhat events do I have on my calendar tomorrow?"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
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
Related MCP server: Apple EventKit MCP Server
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, maposascripterrors and stoposascripton cancellation (against a stand-inosascript, 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
endthe 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?
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).
Claude Desktop (the desktop app, not the web version and not the mobile app).
Bun 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 |
| The entire server. |
| Name, version and dependencies. The project uses ES modules ( |
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 |
|
| The official MCP library. |
|
| Describes and validates the tool parameters. |
| dev only | Lint, format and typecheck during development. Not needed at runtime. See |
Installation
1. Check Bun
Open the terminal and enter:
bun --versionIf a version number appears, Bun is installed. Then show the exact path:
which bunExpected: /opt/homebrew/bin/bun. You need this path later in the configuration.
If Bun is missing, install it following the instructions on 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.
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
bun index.mjsDeliberately, 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
Open Claude Desktop.
Open the settings, then the "Local MCP servers" section.
Click "Edit configuration". The file
claude_desktop_config.jsonopens.Add the entry from the Configuration section under
"mcpServers".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 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 helps with this.
Configuration
Entry for claude_desktop_config.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:
{
"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-calendarwith 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:
"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.
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.
Date Rules
These apply to all tools.
Rule | Explanation |
Formats |
|
Time zone | Always the Mac's local time. There is no time zone parameter. |
Date only |
|
|
|
|
|
Output | Times are always returned as |
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 |
| Unique identifier of the event. Needed for updating and deleting. |
| Title. |
| Start, |
| End, |
|
|
| Location, otherwise empty text. |
| Notes, otherwise empty text. |
| Name of the calendar. |
|
|
| Only from |
Example:
{
"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:
[
{ "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 |
| yes | Start of the range. |
| yes | End of the range, exclusive. |
| no | Search only this calendar. Without it: all calendars. |
| no | Keep only events whose title contains this text (case-insensitive), e.g. |
| 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:
{
"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 |
| yes | Title. |
| yes | Start. For all-day events |
| yes | Exact name of the target calendar (get it first with |
| no | End. Without it: start plus one hour. For all-day events: last day inclusive; without it a single day. |
| no |
|
| no | Location. |
| no | Notes. |
| 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 |
| yes | Identifier of the event (from |
| no | New title. |
| no | New start. |
| no | New end. For all-day events: last day inclusive. |
| no | New location. Empty text clears the location. |
| no | New notes. Empty text clears the notes. |
Behaviour when moving:
Only
startgiven: the event is moved, the duration stays the same.Only
endgiven: 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 |
| yes | Identifier of the event (from |
| no | Recurring events only: |
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:
{ "deleted": { "uid": "...", "title": "Dentist", "...": "..." } }Example: "Delete the test event from this afternoon."
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>:
HELPERS: a fixed block of helper functions, the same for all tools. Among them:
parse()reads a date (YYYY-MM-DDbecomes local midnight).fmt()outputs a date asYYYY-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 byuid(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 forlist_events. EventKit expands recurring events into single occurrences.ekSave()andekRemove()write a change and turn a refusal into the error "Calendar refused the change: …".ekDeleteOccurrence()deletes one occurrence of a recurring event fordelete_event.setRange()sets a new start and end forupdate_event(keeps the duration if onlystartis given).
Parameters: the values from Claude, written as JSON into the constant
P, e.g.const P = {"title":"Dentist", ...};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).
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
warningfield.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
limitor the limits from How It Works; then passcalendarorquery.Deletion is permanent.
delete_eventdeletes 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.
Troubleshooting
Problem | Cause | Fix |
Server is not "Running" in Claude Desktop | Wrong path to | Check the paths with |
Server is not "Running" | Error in the JSON file (missing comma, typographic quotes) | Check |
Server is not "Running", log reports missing packages |
| Run |
"No full Calendars access. …" | Calendars access is missing, declined or only "Add Events Only" | See 7. Allow calendar access: full access for Claude Desktop, then restart it with |
"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 | Find the event again with |
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) | Shrink the range, query only one calendar (pass |
"Range too long: at most 366 days per call" |
| Split the query into several ranges. |
"Input validation error: …" | A date not in the format | Rephrase the request; see Date Rules and the tool's parameter table. |
"osascript was stopped (…)", "osascript failed with exit code …" or "spawn osascript ENOENT" |
| Try again; if it repeats, check the Calendars access (see 7. Allow calendar access). |
"Recurring event: ask the user whether to delete only one occurrence, …" |
| Confirm the single occurrence; delete a whole series in Calendar.app. |
"Occurrence not found: …" |
| Use |
"Not a recurring event: delete it without occurrence." |
| Delete without |
"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 |
"Recurring event: start/end would move the whole series. …" |
| Change the time in Calendar.app; title, location and notes can still be changed. |
"Recurring event: cannot address the whole series. …" | The | Change the series in Calendar.app. |
Result of | 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 |
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
Related MCP Connectors
Connects ChatGPT to your Apple Calendar via a local Mac agent + Vercel relay
Mac & Windows: let ChatGPT, Claude & Cursor use your email, calendar, iMessage, Teams, files.
Lets Claude, ChatGPT, and Muse read and update your beckett tasks, notes, calendar, and lists.
Calendar API for AI agents: events, availability, Google/Microsoft setup, scheduling, and iCal.
Related MCP Servers
- AlicenseNot gradedqualityCmaintenanceProvides Claude with full access to Apple Calendar on macOS for event management, smart scheduling, and schedule analytics. It enables natural language event creation, conflict detection, and template-based scheduling through AppleScript integration.35 npm1MIT
- FlicenseNot gradedqualityFmaintenanceProvides Claude Desktop with access to Apple Calendar and Reminders on macOS for managing schedules and tasks through natural language. It supports comprehensive operations like creating, editing, and searching events and reminders, along with a custom hashtag-based tagging system.3-
- AlicenseNot gradedqualityDmaintenanceEnables AI assistants to access and interact with macOS Calendar.app events and reminders, including viewing, searching, and filtering calendar data.25 PyPI2MIT
- AlicenseNot gradedqualityCmaintenanceEnables MCP clients to interact with Apple Calendar locally on macOS through EventKit, including listing calendars and events, creating events, and updating events with privacy-conscious safeguards.MIT