Skip to main content
Glama
cykriz
by cykriz

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

  2. Requirements

  3. Installation

  4. Configuration

  5. Tools Reference

  6. How It Works

  7. Known Limitations

  8. Troubleshooting

  9. Security

  10. Outlook

  11. License

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, 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?

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

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:

bun --version

If a version number appears, Bun is installed. Then show the exact path:

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.

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.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 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 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-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:

"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

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:

{
  "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

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:

{
  "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:

{ "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>:

  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).

  • 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; 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.

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: 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)

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 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).

"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.

"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). 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.

Related MCP Connectors

Related MCP Servers

  • A
    license
    Not graded
    quality
    C
    maintenance
    Provides 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 npm
    1
    MIT
  • F
    license
    Not graded
    quality
    F
    maintenance
    Provides 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
    -
  • A
    license
    Not graded
    quality
    C
    maintenance
    Enables 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